1use crate::git_worktree::{
2 create_worktree as create_git_worktree,
3 create_worktree_with_timeout as create_git_worktree_with_timeout,
4 list_worktrees as list_git_worktrees,
5 list_worktrees_with_deadline as list_git_worktrees_with_deadline,
6 paths_equal as worktree_paths_equal, remove_worktree as remove_git_worktree,
7 removal_lookup_path as normalize_worktree_removal_target,
8 resolve_base_commit_with_timeout,
9 run_git_read_bounded, GitCommandOutput, GitWorktreeError, GitWorktreeErrorKind,
10 NativeGitWorktreeSnapshot,
11};
12use crate::host_directory_browser::{
13 browse_host_directories, HostDirectoryBrowseError, HostDirectoryBrowseErrorKind,
14};
15use crate::environment_profiles::{
16 EnvironmentProfileBinding, NodeEnvironmentProfile, MAX_NODE_ENVIRONMENT_PROFILES,
17};
18use crate::bundle_catalog::{BundleCatalog, NodeBundle};
19use self::bundle_delivery::{DeliveryStore, DeliveryStoreError};
20use self::context_pack_store::ContextPackStore;
21use crate::bundle_provider::{
22 bundle_launch_arguments, validate_bundle_binding, BundleProviderLayout,
23};
24use crate::context_pack::{
25 ContextPackCatalog, ContextPackRepository, ContextPackRepositoryFileSource,
26 ContextPackSelectedFileSkipReason, NodeContextPack, CONTEXT_PACK_SELECTED_FILES,
27};
28use crate::harness_mcp_proxy::{
29 HarnessMcpProxyError, HarnessMcpProxyRegistry, PreparedHarnessMcpSpawn,
30 ReviewedHarnessMcpProgram,
31};
32use crate::session_environment::{
33 MaterializationId, MaterializationOwner, MaterializationOwnershipRecord,
34 MaterializationState, NodeSecretResolver, NodeSessionMaterializationProfile,
35 SessionEnvironmentMaterializeError, SessionEnvironmentMaterializer,
36};
37use crate::worktree_service::{
38 exact_created_worktree, exact_owned_worktree, ManagedWorktreeLeaseRecord,
39 ManagedWorktreeProfile, ManagedWorktreeRegistry, ManagedWorktreeSessionHolder,
40};
41use crate::session_registry::{
42 self, validate_display_name, LoadedNodeState, ManagedWorktreeSpawnReplayRecordV10,
43 ManagedWorktreeSpawnReplayStateV10, MAX_MANAGED_SESSION_RECORDS,
44 MAX_MANAGED_WORKTREE_SPAWN_REPLAYS,
45};
46#[cfg(unix)]
47use crate::workspace_file_unix::{
48 create_workspace_directory as create_workspace_directory_on_disk,
49 create_workspace_file as create_workspace_file_on_disk,
50 read_workspace_file as read_workspace_file_from_disk,
51 write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
52 WorkspaceFileReadError, WorkspaceFileReadErrorKind,
53 WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
54 WORKSPACE_ENTRY_CREATE_PENDING,
55};
56#[cfg(windows)]
57use crate::workspace_file_windows::{
58 create_workspace_directory as create_workspace_directory_on_disk,
59 create_workspace_file as create_workspace_file_on_disk,
60 read_workspace_file as read_workspace_file_from_disk,
61 write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
62 WorkspaceFileReadError, WorkspaceFileReadErrorKind,
63 WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
64 WORKSPACE_ENTRY_CREATE_PENDING,
65};
66use crate::platform;
67use crate::provider_runtime::{
68 require_policy, ProviderRuntimeAdmissionError, ProviderRuntimeMonitor,
69 ProviderRuntimeRequirement,
70};
71use crate::spawn_spec::SpawnProfileRegistry;
72use crate::standalone_workspace::{
73 prepare_standalone_workspace, StandaloneWorkspaceError, StandaloneWorkspaceErrorKind,
74};
75use crate::protocol::{
76 read_json_frame_limited_body_timeout, write_json_frame, write_json_frame_limited,
77 validate_node_negotiated_handshake_capacity, validate_provider_contract_manifest,
78 provider_id_is_legacy, AdapterContractRevision, AgentStreamChunkKindV1, AgentStreamChunkV1,
79 AgentStreamInteractionOptionV1, AgentStreamNamedIdV1,
80 CapabilityId, ClientFrame, ClientRole,
81 ControllerState, DeliveryBlobChunkHexV1, DeliveryBlobDigestV1,
82 DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryStageId,
83 AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
84 AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, FrameError,
85 GitCommitDetails, GitDiff, GitDiffMode, GitDiffRequest, GitHistoryPage,
86 GitObjectId, GitCommitSummary, GitSignatureStatus, GitSnapshot, GitStatusEntry,
87 GitWorktreeSnapshot, HarnessMcpActivationDigest, HarnessMcpReservationId,
88 HostDirectoryListing,
89 NodeCompatibilitySupport, NodeEvent,
90 NodeEventEnvelope, NodeFailure, NodeFailureCode, NodeHello, NodeId, NodeIncarnationId,
91 NegotiatedNodeCompatibility, NodeRequest, NodeResponse, NodeSnapshot, OpaqueHostPath,
92 ProtocolRange,
93 ProviderAdapterContractSupport,
94 ProviderContractRevision, ProviderContractSupport, RepositoryPath, RequestEnvelope,
95 ProviderRuntimeStatuses, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
96 ContextPackLineageReceipt, ContextPackBytesRead, ResolvedContextPackReceipt,
97 ResolvedHarnessMcpProxyReceiptV1, ResolvedSpawnReceipt,
98 ResolvedSpawnSpec, ResponseEnvelope,
99 ServerChallenge, ServerFrame, SessionAddress, SessionAgentProgress, SessionKey, SessionMode,
100 ManagedSessionRecord,
101 ManagedSessionState, ManagedWorktreeCleanupFailure, ManagedWorktreeLeaseId,
102 SessionRecordRetentionConfig,
103 ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState, ManagedWorktreeRetention,
104 ManagedWorktreeSpawnReceipt, ManagedWorktreeSpawnRequest,
105 ManagedWorktreeSpawnRequestV2, SessionRecordId,
106 NativeSessionCatalogRoute, NativeSessionSelection, SessionTaskBindingV1,
107 SessionTaskTargetV1, TaskId,
108 BlockAuthorityV1, HarnessMcpLaunchV1, SessionHistorySummaryV1, MAX_ACP_BLOCKED_HELP_BYTES, MAX_ACP_BLOCKED_REASON_BYTES,
109 StateSchemaSupport, WorkspaceEntry, WorkspaceEntryKind, WorktreeProfileId,
110 SpawnContextId, SpawnEnvironmentProfileId, SpawnIdempotencyKey,
111 SpawnProfileDefaults, SpawnRequiredCapabilities, SpawnSpec, SpawnSpecResolveError,
112 WorkspaceFileContent, WorkspaceFileRevision,
113 WorkspaceFileRead, WorkspaceId, WorkspaceInspection, WorkspaceInspectionTruncationV1,
114 WorkspaceSnapshot,
115 WorktreeServiceMode,
116 LaunchInventory, ManagedWorktreeProfileSummary, SpawnProfileSummary,
117 WorktreeProfileInventory, MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
118 DEFAULT_CONTROLLER_LEASE_MS,
119 MAX_CONTROLLER_LEASE_MS, MIN_CONTROLLER_LEASE_MS, NODE_COMPATIBILITY_METADATA_CAPABILITY,
120 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
121 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, NODE_REPOSITORY_PATH_CAPABILITY,
122 NODE_PROVIDER_ID_OPEN_CAPABILITY,
123 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
124 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY, NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
125 NODE_HISTORY_CONTEXT_PACK_CAPABILITY, NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
126 NODE_NATIVE_SESSION_CATALOG_CAPABILITY, NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
127 NODE_NATIVE_SESSION_INDEX_CAPABILITY, NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
128 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
129 NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
130 NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
131 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
132 NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
133 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
134 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
135 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
136 NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
137 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
138 NODE_AGENT_STREAM_EVENTS_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY,
139 NODE_GIT_READ_CAPABILITY, NODE_WORKSPACE_FILE_READ_CAPABILITY,
140 NODE_WORKSPACE_FILE_WRITE_CAPABILITY, NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
141 NODE_WORKTREE_SELECTION_CAPABILITY,
142 BUILD_STAMP, MAX_NODE_CLIENT_FRAME_BYTES, MAX_NODE_HELLO_FRAME_BYTES,
143 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS, MAX_AGENT_PROGRESS_ENTRY_BYTES,
144 MAX_NODE_TERMINAL_BYTES, MAX_NODE_TEXT_BYTES,
145 MAX_REPOSITORY_PATH_BYTES,
146 MAX_GIT_DIFF_BYTES, MAX_GIT_HISTORY_COMMITS, MAX_WORKSPACE_FILE_BYTES,
147 MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE,
148 MAX_WORKSPACE_ROOT_BYTES, NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10,
149 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY, SPAWN_RUNTIME_RAW_PTY_LIFECYCLE,
150 SPAWN_RUNTIME_SEMANTIC_READINESS, SPAWN_RUNTIME_SEMANTIC_RESUME,
151 SPAWN_RUNTIME_STRUCTURED_PROMPT,
152};
153#[cfg(test)]
154use crate::protocol::WorktreeProfileRevision;
155use ring::digest::{digest, SHA256};
156use crate::protocol::correlation::{
157 interaction_correlation, sanitize_progress_tool_label, tool_class_label, tool_correlation,
158 truncate_text,
159};
160use gate4agent_catalog::{builtin_registry, AgentRegistry};
161use gate4agent_handle::{EventSubscription, Gate4AgentHandle, PortDispatchError};
162use gate4agent_runtime_native::{
163 NativeMcpServerLaunchOverlay, NativeInstanceLaunchOverlay,
164 NativeLaunchEnvironmentOverlay,
165 NativeHistoryConfig, NativeLaunchProfileControl, NativeRuntime, NativeRuntimeConfig,
166 NativeSessionCatalogAuthority, NativeSessionCatalogError, NativeSessionPreviewError,
167 ScopedNativeSessionCatalogEntry,
168};
169use gate4agent_runtime_native::shell_efficiency::ShellEfficiencyProfile;
170use std::sync::OnceLock;
171use gate4agent_runtime_native::tick_profile::TickProfileSnapshot;
172use drive_loop_profile::DriveLoopProfiler;
173#[cfg(test)]
174use gate4agent_runtime_native::{HistorySourceLayout, NativeHistoryRoot};
175use gate4agent_node_wire::{
176 auth_proof, negotiated_auth_proof, proofs_match, random_incarnation_id, random_nonce,
177 AuthDirection, OwnerOnlyLocalListener,
178};
179use gate4agent_types::{
180 AdapterBinding, AdapterFamily, AgentAdapterCapabilities, AgentCapabilities, AgentId,
181 AgentInstanceId, AgentReadinessSpec, AgentSpec, AgentTransportCapabilities, ApprovalLevel,
182 DetectionSpec, InitialPromptMode, LaunchSpec, ProcessMatcher, PromptSpec, SpecVerification,
183 CommandEnvelope, CommandId,
184 validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
185 HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
186 HostDecisionAuthority as ProviderHostDecisionAuthority,
187 HostRequestDecision as ProviderHostRequestDecision,
188 InputAction,
189 PromptFraming, PromptPayload, ResumeLaunchRequest,
190 ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
191 ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
192 ProviderSnapshot, ProviderStopReason,
193 ResumeTarget,
194 SessionGeneration, StartRequest,
195 TerminalControl, TerminalText,
196 SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
197 HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
198};
199use std::collections::{BTreeMap, VecDeque};
200use std::ffi::OsString;
201use std::io;
202use std::path::{Path, PathBuf};
203use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
204use std::sync::{Arc, Mutex, RwLock};
205use std::time::{Duration, Instant};
206use thiserror::Error;
207use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
208use tokio::task::{AbortHandle, JoinSet};
209use tokio::time::{sleep, timeout};
210
211mod http_api;
212pub mod node_wake;
213
214mod browser_bridge;
215
216use gate4agent_node_core::drive_loop_profile;
217
218#[path = "bundle_delivery.rs"]
219mod bundle_delivery;
220
221#[path = "context_pack_store.rs"]
222mod context_pack_store;
223
224#[cfg(windows)]
225pub use crate::platform::DEFAULT_NODE_ENDPOINT;
226
227const NODE_EVENT_HISTORY_MAX: usize = 4_096;
228const NODE_BROADCAST_CAPACITY: usize = 1_024;
229const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
230const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
236const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
243const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
244const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
245const MAX_PREAUTH_CONNECTIONS: usize = 32;
246const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
247const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
248const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
249const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
250const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
251const PROVISIONAL_SPAWN_GRACE_MS: u64 = 30_000;
257const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
265const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
273const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
280const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
285const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
286const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
287const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
288const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
289const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
290const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
291const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
292const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
293const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
294const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
295const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
296const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
297const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
300const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
307const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
310const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
311const GIT_STATUS_MAX_ENTRIES: usize = 128;
312const GIT_COMMIT_MAX_ENTRIES: usize = 12;
313const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
314const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
315const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
316const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
317const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
318const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
319const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
320const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
321const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
322const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
323 "provider-session-identity-allocation-failed";
324const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
325const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
326const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
327const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
328const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
329const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
330const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
331 "durable-state-path-semantics-unsupported";
332
333fn opaque_windows_path(value: String) -> OpaqueHostPath {
334 OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
335}
336
337fn agent_progress_from_provider_snapshot(
338 address: SessionAddress,
339 provider: &ProviderSnapshot,
340) -> Option<SessionAgentProgress> {
341 let mut truncated = false;
342 let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
343 let mut active_tool_labels = Vec::with_capacity(
344 provider
345 .active_tools
346 .len()
347 .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
348 );
349 for tool in &provider.active_tools {
350 if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
351 truncated = true;
352 break;
353 }
354 if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
355 active_tool_labels.push(label);
356 }
357 }
358 let attention = provider
359 .interactions
360 .iter()
361 .find(|interaction| {
362 matches!(
363 interaction.status,
364 ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
365 )
366 })
367 .map(|interaction| AgentProgressAttentionV1 {
368 kind: match interaction.interaction_kind {
369 ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
370 ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
371 },
372 tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
373 });
374 let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
375 let usage = (provider.completed_turns > 0
376 || provider.usage.input_tokens > 0
377 || provider.usage.output_tokens > 0
378 || provider.usage.cache_read_tokens > 0
379 || provider.usage.cache_write_tokens > 0
380 || provider.usage.reasoning_tokens > 0)
381 .then_some(AgentProgressUsageV1 {
382 input_tokens: provider.usage.input_tokens,
383 output_tokens: provider.usage.output_tokens,
384 cache_read_tokens: provider.usage.cache_read_tokens,
385 cache_write_tokens: provider.usage.cache_write_tokens,
386 reasoning_tokens: provider.usage.reasoning_tokens,
387 });
388 let progress = AgentProgressV1 {
389 provider_sequence: provider.sequence,
390 activity: provider.activity,
391 completed_turns: provider.completed_turns,
392 usage,
393 current: AgentProgressCurrentV1::from(provider.activity),
394 active_tool_labels,
395 active_tool_count,
396 attention,
397 subagent_count,
398 last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
399 gap_count: provider.gap_count,
400 stale: provider.stale,
401 truncated,
402 };
403 let entry = SessionAgentProgress { address, progress };
404 serde_json::to_vec(&entry)
405 .ok()
406 .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
407 .map(|_| entry)
408}
409
410fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
411 match u32::try_from(count) {
412 Ok(count) => count,
413 Err(_) => {
414 *truncated = true;
415 u32::MAX
416 }
417 }
418}
419
420fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
421 if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
422 return None;
423 }
424 Some(match event {
425 ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
426 ProviderEvent::SessionIdentityObserved { .. } => {
427 AgentProgressEventKindV1::SessionIdentityObserved
428 }
429 ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
430 ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
431 ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
432 ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
433 ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
434 ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
435 ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
436 ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
437 ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
438 ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
439 ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
440 ProviderEvent::InteractionRequested { .. } => {
441 AgentProgressEventKindV1::InteractionRequested
442 }
443 ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
444 ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
445 ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
446 ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
447 ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
448 ProviderEvent::UnrecognizedNotification { .. } => {
449 AgentProgressEventKindV1::UnrecognizedNotification
450 }
451 ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
452 ProviderEvent::UserMessage { .. }
463 | ProviderEvent::Plan { .. }
464 | ProviderEvent::AvailableCommandsUpdated { .. }
465 | ProviderEvent::ModeChanged { .. }
466 | ProviderEvent::SessionInfoUpdated { .. }
467 | ProviderEvent::UsageUpdated { .. }
468 | ProviderEvent::ConfigOptionsUpdated { .. } => {
469 AgentProgressEventKindV1::UnrecognizedNotification
470 }
471 })
472}
473
474fn provider_family_is_live(family: AdapterFamily) -> bool {
487 match family {
488 AdapterFamily::PtySemantic
489 | AdapterFamily::Hook
490 | AdapterFamily::ManagedHook
491 | AdapterFamily::Pipe
492 | AdapterFamily::OneShot
493 | AdapterFamily::Acp => true,
494 AdapterFamily::History
495 | AdapterFamily::Resume
496 | AdapterFamily::SessionOptions
497 | AdapterFamily::CapabilityProbe => false,
498 }
499}
500
501fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
502 match by {
503 ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
504 ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
505 ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
506 ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
507 }
508}
509
510fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
522 match authority {
523 BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
524 BlockAuthorityV1::HostPolicy => "denied by host policy",
525 BlockAuthorityV1::HostDeadline => {
526 "denied by host policy after the operator deadline expired"
527 }
528 BlockAuthorityV1::Operator => "declined by the operator",
529 BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
530 BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
531 BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
532 BlockAuthorityV1::ProviderRefusal => "refused by the provider",
533 BlockAuthorityV1::UserRejected => "rejected by the user",
534 BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
535 BlockAuthorityV1::Unknown => "blocked; no reason was reported",
536 }
537}
538
539fn host_request_denied_block(
542 method: &str,
543 by: ProviderHostDecisionAuthority,
544 reason: Option<&str>,
545) -> (String, BlockAuthorityV1, String) {
546 let authority = block_authority(by);
547 let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
548 let (reason_text, _) =
552 truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
553 (tool_class_label(method), authority, reason_text)
554}
555
556fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
575 match kind? {
576 "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
577 "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
578 _ => None,
579 }
580}
581
582fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
594 let trimmed = text.trim();
595 let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
596 return (trimmed.to_owned(), None);
597 };
598 let tail = after_reason.trim_start();
599 let Some((reason, help)) = tail.split_once(". ") else {
600 let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
604 return (reason.to_owned(), None);
605 };
606 let help = help.trim();
607 if help.is_empty() {
608 (reason.trim().to_owned(), None)
609 } else {
610 (reason.trim().to_owned(), Some(help.to_owned()))
611 }
612}
613
614fn tool_class_from_denial_help(help: Option<&str>) -> String {
624 help.and_then(|text| text.split("add a ").nth(1))
625 .and_then(|tail| tail.split(' ').next())
626 .filter(|word| !word.is_empty())
627 .map(str::to_owned)
628 .unwrap_or_else(|| "Tool".to_owned())
629}
630
631fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
639 fn names_quota(s: &str) -> bool {
640 let lower = s.to_ascii_lowercase();
641 lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
642 }
643 vendor_code.is_some_and(names_quota) || names_quota(message)
644}
645
646fn provider_session_end_block(
662 stop_reason: &ProviderStopReason,
663) -> Option<(BlockAuthorityV1, String, String)> {
664 match stop_reason {
665 ProviderStopReason::Refusal => Some((
666 BlockAuthorityV1::ProviderRefusal,
667 "refusal".to_owned(),
668 "the provider ended the turn with stopReason: refusal".to_owned(),
669 )),
670 ProviderStopReason::ProviderError { message, vendor_code, .. }
671 if provider_error_is_quota(vendor_code.as_deref(), message) =>
672 {
673 let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
674 Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
675 }
676 _ => None,
677 }
678}
679
680fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
721 let ControlEventKind::ProviderEvent {
722 sequence: provider_sequence,
723 source,
724 source_sequence,
725 event: provider_event,
726 } = &event.event
727 else {
728 return None;
729 };
730 if !provider_family_is_live(source.family) {
731 return None;
732 }
733 let kind = match provider_event {
734 ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
735 text: text.clone(),
736 is_delta: *is_delta,
737 },
738 ProviderEvent::Thinking { text } => {
739 AgentStreamChunkKindV1::Thinking { text: text.clone() }
740 }
741 ProviderEvent::InteractionRequested {
742 interaction_kind,
743 tool_name,
744 title,
745 prompt,
746 options,
747 ..
748 } => AgentStreamChunkKindV1::InteractionPrompt {
749 correlation_id: interaction_correlation(
750 event.instance_id,
751 event.generation,
752 *provider_sequence,
753 ),
754 interaction_kind: *interaction_kind,
755 tool_name: tool_name.clone(),
756 title: title.clone(),
765 prompt: prompt.clone(),
766 options: options
767 .iter()
768 .map(|option| AgentStreamInteractionOptionV1 {
769 option_id: option.option_id.clone(),
770 name: option.name.clone(),
771 kind: option.kind.clone(),
772 })
773 .collect(),
774 },
775 ProviderEvent::ConfigOptionsUpdated { options } => {
776 AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
777 }
778 ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
789 current: Some(mode_id.clone()),
790 available: available
791 .iter()
792 .map(|mode| AgentStreamNamedIdV1 {
793 id: mode.id.clone(),
794 name: mode.name.clone(),
795 description: mode.description.clone(),
796 })
797 .collect(),
798 },
799 ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
803 let ProviderHostRequestDecision::Denied { by } = decision else {
804 return None;
805 };
806 let (tool_class, authority, reason_text) =
807 host_request_denied_block(method, *by, reason.as_deref());
808 AgentStreamChunkKindV1::Blocked {
809 correlation_id: None,
810 tool_class,
811 authority,
812 reason_kind: None,
813 reason: reason_text,
814 help: None,
815 }
816 }
817 ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
821 let (authority, reason_kind) =
822 provider_tool_block_authority(non_execution_kind.as_deref())?;
823 let (reason, help) = split_provider_denial_text(output);
824 let (reason, _) =
825 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
826 let help = help.map(|help| {
827 truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
828 });
829 AgentStreamChunkKindV1::Blocked {
830 correlation_id: Some(tool_correlation(event, source, id)),
831 tool_class: tool_class_from_denial_help(help.as_deref()),
832 authority,
833 reason_kind: Some(reason_kind.to_owned()),
834 reason,
835 help,
836 }
837 }
838 ProviderEvent::SessionEnded { stop_reason, .. } => {
842 let (authority, reason_kind, reason) =
843 stop_reason.as_ref().and_then(provider_session_end_block)?;
844 let (reason, _) =
845 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
846 AgentStreamChunkKindV1::Blocked {
847 correlation_id: None,
848 tool_class: "Session".to_owned(),
849 authority,
850 reason_kind: Some(reason_kind),
851 reason,
852 help: None,
853 }
854 }
855 ProviderEvent::SessionStarted { .. }
856 | ProviderEvent::SessionIdentityObserved { .. }
857 | ProviderEvent::TurnStarted { .. }
858 | ProviderEvent::WorkingObserved
859 | ProviderEvent::ToolStarted { .. }
860 | ProviderEvent::TurnCompleted { .. }
861 | ProviderEvent::ContextWindowUsage { .. }
862 | ProviderEvent::TurnInterrupted
863 | ProviderEvent::Error { .. }
864 | ProviderEvent::Ready
865 | ProviderEvent::InteractionResolved { .. }
866 | ProviderEvent::SubagentStarted { .. }
867 | ProviderEvent::SubagentStopped { .. }
868 | ProviderEvent::RateLimited { .. }
869 | ProviderEvent::UnrecognizedNotification { .. }
870 | ProviderEvent::UserMessage { .. }
871 | ProviderEvent::Plan { .. }
872 | ProviderEvent::AvailableCommandsUpdated { .. }
873 | ProviderEvent::SessionInfoUpdated { .. }
874 | ProviderEvent::UsageUpdated { .. } => return None,
875 };
876 Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
877}
878
879async fn run_native_session_catalog_operation<T, E, F>(
880 catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
881 operation: &'static str,
882 execute: F,
883) -> Result<T, NodeFailure>
884where
885 T: Send + 'static,
886 E: std::fmt::Display + Send + 'static,
887 F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
888{
889 let task = tokio::task::spawn_blocking(move || {
890 let mut catalog = match catalog.try_lock() {
891 Ok(catalog) => catalog,
892 Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
893 Err(std::sync::TryLockError::WouldBlock) => {
894 return Err(failure(
895 NodeFailureCode::BackendBusy,
896 "native session catalog operation is already running",
897 ));
898 }
899 };
900 execute(&mut catalog).map_err(|error| {
901 failure(
902 NodeFailureCode::BackendOperationFailed,
903 &format!("{operation} failed: {error}"),
904 )
905 })
906 });
907 timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
908 .await
909 .map_err(|_| {
910 failure(
911 NodeFailureCode::BackendBusy,
912 &format!("{operation} exceeded its bounded deadline"),
913 )
914 })?
915 .map_err(|_| {
916 failure(
917 NodeFailureCode::BackendOperationFailed,
918 &format!("{operation} task failed"),
919 )
920 })?
921}
922
923fn windows_path_text(path: &OpaqueHostPath) -> &str {
924 path.as_utf8()
925 .expect("Windows node only creates UTF-8 opaque host paths")
926}
927
928fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
929 path.as_utf8()
930 .map(str::to_owned)
931 .ok_or_else(|| failure(
932 NodeFailureCode::InvalidRequest,
933 "this node requires UTF-8 host paths",
934 ))
935}
936
937fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
938 GitWorktreeSnapshot {
939 path: opaque_windows_path(worktree.path),
940 head: worktree.head,
941 branch: worktree.branch,
942 is_bare: worktree.is_bare,
943 is_main: worktree.is_main,
944 locked: worktree.locked,
945 lock_reason: worktree.lock_reason,
946 prunable: worktree.prunable,
947 prunable_reason: worktree.prunable_reason,
948 workspace_id: worktree.workspace_id.take(),
949 }
950}
951
952#[derive(Clone, Debug, Eq, PartialEq)]
953pub struct WorkspaceConfig {
954 workspace_id: WorkspaceId,
955 canonical_root: String,
956 worktree_service_mode: WorktreeServiceMode,
957 managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
958}
959
960impl WorkspaceConfig {
961 pub fn new(
962 workspace_id: WorkspaceId,
963 root: impl AsRef<Path>,
964 ) -> Result<Self, NodeServerError> {
965 let root = root.as_ref();
966 if !root.is_absolute() {
967 return Err(NodeServerError::InvalidWorkspaceRoot {
968 workspace_id,
969 path: root.to_string_lossy().into_owned(),
970 message: "path is not absolute".to_owned(),
971 });
972 }
973 let canonical = std::fs::canonicalize(root).map_err(|error| {
974 NodeServerError::InvalidWorkspaceRoot {
975 workspace_id: workspace_id.clone(),
976 path: root.to_string_lossy().into_owned(),
977 message: error.to_string(),
978 }
979 })?;
980 if !canonical.is_dir() {
981 return Err(NodeServerError::InvalidWorkspaceRoot {
982 workspace_id,
983 path: root.to_string_lossy().into_owned(),
984 message: "path is not a directory".to_owned(),
985 });
986 }
987 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
988 NodeServerError::InvalidWorkspaceRoot {
989 workspace_id: workspace_id.clone(),
990 path: path.to_string_lossy().into_owned(),
991 message: "canonical path is not valid Unicode".to_owned(),
992 }
993 })?;
994 let canonical_root = platform::normalize_canonical_root(canonical_root);
995 validate_workspace_root(&workspace_id, &canonical_root)?;
996 Ok(Self {
997 workspace_id,
998 canonical_root,
999 worktree_service_mode: WorktreeServiceMode::Manual,
1000 managed_worktree_profiles: BTreeMap::new(),
1001 })
1002 }
1003
1004 pub fn workspace_id(&self) -> &WorkspaceId {
1005 &self.workspace_id
1006 }
1007
1008 pub fn canonical_root(&self) -> &str {
1009 &self.canonical_root
1010 }
1011
1012 pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1013 self.worktree_service_mode = mode;
1014 self
1015 }
1016
1017 pub fn with_managed_worktree_profile(
1018 mut self,
1019 profile: ManagedWorktreeProfile,
1020 ) -> Result<Self, NodeServerError> {
1021 profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1022 NodeServerError::InvalidManagedWorktreeProfile {
1023 workspace_id: self.workspace_id.clone(),
1024 profile_id: profile.profile_id().clone(),
1025 message,
1026 }
1027 })?;
1028 if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1029 return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1030 workspace_id: self.workspace_id,
1031 profile_id: profile.profile_id().clone(),
1032 });
1033 }
1034 if self.managed_worktree_profiles.len()
1035 == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1036 {
1037 return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1038 workspace_id: self.workspace_id,
1039 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1040 });
1041 }
1042 self.managed_worktree_profiles
1043 .insert(profile.profile_id().clone(), profile);
1044 Ok(self)
1045 }
1046}
1047
1048#[derive(Clone)]
1049pub struct NodeServerConfig {
1050 pub endpoint: String,
1051 api_listen: Option<std::net::SocketAddr>,
1052 bridge_listen: Option<std::net::SocketAddr>,
1055 bridge_token: Option<String>,
1058 bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1061 call_home: Option<std::net::SocketAddr>,
1064 call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1068 pub node_id: NodeId,
1069 pub workspaces: Vec<WorkspaceConfig>,
1070 access_token: String,
1071 pub runtime: NativeRuntimeConfig,
1072 session_record_retention: SessionRecordRetentionConfig,
1073 state_path: Option<PathBuf>,
1074 spawn_profiles: SpawnProfileRegistry,
1075 session_environment: Option<NodeSessionEnvironmentConfig>,
1076 history: Option<NativeHistoryConfig>,
1077 harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1078 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1082 #[cfg(feature = "fixture")]
1083 fixture_raw_pty_runtime: bool,
1084}
1085
1086#[derive(Clone)]
1087struct NodeSessionEnvironmentConfig {
1088 root: PathBuf,
1089 resolver: Arc<dyn NodeSecretResolver>,
1090}
1091
1092impl NodeServerConfig {
1093 pub fn new(
1094 endpoint: impl Into<String>,
1095 access_token: impl Into<String>,
1096 node_id: NodeId,
1097 workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1098 ) -> Result<Self, NodeServerError> {
1099 let endpoint = endpoint.into();
1100 let access_token = access_token.into();
1101 if !platform::validate_endpoint(&endpoint) {
1102 return Err(NodeServerError::InvalidEndpoint);
1103 }
1104 if access_token.is_empty() || access_token.len() > 4_096 {
1105 return Err(NodeServerError::InvalidAccessToken);
1106 }
1107 let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1108 if workspaces.is_empty() {
1109 return Err(NodeServerError::NoWorkspaces);
1110 }
1111 let mut workspace_ids = BTreeMap::new();
1112 let mut workspace_roots = BTreeMap::new();
1113 for workspace in &workspaces {
1114 if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1115 && workspace.managed_worktree_profiles.is_empty()
1116 {
1117 return Err(NodeServerError::ManagedWorktreeProfileRequired(
1118 workspace.workspace_id.clone(),
1119 ));
1120 }
1121 if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1122 && !workspace.managed_worktree_profiles.is_empty()
1123 {
1124 return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1125 workspace.workspace_id.clone(),
1126 ));
1127 }
1128 if workspace_ids
1129 .insert(workspace.workspace_id.clone(), ())
1130 .is_some()
1131 {
1132 return Err(NodeServerError::DuplicateWorkspaceId(
1133 workspace.workspace_id.clone(),
1134 ));
1135 }
1136 let root_key = platform::root_identity(&workspace.canonical_root);
1137 if let Some(existing) = workspace_roots.insert(
1138 root_key,
1139 workspace.workspace_id.clone(),
1140 ) {
1141 return Err(NodeServerError::DuplicateWorkspaceRoot {
1142 first: existing,
1143 second: workspace.workspace_id.clone(),
1144 root: workspace.canonical_root.clone(),
1145 });
1146 }
1147 }
1148 Ok(Self {
1149 endpoint,
1150 api_listen: None,
1151 bridge_listen: None,
1152 bridge_token: None,
1153 bridge_underlay: None,
1154 call_home: None,
1155 call_home_identity: None,
1156 node_id,
1157 workspaces,
1158 access_token,
1159 runtime: NativeRuntimeConfig {
1162 worker_poll_interval_ms: 4,
1163 ..NativeRuntimeConfig::default()
1164 },
1165 session_record_retention: SessionRecordRetentionConfig::default(),
1166 state_path: None,
1167 spawn_profiles: SpawnProfileRegistry::default(),
1168 session_environment: None,
1169 history: None,
1170 harness_mcp_helper: None,
1171 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1172 #[cfg(feature = "fixture")]
1173 fixture_raw_pty_runtime: false,
1174 })
1175 }
1176
1177 pub fn with_api_listen(
1178 mut self,
1179 api_listen: std::net::SocketAddr,
1180 ) -> Result<Self, NodeServerError> {
1181 if !api_listen.ip().is_loopback() {
1182 return Err(NodeServerError::InvalidApiListen(api_listen));
1183 }
1184 self.api_listen = Some(api_listen);
1185 Ok(self)
1186 }
1187
1188 pub fn with_bridge_listen(
1192 mut self,
1193 bridge_listen: std::net::SocketAddr,
1194 ) -> Result<Self, NodeServerError> {
1195 if !bridge_listen.ip().is_loopback() {
1196 return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1197 }
1198 self.bridge_listen = Some(bridge_listen);
1199 Ok(self)
1200 }
1201
1202 pub fn with_bridge_token(
1205 mut self,
1206 bridge_token: impl Into<String>,
1207 ) -> Result<Self, NodeServerError> {
1208 let bridge_token = bridge_token.into();
1209 if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1210 return Err(NodeServerError::InvalidBridgeToken);
1211 }
1212 self.bridge_token = Some(bridge_token);
1213 Ok(self)
1214 }
1215
1216 pub fn with_bridge_underlay(
1221 mut self,
1222 bind: std::net::SocketAddr,
1223 transport_key: [u8; 32],
1224 underlay_token: impl Into<String>,
1225 ) -> Result<Self, NodeServerError> {
1226 if self.bridge_listen.is_none() {
1227 return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1228 }
1229 if self.bridge_token.is_none() {
1230 return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1231 }
1232 let underlay_token = underlay_token.into();
1233 if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1234 return Err(NodeServerError::InvalidBridgeUnderlayToken);
1235 }
1236 self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1238 bind,
1239 transport_key,
1240 underlay_token,
1241 });
1242 Ok(self)
1243 }
1244
1245 pub fn with_call_home(
1266 mut self,
1267 relay: std::net::SocketAddr,
1268 ) -> Result<Self, NodeServerError> {
1269 if relay.port() == 0 || relay.ip().is_unspecified() {
1272 return Err(NodeServerError::InvalidCallHome(relay));
1273 }
1274 self.call_home = Some(relay);
1275 Ok(self)
1276 }
1277
1278 pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1280 self.call_home_identity = Some(identity);
1281 self
1282 }
1283
1284 pub fn with_state_path(
1285 mut self,
1286 state_path: impl Into<PathBuf>,
1287 ) -> Result<Self, NodeServerError> {
1288 let state_path = state_path.into();
1289 if !state_path.is_absolute() || state_path.file_name().is_none() {
1290 return Err(NodeServerError::InvalidStatePath(
1291 state_path.to_string_lossy().into_owned(),
1292 ));
1293 }
1294 self.state_path = Some(state_path);
1295 Ok(self)
1296 }
1297
1298 pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1299 self.spawn_profiles = spawn_profiles;
1300 self
1301 }
1302
1303 pub fn with_session_record_retention(
1309 mut self,
1310 retention: SessionRecordRetentionConfig,
1311 ) -> Self {
1312 self.session_record_retention = retention;
1313 self
1314 }
1315
1316 pub fn with_session_environment_materialization(
1317 mut self,
1318 root: impl Into<PathBuf>,
1319 resolver: Arc<dyn NodeSecretResolver>,
1320 ) -> Result<Self, NodeServerError> {
1321 let root = root.into();
1322 if !root.is_absolute() {
1323 return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1324 }
1325 self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1326 Ok(self)
1327 }
1328
1329 pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1332 self.history = Some(history);
1333 self
1334 }
1335
1336 pub fn with_harness_mcp_helper(
1342 mut self,
1343 helper_program: impl Into<PathBuf>,
1344 ) -> Result<Self, NodeServerError> {
1345 self.harness_mcp_helper = Some(
1346 ReviewedHarnessMcpProgram::review(helper_program.into())
1347 .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1348 );
1349 Ok(self)
1350 }
1351
1352 pub fn with_network_allowlist_catalog(
1357 mut self,
1358 catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1359 ) -> Result<Self, NodeServerError> {
1360 if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1361 return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1362 max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1363 });
1364 }
1365 self.network_allowlist_catalog = catalog;
1366 Ok(self)
1367 }
1368
1369 pub fn with_network_allowlist_catalog_file(
1373 self,
1374 path: impl AsRef<std::path::Path>,
1375 ) -> Result<Self, NodeServerError> {
1376 let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1377 .map_err(NodeServerError::from)?;
1378 self.with_network_allowlist_catalog(catalog)
1379 }
1380}
1381
1382pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1383 platform::default_state_path(node_id.as_str())
1384 .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1385}
1386
1387pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1388 platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1389}
1390
1391fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1392 let parent = state_path.parent()?;
1393 let mut name = state_path.file_name()?.to_os_string();
1394 name.push(".delivery-store");
1395 Some(parent.join(name))
1396}
1397
1398fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1399 let parent = state_path.parent()?;
1400 let mut name = state_path.file_name()?.to_os_string();
1401 name.push(".context-pack-store");
1402 Some(parent.join(name))
1403}
1404
1405pub struct NodeServer {
1406 config: NodeServerConfig,
1407 runtime: NativeRuntime,
1408 shared: Arc<NodeShared>,
1409 events: EventSubscription,
1410 state_path_lock: Option<session_registry::StatePathLock>,
1411}
1412
1413#[cfg(feature = "fixture")]
1414#[derive(Clone, Default)]
1415pub struct SpawnManagedWorktreeV2FailureProbe {
1416 latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1417}
1418
1419#[cfg(feature = "fixture")]
1420impl SpawnManagedWorktreeV2FailureProbe {
1421 pub fn latest_code(&self) -> Option<NodeFailureCode> {
1422 *self
1423 .latest_code
1424 .lock()
1425 .unwrap_or_else(|poisoned| poisoned.into_inner())
1426 }
1427
1428 fn record(&self, code: Option<NodeFailureCode>) {
1429 *self
1430 .latest_code
1431 .lock()
1432 .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1433 }
1434}
1435
1436#[cfg(all(feature = "fixture", windows))]
1437struct IsolatedCodexHomeFixtureEnvironment;
1438
1439#[cfg(all(feature = "fixture", windows))]
1440impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1441 for IsolatedCodexHomeFixtureEnvironment
1442{
1443 fn resolve_child_environment(
1444 &self,
1445 ) -> Result<
1446 Vec<gate4agent_catalog::EnvMutation>,
1447 gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1448 > {
1449 Ok(vec![gate4agent_catalog::EnvMutation {
1450 key: OsString::from("USERPROFILE"),
1451 value: None,
1452 }])
1453 }
1454}
1455
1456impl NodeServer {
1457 pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1460 self.shared.m4a_accounts.as_ref()?;
1461 Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1462 }
1463
1464 pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1465 let catalog = active_registry()?;
1466 Self::new_with_registry(config, catalog)
1467 }
1468
1469 #[cfg(feature = "fixture")]
1470 pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1471 let mut spec = gate4agent_testkit::interactive_agent_spec();
1472 spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1473 spec.display_name = "Controlled Claude PTY fixture".to_owned();
1474 Self::new_fixture_with_spec(config, spec)
1475 }
1476
1477 #[cfg(feature = "fixture")]
1478 pub fn new_clean_exit_fixture(
1479 config: NodeServerConfig,
1480 fixture_root: PathBuf,
1481 started_marker: PathBuf,
1482 release_signal: PathBuf,
1483 ) -> Result<Self, NodeServerError> {
1484 let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1485 &fixture_root,
1486 &started_marker,
1487 &release_signal,
1488 )
1489 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1490 spec.id = AgentId::new("claude")
1491 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1492 spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1493 Self::new_fixture_with_spec(config, spec)
1494 }
1495
1496 #[cfg(all(feature = "fixture", windows))]
1506 pub fn new_durable_context_pack_clean_exit_fixture(
1507 config: NodeServerConfig,
1508 fixture_root: PathBuf,
1509 started_marker: PathBuf,
1510 release_signal: PathBuf,
1511 session_id: &str,
1512 ) -> Result<Self, NodeServerError> {
1513 let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1514 &fixture_root,
1515 &started_marker,
1516 &release_signal,
1517 session_id,
1518 )
1519 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1520 let source_id = AgentId::new("claude")
1521 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1522 let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1523 NodeServerError::Registry(
1524 "durable context pack clean-exit fixture source adapter is unavailable"
1525 .to_owned(),
1526 )
1527 })?;
1528 source_spec.capabilities.adapters.history =
1529 source_provider.capabilities.adapters.history.clone();
1530 source_spec.id = source_id;
1531 source_spec.display_name =
1532 "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1533 source_spec.detection.command =
1539 "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1540 source_spec.detection.aliases.clear();
1541
1542 let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1543 target_spec.id = AgentId::new("codex")
1544 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1545 target_spec.display_name =
1546 "Controlled Codex durable context-pack target fixture".to_owned();
1547 target_spec.detection.command =
1548 "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1549 target_spec.detection.aliases.clear();
1550
1551 let catalog = AgentRegistry::new([source_spec, target_spec])
1552 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1553 let mut server = Self::new_with_registry(config, catalog)?;
1554 Arc::get_mut(&mut server.shared)
1555 .expect("fixture Node shared state must remain uniquely owned before run")
1556 .fixture_semantic_hook_policy = true;
1557 Ok(server)
1558 }
1559
1560 #[cfg(feature = "fixture")]
1561 pub fn new_monitoring_hook_fixture(
1562 config: NodeServerConfig,
1563 ) -> Result<Self, NodeServerError> {
1564 let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1565 spec.id = AgentId::new("claude")
1566 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1567 let mut server = Self::new_fixture_with_spec(config, spec)?;
1568 Arc::get_mut(&mut server.shared)
1569 .expect("fixture Node shared state must remain uniquely owned before run")
1570 .fixture_semantic_hook_policy = true;
1571 Ok(server)
1572 }
1573
1574 #[cfg(all(feature = "fixture", windows))]
1575 pub fn new_provider_bundle_argv_fixture(
1576 config: NodeServerConfig,
1577 agent_id: AgentId,
1578 proof_path: PathBuf,
1579 ) -> Result<Self, NodeServerError> {
1580 let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1581 Self::new_fixture_with_spec(config, spec)
1582 }
1583
1584 #[cfg(all(feature = "fixture", windows))]
1585 pub fn new_provider_bundle_argv_hold_fixture(
1586 config: NodeServerConfig,
1587 agent_id: AgentId,
1588 proof_path: PathBuf,
1589 release_signal: PathBuf,
1590 ) -> Result<Self, NodeServerError> {
1591 let spec = Self::provider_bundle_argv_fixture_spec(
1592 agent_id,
1593 proof_path,
1594 Some(release_signal),
1595 )?;
1596 Self::new_fixture_with_spec(config, spec)
1597 }
1598
1599 #[cfg(all(feature = "fixture", windows))]
1600 fn provider_bundle_argv_fixture_spec(
1601 agent_id: AgentId,
1602 proof_path: PathBuf,
1603 release_signal: Option<PathBuf>,
1604 ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1605 if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1606 return Err(NodeServerError::Registry(
1607 "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1608 ));
1609 }
1610 let mut spec = gate4agent_testkit::interactive_agent_spec();
1611 spec.id = agent_id.clone();
1612 spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1613 let provider = builtin_registry()
1614 .get(&agent_id)
1615 .ok_or_else(|| {
1616 NodeServerError::Registry(
1617 "provider bundle argv fixture adapter is unavailable".to_owned(),
1618 )
1619 })?;
1620 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1621 if !proof_path.is_absolute()
1622 || !proof_path
1623 .parent()
1624 .is_some_and(|parent| parent.is_dir())
1625 {
1626 return Err(NodeServerError::Registry(
1627 "provider bundle argv proof path requires an existing absolute parent"
1628 .to_owned(),
1629 ));
1630 }
1631 if let Some(release_signal) = release_signal.as_ref() {
1632 if !release_signal.is_absolute()
1633 || !release_signal
1634 .parent()
1635 .is_some_and(|parent| parent.is_dir())
1636 {
1637 return Err(NodeServerError::Registry(
1638 "provider bundle argv release path requires an existing absolute parent"
1639 .to_owned(),
1640 ));
1641 }
1642 if release_signal.exists() {
1643 return Err(NodeServerError::Registry(
1644 "provider bundle argv release path must not exist".to_owned(),
1645 ));
1646 }
1647 let proof_parent = std::fs::canonicalize(
1648 proof_path
1649 .parent()
1650 .expect("validated provider bundle proof parent"),
1651 )
1652 .map_err(|_| {
1653 NodeServerError::Registry(
1654 "provider bundle argv proof parent is unavailable".to_owned(),
1655 )
1656 })?;
1657 let release_parent = std::fs::canonicalize(
1658 release_signal
1659 .parent()
1660 .expect("validated provider bundle release parent"),
1661 )
1662 .map_err(|_| {
1663 NodeServerError::Registry(
1664 "provider bundle argv release parent is unavailable".to_owned(),
1665 )
1666 })?;
1667 if proof_parent != release_parent {
1668 return Err(NodeServerError::Registry(
1669 "provider bundle argv release path must share the proof parent".to_owned(),
1670 ));
1671 }
1672 }
1673 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1674 NodeServerError::Registry(
1675 "provider bundle argv proof path is not valid Unicode".to_owned(),
1676 )
1677 })?;
1678 let release_signal = release_signal.map(|release_signal| {
1679 release_signal.into_os_string().into_string().map_err(|_| {
1680 NodeServerError::Registry(
1681 "provider bundle argv release path is not valid Unicode".to_owned(),
1682 )
1683 })
1684 }).transpose()?;
1685 let script = spec
1686 .launch
1687 .fixed_args
1688 .pop()
1689 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1690 let bundle_validation = if agent_id.as_str() == "codex" {
1691 "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') }"
1692 } else if agent_id.as_str() == "claude" {
1693 "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 }"
1694 } else {
1695 "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 }"
1696 };
1697 if let Some(release_signal) = release_signal {
1698 spec.launch.fixed_args.push(format!(
1699 "& {{ 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} }}",
1700 ));
1701 spec.launch.fixed_args.push(proof_path);
1702 spec.launch.fixed_args.push(release_signal);
1703 } else {
1704 spec.launch.fixed_args.push(format!(
1705 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1706 ));
1707 spec.launch.fixed_args.push(proof_path);
1708 }
1709 Ok(spec)
1710 }
1711
1712 #[cfg(all(feature = "fixture", windows))]
1713 pub fn new_context_pack_fixture(
1714 config: NodeServerConfig,
1715 proof_path: PathBuf,
1716 ) -> Result<Self, NodeServerError> {
1717 let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1718 Self::new_with_registry(config, catalog)
1719 }
1720
1721 #[cfg(all(feature = "fixture", windows))]
1722 pub fn new_monitoring_context_pack_fixture(
1723 config: NodeServerConfig,
1724 proof_path: PathBuf,
1725 ) -> Result<Self, NodeServerError> {
1726 let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1727 let mut server = Self::new_with_registry(config, catalog)?;
1728 Arc::get_mut(&mut server.shared)
1729 .expect("fixture Node shared state must remain uniquely owned before run")
1730 .fixture_semantic_hook_policy = true;
1731 Ok(server)
1732 }
1733
1734 #[cfg(all(feature = "fixture", windows))]
1735 pub fn install_isolated_codex_home_environment_fixture(
1736 &mut self,
1737 profile_id: SpawnEnvironmentProfileId,
1738 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1739 ) -> Result<(), NodeServerError> {
1740 let provider = AgentId::new("codex")
1741 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1742 let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1743 gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1744 "{}-pty",
1745 profile_id.as_str(),
1746 ))
1747 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1748 provider.clone(),
1749 TransportKind::Pty,
1750 vec![OsString::from("USERPROFILE")],
1751 Arc::new(IsolatedCodexHomeFixtureEnvironment),
1752 )
1753 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1754 let materialization = NodeSessionMaterializationProfile::new(
1755 Vec::new(),
1756 vec![crate::session_environment::NodeSessionPathBinding::new(
1757 "CODEX_HOME",
1758 crate::session_environment::NodeSessionPathClass::ProviderHome,
1759 )
1760 .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1761 Vec::new(),
1762 )
1763 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1764 self.install_environment_profile(
1765 NodeEnvironmentProfile::new_with_materialization(
1766 profile_id,
1767 profile_revision,
1768 provider,
1769 [native_profile],
1770 Some(materialization),
1771 )
1772 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1773 )
1774 }
1775
1776 #[cfg(all(feature = "fixture", windows))]
1777 pub fn new_context_only_proof_fixture(
1778 config: NodeServerConfig,
1779 target_provider: AgentId,
1780 proof_path: PathBuf,
1781 ) -> Result<Self, NodeServerError> {
1782 if target_provider.as_str() != "kimi" {
1783 return Err(NodeServerError::Registry(
1784 "context-only proof fixture requires Kimi".to_owned(),
1785 ));
1786 }
1787 let catalog = Self::context_pack_fixture_catalog(
1788 proof_path,
1789 Some(&target_provider),
1790 false,
1791 )?;
1792 Self::new_with_registry(config, catalog)
1793 }
1794
1795 #[cfg(all(feature = "fixture", windows))]
1796 fn context_pack_fixture_catalog(
1797 proof_path: PathBuf,
1798 context_only_target: Option<&AgentId>,
1799 monitoring_claude_source: bool,
1800 ) -> Result<AgentRegistry, NodeServerError> {
1801 if !proof_path.is_absolute()
1802 || !proof_path
1803 .parent()
1804 .is_some_and(|parent| parent.is_dir())
1805 {
1806 return Err(NodeServerError::Registry(
1807 "context pack proof path requires an existing absolute parent".to_owned(),
1808 ));
1809 }
1810 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1811 NodeServerError::Registry(
1812 "context pack proof path is not valid Unicode".to_owned(),
1813 )
1814 })?;
1815 let providers = ["claude", "codex", "grok", "kimi"];
1816 let builtins = builtin_registry();
1817 let mut specs = Vec::with_capacity(providers.len());
1818 for provider_id in providers {
1819 let agent_id = AgentId::new(provider_id)
1820 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1821 let provider = builtins.get(&agent_id).ok_or_else(|| {
1822 NodeServerError::Registry(
1823 "context pack fixture provider adapter is unavailable".to_owned(),
1824 )
1825 })?;
1826 let monitored_provider =
1827 monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1828 let mut spec = if monitored_provider {
1829 gate4agent_testkit::monitoring_hook_agent_spec()
1830 } else {
1831 gate4agent_testkit::interactive_agent_spec()
1832 };
1833 spec.id = agent_id.clone();
1834 spec.display_name = format!("Controlled {agent_id} context pack fixture");
1835 spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1836 spec.detection.aliases.clear();
1837 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1838 if provider_id == "codex" {
1839 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1840 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1841 })?;
1842 let validation = r#"
1843$bundleArgs = @($bundleArgs)
1844if ($bundleArgs.Count -ne 0) { exit 91 }
1845$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1846$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1847$cwd = (Get-Location).ProviderPath
1848if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1849$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1850if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1851$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1852if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1853$sha = [Security.Cryptography.SHA256]::Create()
1854try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1855if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1856if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1857$contextPath = Join-Path $contextRoot 'context-pack.json'
1858if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1859$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1860if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1861$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1862$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1863$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1864$separator = [IO.Path]::DirectorySeparatorChar
1865if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1866if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1867$rawContext = [IO.File]::ReadAllText($contextPath)
1868if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1869try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1870$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1871$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1872foreach ($property in @($document.PSObject.Properties.Name)) {
1873 if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1874}
1875foreach ($property in $requiredTopLevel) {
1876 if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1877}
1878if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1879if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1880$messages = @($document.retained_messages)
1881if ($messages.Count -eq 0) { exit 107 }
1882foreach ($message in $messages) {
1883 $messageProperties = @($message.PSObject.Properties.Name)
1884 foreach ($property in $messageProperties) {
1885 if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1886 }
1887 if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1888}
1889$sha = [Security.Cryptography.SHA256]::Create()
1890try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1891[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))
1892$rawProof = [IO.File]::ReadAllText($proofPath)
1893if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1894[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1895"#;
1896 spec.launch.fixed_args.push(format!(
1897 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1898 ));
1899 spec.launch.fixed_args.push(proof_path.clone());
1900 } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1901 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1902 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1903 })?;
1904 let validation = r#"
1905$bundleArgs = @($bundleArgs)
1906if ($bundleArgs.Count -ne 0) { exit 91 }
1907$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1908$cwd = (Get-Location).ProviderPath
1909if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1910$contextPath = Join-Path $contextRoot 'context-pack.json'
1911if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1912$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1913if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1914$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1915$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1916$separator = [IO.Path]::DirectorySeparatorChar
1917if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1918try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1919if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1920if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1921$messages = @($document.retained_messages)
1922if ($messages.Count -eq 0) { exit 99 }
1923$hasUser = $false
1924$hasAssistant = $false
1925foreach ($message in $messages) {
1926 if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1927 if ([string]$message.role -ceq 'user') { $hasUser = $true }
1928 elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1929 else { exit 101 }
1930}
1931if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1932$sha = [Security.Cryptography.SHA256]::Create()
1933try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1934[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1935[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1936"#;
1937 spec.launch.fixed_args.push(format!(
1938 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1939 ));
1940 spec.launch.fixed_args.push(proof_path.clone());
1941 }
1942 specs.push(spec);
1943 }
1944 let catalog = AgentRegistry::new(specs)
1945 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1946 Ok(catalog)
1947 }
1948
1949 #[cfg(feature = "fixture")]
1950 pub fn new_exact_launcher_fixture(
1951 config: NodeServerConfig,
1952 agent_id: AgentId,
1953 launcher: String,
1954 ) -> Result<Self, NodeServerError> {
1955 if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1956 return Err(NodeServerError::Registry(
1957 "exact launcher fixture requires a supported provider".to_owned(),
1958 ));
1959 }
1960 if launcher.contains('\0') {
1961 return Err(NodeServerError::Registry(
1962 "exact launcher fixture path contains NUL".to_owned(),
1963 ));
1964 }
1965 let launcher_path = Path::new(&launcher);
1966 if !launcher_path.is_absolute() {
1967 return Err(NodeServerError::Registry(
1968 "exact launcher fixture path must be absolute".to_owned(),
1969 ));
1970 }
1971 let metadata = std::fs::metadata(launcher_path).map_err(|_| {
1972 NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
1973 })?;
1974 if !metadata.is_file() {
1975 return Err(NodeServerError::Registry(
1976 "exact launcher fixture path must be a regular file".to_owned(),
1977 ));
1978 }
1979 let mut spec = builtin_registry()
1980 .get(&agent_id)
1981 .ok_or_else(|| {
1982 NodeServerError::Registry(
1983 "exact launcher fixture provider is unavailable".to_owned(),
1984 )
1985 })?
1986 .clone();
1987 spec.launch.program = launcher;
1988 Self::new_fixture_with_spec(config, spec)
1989 }
1990
1991 #[cfg(feature = "fixture")]
2000 fn h3b_provider_spec_with_program_and_args(
2001 agent_id: AgentId,
2002 provider_program: PathBuf,
2003 fixed_args: Vec<OsString>,
2004 ) -> Result<AgentSpec, NodeServerError> {
2005 if !provider_program.is_absolute() || fixed_args.len() > 32 {
2006 return Err(NodeServerError::Registry(
2007 "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2008 ));
2009 }
2010 let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2011 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2012 })?;
2013 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2014 return Err(NodeServerError::Registry(
2015 "H3B fixture provider must be an exact regular file".to_owned(),
2016 ));
2017 }
2018 let program = provider_program.into_os_string().into_string().map_err(|_| {
2019 NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2020 })?;
2021 let fixed_args = fixed_args.into_iter().map(|argument| {
2022 argument.into_string().map_err(|_| NodeServerError::Registry(
2023 "H3B fixture argument is not Unicode".to_owned(),
2024 ))
2025 }).collect::<Result<Vec<_>, _>>()?;
2026 let argument_bytes = fixed_args.iter()
2027 .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2028 if program.contains('\0')
2029 || fixed_args.iter().any(|argument| argument.contains('\0'))
2030 || !matches!(argument_bytes, Some(total) if total <= 65_536)
2031 {
2032 return Err(NodeServerError::Registry(
2033 "H3B fixture provider or arguments are invalid".to_owned(),
2034 ));
2035 }
2036 let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2037 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2038 })?.clone();
2039 let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2040 .ok_or_else(|| NodeServerError::Registry(
2041 "H3B fixture provider basename is unavailable".to_owned(),
2042 ))?.to_owned();
2043 spec.detection.command = process_name.clone();
2044 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2045 name: process_name,
2046 }];
2047 spec.launch.program = program;
2048 spec.launch.fixed_args = fixed_args;
2049 Ok(spec)
2050 }
2051
2052 #[cfg(feature = "fixture")]
2053 pub fn new_harness_mcp_proxy_fixture(
2054 config: NodeServerConfig,
2055 provider_program: PathBuf,
2056 fixed_args: Vec<OsString>,
2057 harness_mcp_program: PathBuf,
2058 ) -> Result<Self, NodeServerError> {
2059 let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2060 let agent_id = AgentId::new("codex")
2061 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2062 let mut spec =
2063 Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2064 spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2065 config.fixture_raw_pty_runtime = true;
2066 Self::new_fixture_with_spec(config, spec)
2067 }
2068
2069 #[cfg(feature = "fixture")]
2093 pub fn new_harness_mcp_acp_launcher_fixture(
2094 config: NodeServerConfig,
2095 helper_program: PathBuf,
2096 provider: AgentId,
2097 program: PathBuf,
2098 args: Vec<OsString>,
2099 ) -> Result<Self, NodeServerError> {
2100 if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2101 return Err(NodeServerError::Registry(
2102 "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2103 ));
2104 }
2105 let config = config.with_harness_mcp_helper(helper_program)?;
2106 let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2107 let launch = spec.launch.clone();
2108 let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2109 NodeServerError::Registry(
2110 "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2111 .to_owned(),
2112 )
2113 })?;
2114 acp.launch_override = Some(launch);
2122 spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2123 Self::new_fixture_with_spec(config, spec)
2124 }
2125
2126 #[cfg(feature = "fixture")]
2127 pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2128 let mut spec = gate4agent_testkit::interactive_agent_spec();
2129 let claude_id = AgentId::new("claude")
2130 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2131 let claude = builtin_registry()
2132 .get(&claude_id)
2133 .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2134 spec.id = claude_id;
2135 spec.display_name = "Resumable Claude PTY fixture".to_owned();
2136 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2137 name: "claude".to_owned(),
2138 }];
2139 spec.capabilities.transports.pty_adapter = claude
2140 .capabilities
2141 .transports
2142 .pty_adapter
2143 .clone();
2144 spec.capabilities.adapters.resume = claude
2145 .capabilities
2146 .adapters
2147 .resume
2148 .clone();
2149 let script = spec
2150 .launch
2151 .fixed_args
2152 .pop()
2153 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2154 spec.launch.fixed_args.push(format!(
2155 "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2156 ));
2157 Self::new_fixture_with_spec(config, spec)
2158 }
2159
2160 #[cfg(feature = "fixture")]
2161 pub fn new_cmd_cwd_fixture(
2162 config: NodeServerConfig,
2163 command: String,
2164 ) -> Result<Self, NodeServerError> {
2165 let mut spec = gate4agent_testkit::interactive_agent_spec();
2166 spec.id = AgentId::new("claude")
2167 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2168 spec.display_name = "Controlled cmd cwd fixture".to_owned();
2169 spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2170 spec.launch.program = command;
2171 spec.launch.fixed_args.clear();
2172 Self::new_fixture_with_spec(config, spec)
2173 }
2174
2175 #[cfg(feature = "fixture")]
2176 fn new_fixture_with_spec(
2177 config: NodeServerConfig,
2178 spec: gate4agent_types::AgentSpec,
2179 ) -> Result<Self, NodeServerError> {
2180 let catalog = AgentRegistry::new([spec])
2181 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2182 Self::new_with_registry(config, catalog)
2183 }
2184
2185 fn new_with_registry(
2186 config: NodeServerConfig,
2187 catalog: AgentRegistry,
2188 ) -> Result<Self, NodeServerError> {
2189 for profile in config.spawn_profiles.iter() {
2190 if catalog.get(&profile.provider).is_none() {
2191 return Err(NodeServerError::Registry(
2192 "spawn profile references an unavailable provider".to_owned(),
2193 ));
2194 }
2195 }
2196 let input_settle_timeout_ms = catalog
2197 .iter()
2198 .map(|spec| spec.readiness.timeout_ms)
2199 .max()
2200 .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2201 .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2202 .max(MUTATION_SETTLE_TIMEOUT_MS);
2203 let (provider_contracts, provider_adapter_contracts) =
2204 provider_contract_manifest(&catalog)?;
2205 let compatibility_support = node_compatibility_support_for_manifest(
2206 &provider_contracts,
2207 &provider_adapter_contracts,
2208 )?;
2209 validate_node_negotiated_handshake_capacity(&compatibility_support)
2210 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2211 let enabled_providers = provider_contracts
2212 .iter()
2213 .map(|contract| contract.provider.clone())
2214 .collect();
2215 #[cfg(feature = "fixture")]
2216 let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2217 #[cfg(not(feature = "fixture"))]
2218 let fixture_raw_pty_runtime = false;
2219 let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2220 let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2221 crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2222 })).expect("fixture provider catalog remains bounded");
2223 (None, statuses)
2224 } else {
2225 let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2226 let statuses = monitor.collect();
2227 (Some(monitor), statuses)
2228 };
2229 let state_path_lock =
2230 session_registry::StatePathLock::acquire(config.state_path.as_deref())
2231 .map_err(|error| {
2232 durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2233 })?;
2234 let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2235 Some(state_path) => {
2236 let delivery_root = delivery_store_root_for_state_path(state_path)
2237 .ok_or_else(|| {
2238 NodeServerError::DeliveryStore(
2239 "delivery store root could not be derived from state path".to_owned(),
2240 )
2241 })?;
2242 let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2243 let detail = match &error {
2244 DeliveryStoreError::Storage(io_error) => {
2245 format!("{error}: {io_error}")
2246 }
2247 _ => error.to_string(),
2248 };
2249 NodeServerError::DeliveryStore(detail)
2250 })?;
2251 (Some(store), bundles)
2252 }
2253 None => (None, Vec::new()),
2254 };
2255 let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2256 NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2257 })?;
2258 let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2259 Some(state_path) => {
2260 let context_pack_root = context_pack_store_root_for_state_path(state_path)
2261 .ok_or(NodeServerError::ContextPackStore)?;
2262 let (store, packs) = ContextPackStore::open(context_pack_root)
2263 .map_err(|_| NodeServerError::ContextPackStore)?;
2264 (Some(store), packs)
2265 }
2266 None => (None, Vec::new()),
2267 };
2268 let mut durable_context_catalog = ContextPackCatalog::default();
2269 for pack in durable_context_packs {
2270 durable_context_catalog
2271 .insert(pack)
2272 .map_err(|_| NodeServerError::ContextPackStore)?;
2273 }
2274 let session_environment_materializer = config
2275 .session_environment
2276 .as_ref()
2277 .map(|environment| {
2278 SessionEnvironmentMaterializer::new(
2279 environment.root.clone(),
2280 Arc::clone(&environment.resolver),
2281 )
2282 .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2283 })
2284 .transpose()?;
2285 #[cfg(unix)]
2288 let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2289 let native_session_catalog = config.history.clone().map(|history| {
2290 Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2291 });
2292 let (handle, runtime) = match config.history.clone() {
2293 Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2294 None => NativeRuntime::new(catalog, config.runtime),
2295 };
2296 let native_launch_profile_control = runtime.native_launch_profile_control();
2297 let harness_mcp_registry = config
2298 .harness_mcp_helper
2299 .clone()
2300 .map(HarnessMcpProxyRegistry::new);
2301 let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2302 let incarnation_id = random_incarnation_id()
2303 .map_err(NodeServerError::IncarnationIdentity)?;
2304 let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2305 .map_err(durable_state_load_error)?;
2306 let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2307 let managed_worktree_tombstones =
2308 std::mem::take(&mut loaded.managed_worktree_tombstones);
2309 let materializations = std::mem::take(&mut loaded.materializations);
2310 let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2311 let (workspaces, records, persistence_warning) =
2312 merge_durable_state(&config.workspaces, loaded)?;
2313 let mut shared = NodeShared::new_with_incarnation(
2314 handle,
2315 config.access_token.clone(),
2316 config.node_id.clone(),
2317 incarnation_id,
2318 workspaces,
2319 enabled_providers,
2320 provider_runtime_statuses,
2321 provider_runtime_monitor,
2322 provider_contracts,
2323 provider_adapter_contracts,
2324 config.spawn_profiles.clone(),
2325 Some(native_launch_profile_control),
2326 native_session_catalog,
2327 session_environment_materializer,
2328 config.state_path.clone(),
2329 records,
2330 managed_worktrees,
2331 managed_worktree_tombstones,
2332 materializations,
2333 persistence_warning,
2334 input_settle_timeout_ms,
2335 );
2336 shared.harness_mcp_registry = harness_mcp_registry.clone();
2337 shared.session_record_retention = config.session_record_retention;
2338 shared.network_allowlist_catalog =
2339 RwLock::new(config.network_allowlist_catalog.clone());
2340 shared.bundle_catalog = RwLock::new(delivered_catalog);
2341 shared.delivery_store = Mutex::new(delivery_store);
2342 shared.context_catalog = RwLock::new(durable_context_catalog);
2343 shared.context_pack_store = Mutex::new(context_pack_store);
2344 shared.managed_spawn_replays = Mutex::new(
2345 managed_spawn_replays
2346 .into_iter()
2347 .map(|record| (record.idempotency_key.clone(), record))
2348 .collect(),
2349 );
2350 let shared = Arc::new(shared);
2351 if let Some(registry) = harness_mcp_registry {
2352 let weak = Arc::downgrade(&shared);
2353 registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2354 Some(shared) => shared.publish_transient(event),
2355 None => false,
2356 }));
2357 }
2358 if shared.persistence_error().is_none() {
2359 shared.persist_state().map_err(|error| {
2360 durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2361 })?;
2362 }
2363 Ok(Self {
2364 config,
2365 runtime,
2366 shared,
2367 events,
2368 state_path_lock,
2369 })
2370 }
2371
2372 pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2373 NodeShutdownHandle {
2374 shared: Arc::clone(&self.shared),
2375 }
2376 }
2377
2378 #[cfg(feature = "fixture")]
2379 pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2380 &self,
2381 ) -> SpawnManagedWorktreeV2FailureProbe {
2382 self.shared
2383 .fixture_spawn_managed_worktree_v2_failure_probe
2384 .clone()
2385 }
2386
2387 pub fn install_environment_profile(
2392 &mut self,
2393 profile: NodeEnvironmentProfile,
2394 ) -> Result<(), NodeServerError> {
2395 if !self.shared.enabled_providers.contains(profile.provider()) {
2396 return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2397 profile.provider().clone(),
2398 ));
2399 }
2400 let (binding, native_profiles, materialization) = profile.into_parts();
2401 if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2402 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2403 }
2404 let mut profiles = self
2405 .shared
2406 .environment_profiles
2407 .write()
2408 .unwrap_or_else(|poisoned| poisoned.into_inner());
2409 if profiles.contains_key(&binding.id) {
2410 return Err(NodeServerError::DuplicateEnvironmentProfile(
2411 binding.id.clone(),
2412 ));
2413 }
2414 if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2415 return Err(NodeServerError::EnvironmentProfileCapacity {
2416 max: MAX_NODE_ENVIRONMENT_PROFILES,
2417 });
2418 }
2419 for native_id in binding.native_profile_ids() {
2420 if profiles.values().any(|current| {
2421 current.native_profile_ids().any(|current_id| current_id == native_id)
2422 }) {
2423 return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2424 native_id.to_string(),
2425 ));
2426 }
2427 }
2428
2429 let mut installed = Vec::with_capacity(native_profiles.len());
2430 for native_profile in native_profiles {
2431 let native_id = native_profile.id().clone();
2432 if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2433 for installed_id in installed.into_iter().rev() {
2434 let _ = self.runtime.remove_native_launch_profile(&installed_id);
2435 }
2436 return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2437 }
2438 installed.push(native_id);
2439 }
2440 if let Some(materialization) = materialization {
2441 self.shared
2442 .environment_materialization_profiles
2443 .write()
2444 .unwrap_or_else(|poisoned| poisoned.into_inner())
2445 .insert(binding.id.clone(), materialization);
2446 }
2447 profiles.insert(binding.id.clone(), binding);
2448 Ok(())
2449 }
2450
2451 pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2453 if self.shared.session_environment_materializer.is_none() {
2454 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2455 }
2456 let mut catalog = self
2457 .shared
2458 .bundle_catalog
2459 .write()
2460 .unwrap_or_else(|poisoned| poisoned.into_inner());
2461 catalog
2462 .insert_idempotent(bundle)
2463 .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2464 Ok(())
2465 }
2466
2467 pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2468 let shutdown = self.shutdown_handle();
2469 let run = self.run();
2470 tokio::pin!(run);
2471 tokio::select! {
2472 result = &mut run => result,
2473 signal = wait_for_ctrl_signal() => {
2474 signal?;
2475 let shutdown_result = shutdown.request_shutdown().await;
2476 let run_result = run.await;
2477 shutdown_result.and(run_result)
2478 }
2479 }
2480 }
2481
2482 pub async fn run(self) -> Result<(), NodeServerError> {
2483 let NodeServer {
2484 config,
2485 mut runtime,
2486 shared,
2487 events,
2488 state_path_lock: _state_path_lock,
2489 } = self;
2490 shared.reconcile_materializations();
2491 shared.reconcile_managed_worktrees().await;
2492 shared.retire_unavailable_session_records().await;
2493 shared.reconcile_context_pack_exports().await;
2494 let endpoint = config.endpoint.clone();
2498 let call_home = config.call_home;
2499 let call_home_identity = config.call_home_identity.clone();
2500 let api_listen = config.api_listen;
2501 let bridge_listen = config.bridge_listen;
2502 let bridge_underlay = config.bridge_underlay;
2503 let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2504 .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2505 let accept_shared = Arc::clone(&shared);
2506 let api_shared = Arc::clone(&shared);
2507 let bridge_shared = Arc::clone(&shared);
2508 let shutdown_shared = Arc::clone(&shared);
2509 let shutdown_timeout = Duration::from_millis(
2510 config.runtime.provider_shutdown_timeout_ms.max(1),
2511 );
2512 let result = {
2513 let runtime_loop = drive_runtime_until_shutdown(
2514 &mut runtime,
2515 events,
2516 Arc::clone(&shared),
2517 shutdown_timeout,
2518 );
2519 let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2520 let api_loop = http_api::run(api_listen, api_shared);
2521 let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2522 tokio::pin!(runtime_loop);
2523 tokio::pin!(accept_loop);
2524 tokio::pin!(api_loop);
2525 tokio::pin!(bridge_loop);
2526 tokio::select! {
2527 runtime_result = &mut runtime_loop => {
2528 let shutdown_result = shutdown_shared
2529 .begin_shutdown()
2530 .await
2531 .map_err(NodeServerError::ShutdownDispatch);
2532 let (accept_result, api_result, bridge_result) =
2533 tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2534 match runtime_result {
2535 Err(error) => Err(error),
2536 Ok(()) => shutdown_result
2537 .and(accept_result)
2538 .and(api_result.map_err(NodeServerError::HttpApi))
2539 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2540 }
2541 }
2542 accept_result = &mut accept_loop => {
2543 let shutdown_result = shutdown_shared
2544 .begin_shutdown()
2545 .await
2546 .map_err(NodeServerError::ShutdownDispatch);
2547 let (runtime_result, api_result, bridge_result) =
2548 tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2549 match accept_result {
2550 Err(error) => Err(error),
2551 Ok(()) => shutdown_result
2552 .and(runtime_result)
2553 .and(api_result.map_err(NodeServerError::HttpApi))
2554 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2555 }
2556 }
2557 api_result = &mut api_loop => {
2558 let shutdown_result = shutdown_shared
2559 .begin_shutdown()
2560 .await
2561 .map_err(NodeServerError::ShutdownDispatch);
2562 let (runtime_result, accept_result, bridge_result) =
2563 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2564 match api_result {
2565 Err(error) => Err(NodeServerError::HttpApi(error)),
2566 Ok(()) => shutdown_result
2567 .and(runtime_result)
2568 .and(accept_result)
2569 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2570 }
2571 }
2572 bridge_result = &mut bridge_loop => {
2573 let shutdown_result = shutdown_shared
2574 .begin_shutdown()
2575 .await
2576 .map_err(NodeServerError::ShutdownDispatch);
2577 let (runtime_result, accept_result, api_result) =
2578 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2579 match bridge_result {
2580 Err(error) => Err(NodeServerError::BridgeApi(error)),
2581 Ok(()) => shutdown_result
2582 .and(runtime_result)
2583 .and(accept_result)
2584 .and(api_result.map_err(NodeServerError::HttpApi)),
2585 }
2586 }
2587 }
2588 };
2589 if let Ok(mut guard) = shared.delivery_store.lock() {
2596 *guard = None;
2597 }
2598 if let Ok(mut guard) = shared.context_pack_store.lock() {
2599 *guard = None;
2600 }
2601 result
2602 }
2603}
2604
2605async fn drive_runtime_until_shutdown(
2606 runtime: &mut NativeRuntime,
2607 events: EventSubscription,
2608 shared: Arc<NodeShared>,
2609 shutdown_timeout: Duration,
2610) -> Result<(), NodeServerError> {
2611 let _ = shared
2615 .shell_efficiency_profile
2616 .set(runtime.shell_efficiency_profile());
2617 let mut shutdown_started = None;
2618 let mut last_retention_sweep = Instant::now();
2619 let mut last_provisional_reap = Instant::now();
2620 loop {
2621 let runtime_tick_start = Instant::now();
2628 let tick_result = runtime.tick().await;
2629 let runtime_tick_elapsed = runtime_tick_start.elapsed();
2630
2631 let event_drain_start = Instant::now();
2632 for outcome in &tick_result.command_outcomes {
2640 if let Err(error) = &outcome.result {
2641 shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2642 tracing::warn!(
2643 command_id = outcome.command_id.0,
2644 cause = %error,
2645 "control command rejected"
2646 );
2647 shared.record_rejected_command(
2648 outcome.command_id,
2649 error.to_string(),
2650 error.is_unsupported_transport(),
2651 );
2652 }
2653 }
2654 let mut events_drained: u64 = 0;
2655 while let Ok(event) = events.try_recv() {
2656 events_drained += 1;
2657 let clean_exit = matches!(
2658 event.event,
2659 ControlEventKind::Exited { exit_code: Some(0), forced: false },
2660 );
2661 let instance_id = event.instance_id;
2662 let generation = event.generation;
2663 if clean_exit {
2675 if let Some((record_id, session)) =
2676 shared.managed_record_export_target_for_instance(instance_id, generation)
2677 {
2678 let export_shared = Arc::clone(&shared);
2679 tokio::spawn(async move {
2680 export_shared
2681 .reconcile_context_pack_export_for_record(&record_id, session)
2682 .await;
2683 });
2684 }
2685 }
2686 shared.publish_control(event);
2687 }
2688 let event_drain_elapsed = event_drain_start.elapsed();
2689
2690 let publish_terminal_frames_start = Instant::now();
2691 shared.publish_terminal_frames();
2692 let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2693
2694 shared
2700 .native_session_gauge
2701 .store(runtime.active_native_sessions(), Ordering::Relaxed);
2702 *shared
2703 .runtime_tick_profile
2704 .lock()
2705 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2706
2707 let remainder_start = Instant::now();
2708 if shared.shutdown.load(Ordering::Acquire) {
2709 let started = *shutdown_started.get_or_insert_with(Instant::now);
2710 let snapshot = shared.handle.snapshot();
2711 let sessions_settled = snapshot.sessions.iter().all(|session| {
2712 !matches!(
2713 session.status,
2714 gate4agent_types::SessionStatus::Starting
2715 | gate4agent_types::SessionStatus::Running
2716 | gate4agent_types::SessionStatus::Stopping
2717 )
2718 });
2719 if runtime.active_native_sessions() == 0 && sessions_settled {
2720 return Ok(());
2721 }
2722 if started.elapsed() >= shutdown_timeout {
2723 return Err(NodeServerError::ShutdownTimedOut {
2724 active_native_sessions: runtime.active_native_sessions(),
2725 });
2726 }
2727 }
2728 let remainder_elapsed = remainder_start.elapsed();
2729
2730 {
2731 let mut profile = shared
2732 .drive_loop_profile
2733 .lock()
2734 .unwrap_or_else(|poisoned| poisoned.into_inner());
2735 profile.record_runtime_tick(runtime_tick_elapsed);
2736 profile.record_event_drain(event_drain_elapsed);
2737 profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2738 profile.record_remainder(remainder_elapsed);
2739 profile.note_iteration(Instant::now());
2740 }
2741
2742 shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2754 if tick_result.observations_applied == 0 && events_drained == 0 {
2755 shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2756 }
2757
2758 if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2763 shared.retire_unavailable_session_records().await;
2764 last_retention_sweep = Instant::now();
2765 }
2766 if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2768 for address in shared.take_due_provisional_spawns() {
2769 shared.reap_provisional_spawn(address).await;
2770 }
2771 last_provisional_reap = Instant::now();
2772 }
2773
2774 let hot = shared
2777 .pty_input_marks
2778 .lock()
2779 .unwrap_or_else(|poisoned| poisoned.into_inner())
2780 .values()
2781 .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2782 if hot {
2783 sleep(Duration::from_millis(1)).await;
2784 } else {
2785 tokio::select! {
2786 _ = sleep(Duration::from_millis(10)) => {}
2787 _ = shared.pty_input_wake.notified() => {}
2788 }
2789 }
2790 }
2791}
2792
2793#[derive(Clone)]
2794pub struct NodeShutdownHandle {
2795 shared: Arc<NodeShared>,
2796}
2797
2798impl NodeShutdownHandle {
2799 pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2800 self.shared
2801 .begin_shutdown()
2802 .await
2803 .map_err(NodeServerError::ShutdownDispatch)
2804 }
2805}
2806
2807async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2808 #[cfg(windows)]
2809 {
2810 let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2811 let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2812 return tokio::select! {
2813 signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2814 signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2815 };
2816 }
2817 #[cfg(unix)]
2818 {
2819 let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2820 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2821 tokio::select! {
2822 signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2823 signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2824 }
2825 }
2826}
2827
2828fn shell_host_spec() -> AgentSpec {
2842 AgentSpec {
2843 id: AgentId::new("shell").expect("shell agent id"),
2844 revision: "hatchery:shell-pty-1".to_owned(),
2845 display_name: "Host shell".to_owned(),
2846 detection: DetectionSpec {
2847 command: "bash".to_owned(),
2848 aliases: vec!["sh".to_owned(), "shell".to_owned()],
2849 required_commands: Vec::new(),
2850 unsupported_platforms: Vec::new(),
2851 },
2852 launch: LaunchSpec {
2853 program: "/bin/bash".to_owned(),
2855 fixed_args: vec!["-i".to_owned()],
2856 },
2857 expected_processes: vec![ProcessMatcher::Exact {
2858 name: "bash".to_owned(),
2859 }],
2860 prompt: PromptSpec {
2861 initial: InitialPromptMode::None,
2862 native_draft: None,
2863 },
2864 readiness: AgentReadinessSpec::default(),
2865 capabilities: AgentCapabilities {
2866 agent_commands: None,
2867 transports: AgentTransportCapabilities {
2868 pty: true,
2869 pty_adapter: None,
2870 pipe: None,
2871 acp: None,
2872 },
2873 adapters: AgentAdapterCapabilities::default(),
2874 },
2875 verification: SpecVerification::Gate4AgentVerified,
2876 }
2877}
2878
2879fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2880 let mut specs = vec![shell_host_spec()];
2881 specs.extend(
2882 builtin_registry()
2883 .iter()
2884 .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2885 .cloned(),
2886 );
2887 AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2888}
2889
2890fn provider_contract_manifest(
2891 catalog: &AgentRegistry,
2892) -> Result<
2893 (
2894 Vec<ProviderContractSupport>,
2895 Vec<ProviderAdapterContractSupport>,
2896 ),
2897 NodeServerError,
2898> {
2899 let mut provider_contracts = Vec::new();
2900 let mut provider_adapter_contracts = Vec::new();
2901 for spec in catalog.iter() {
2902 let provider = spec.id.clone();
2903 let revision = ProviderContractRevision::new(spec.revision.clone())
2904 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2905 provider_contracts.push(ProviderContractSupport {
2906 provider: provider.clone(),
2907 revision,
2908 });
2909 for (family, binding) in declared_adapter_bindings(spec)? {
2910 let revision = AdapterContractRevision::new(binding.revision.clone())
2911 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2912 provider_adapter_contracts.push(ProviderAdapterContractSupport {
2913 provider: provider.clone(),
2914 family,
2915 adapter_id: binding.id.clone(),
2916 revision,
2917 });
2918 }
2919 }
2920 validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2921 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2922 Ok((provider_contracts, provider_adapter_contracts))
2923}
2924
2925fn declared_adapter_bindings(
2926 spec: &AgentSpec,
2927) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2928 let mut bindings = Vec::new();
2929 push_declared_binding(
2930 &mut bindings,
2931 AdapterFamily::PtySemantic,
2932 spec.capabilities.transports.pty_adapter.as_ref(),
2933 )?;
2934 push_declared_binding(
2935 &mut bindings,
2936 AdapterFamily::Pipe,
2937 spec.capabilities.adapters.pty_sidecar.as_ref(),
2938 )?;
2939 let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2940 let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2941 gate4agent_types::PipeProtocol::SemanticNdjson
2942 | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2943 gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2944 });
2945 push_declared_binding(
2946 &mut bindings,
2947 AdapterFamily::Pipe,
2948 pipe_transport
2949 .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2950 .map(|transport| &transport.adapter),
2951 )?;
2952 push_declared_binding(
2953 &mut bindings,
2954 AdapterFamily::Acp,
2955 spec.capabilities
2956 .transports
2957 .acp
2958 .as_ref()
2959 .map(|transport| &transport.adapter),
2960 )?;
2961 push_declared_binding(
2962 &mut bindings,
2963 AdapterFamily::Hook,
2964 spec.capabilities.adapters.hook.as_ref(),
2965 )?;
2966 push_declared_binding(
2967 &mut bindings,
2968 AdapterFamily::ManagedHook,
2969 spec.capabilities.adapters.managed_hook.as_ref(),
2970 )?;
2971 push_declared_binding(
2972 &mut bindings,
2973 AdapterFamily::OneShot,
2974 pipe_transport
2975 .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
2976 .map(|transport| &transport.adapter),
2977 )?;
2978 push_declared_binding(
2979 &mut bindings,
2980 AdapterFamily::OneShot,
2981 spec.capabilities.adapters.one_shot.as_ref(),
2982 )?;
2983 push_declared_binding(
2984 &mut bindings,
2985 AdapterFamily::History,
2986 spec.capabilities.adapters.history.as_ref(),
2987 )?;
2988 push_declared_binding(
2989 &mut bindings,
2990 AdapterFamily::Resume,
2991 spec.capabilities.adapters.resume.as_ref(),
2992 )?;
2993 push_declared_binding(
2994 &mut bindings,
2995 AdapterFamily::SessionOptions,
2996 spec.capabilities.adapters.session_options.as_ref(),
2997 )?;
2998 push_declared_binding(
2999 &mut bindings,
3000 AdapterFamily::CapabilityProbe,
3001 spec.capabilities.adapters.capability_probe.as_ref(),
3002 )?;
3003 Ok(bindings)
3004}
3005
3006fn push_declared_binding<'a>(
3007 bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3008 family: AdapterFamily,
3009 binding: Option<&'a AdapterBinding>,
3010) -> Result<(), NodeServerError> {
3011 let Some(binding) = binding else {
3012 return Ok(());
3013 };
3014 if let Some((_, existing)) = bindings
3015 .iter()
3016 .find(|(existing_family, _)| *existing_family == family)
3017 {
3018 if *existing == binding {
3019 return Ok(());
3020 }
3021 return Err(NodeServerError::ProviderContractManifest(format!(
3022 "provider declares conflicting {family:?} adapter bindings"
3023 )));
3024 }
3025 bindings.push((family, binding));
3026 Ok(())
3027}
3028
3029fn merge_durable_state(
3030 configured: &[WorkspaceConfig],
3031 mut loaded: LoadedNodeState,
3032) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3033 let mut workspaces = configured
3034 .iter()
3035 .cloned()
3036 .map(|workspace| (workspace.workspace_id.clone(), workspace))
3037 .collect::<BTreeMap<_, _>>();
3038 let mut roots = workspaces
3039 .values()
3040 .map(|workspace| {
3041 (
3042 platform::root_identity(&workspace.canonical_root),
3043 workspace.workspace_id.clone(),
3044 )
3045 })
3046 .collect::<BTreeMap<_, _>>();
3047 let mut warning = loaded
3048 .warning
3049 .take()
3050 .map(|message| session_registry::sanitized_persistence_summary(&message));
3051
3052 for (workspace_id, canonical_root) in loaded.workspaces {
3053 if let Some(existing) = workspaces.get(&workspace_id) {
3054 if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3055 return Err(NodeServerError::DurableState(io::Error::new(
3056 io::ErrorKind::InvalidData,
3057 DURABLE_STATE_CONFLICT_ERROR,
3058 )));
3059 }
3060 continue;
3061 }
3062 if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3063 return Err(NodeServerError::DurableState(io::Error::new(
3064 io::ErrorKind::InvalidData,
3065 DURABLE_STATE_CONFLICT_ERROR,
3066 )));
3067 }
3068 if !Path::new(&canonical_root).is_dir() {
3069 warning.get_or_insert_with(|| {
3070 session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3071 });
3072 continue;
3073 }
3074 let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3075 NodeServerError::DurableState(io::Error::new(
3076 io::ErrorKind::InvalidData,
3077 DURABLE_STATE_CONFLICT_ERROR,
3078 ))
3079 })?;
3080 roots.insert(
3081 platform::root_identity(&workspace.canonical_root),
3082 workspace.workspace_id.clone(),
3083 );
3084 workspaces.insert(workspace_id, workspace);
3085 }
3086
3087 for record in &mut loaded.records {
3088 match workspaces.get(&record.workspace_id) {
3089 Some(workspace)
3090 if platform::roots_equal(
3091 &workspace.canonical_root,
3092 windows_path_text(&record.canonical_root),
3093 ) => {}
3094 Some(_) => {
3095 return Err(NodeServerError::DurableState(io::Error::new(
3096 io::ErrorKind::InvalidData,
3097 DURABLE_STATE_CONFLICT_ERROR,
3098 )));
3099 }
3100 None => {
3101 record.state = ManagedSessionState::Unavailable;
3102 record.active_session = None;
3103 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3104 }
3105 }
3106 }
3107 Ok((workspaces.into_values().collect(), loaded.records, warning))
3108}
3109
3110struct RejectedCommandReason {
3113 message: String,
3114 unsupported_transport: bool,
3121 expires_at: Instant,
3122}
3123
3124struct NodeShared {
3125 handle: Gate4AgentHandle,
3126 access_token: String,
3127 node_id: NodeId,
3128 incarnation_id: NodeIncarnationId,
3129 started_at_unix_ms: u64,
3130 workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3131 worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3132 managed_worktree_profiles:
3133 BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3134 managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3135 enabled_providers: Vec<AgentId>,
3136 provider_runtime_statuses: ProviderRuntimeStatuses,
3137 provider_runtime_status_updates:
3138 RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3139 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3140 provider_contracts: Vec<ProviderContractSupport>,
3141 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3142 spawn_profiles: SpawnProfileRegistry,
3143 environment_profiles:
3144 RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3145 environment_materialization_profiles:
3146 RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3147 network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3155 #[cfg(feature = "dig2-station-probe")]
3159 browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3160 bundle_catalog: RwLock<BundleCatalog>,
3161 delivery_store: Mutex<Option<DeliveryStore>>,
3162 context_pack_store: Mutex<Option<ContextPackStore>>,
3163 context_catalog: RwLock<ContextPackCatalog>,
3164 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3165 harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3166 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3167 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3168 m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3170 m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3172 materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3173 spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3174 managed_spawn_replays:
3175 Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3176 controller: Mutex<Option<ControllerLease>>,
3177 provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3180 history: Mutex<NodeEventHistory>,
3181 event_tx: broadcast::Sender<NodeEventEnvelope>,
3182 terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3183 agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3191 harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3200 terminal_frame_watermarks:
3201 Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3202 pty_input_wake: tokio::sync::Notify,
3204 pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3207 next_connection_id: AtomicU64,
3208 next_instance_id: AtomicU64,
3209 next_command_id: AtomicU64,
3210 shutdown: AtomicBool,
3211 shutdown_notify: Notify,
3212 preauth_slots: Arc<Semaphore>,
3213 authenticated_slots: Arc<Semaphore>,
3214 inspection_slots: Arc<Semaphore>,
3215 mutation_gate: AsyncMutex<()>,
3216 session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3217 session_records: Mutex<SessionRecords>,
3218 state_path: Option<PathBuf>,
3219 persistence_error: RwLock<Option<String>>,
3220 state_transaction: Mutex<()>,
3221 input_settle_timeout_ms: u64,
3222 drive_loop_profile: Mutex<DriveLoopProfiler>,
3226 runtime_tick_profile: Mutex<TickProfileSnapshot>,
3232 shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3244 native_session_gauge: AtomicUsize,
3251 connection_loop_iterations: AtomicU64,
3262 connection_events_sent: AtomicU64,
3263 rejected_commands_total: AtomicU64,
3272 rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3292 drive_loop_iterations_total: AtomicU64,
3293 drive_loop_iterations_idle: AtomicU64,
3294 session_record_retention: SessionRecordRetentionConfig,
3298 retired_records_total: AtomicU64,
3304 #[cfg(feature = "fixture")]
3305 fixture_semantic_hook_policy: bool,
3306 #[cfg(feature = "fixture")]
3307 fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3308}
3309
3310impl Drop for NodeShared {
3311 fn drop(&mut self) {
3312 SessionLifecycleHook::on_shutdown(self);
3313 }
3314}
3315
3316#[derive(Clone, Debug, Eq, PartialEq)]
3317struct SessionBinding {
3318 workspace_id: WorkspaceId,
3319 generation: SessionGeneration,
3320 runtime_policy: ProviderRuntimePolicy,
3321 pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3322 record_id: Option<SessionRecordId>,
3323 managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3324 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3325 bundle: Option<ResolvedBundleReceipt>,
3326 context: Option<ResolvedContextPackReceipt>,
3327 materialization_id: Option<MaterializationId>,
3328}
3329
3330#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3331enum SpawnRecordPolicy {
3332 ProviderIdentityOnly,
3333 Always,
3334}
3335
3336struct NativeEnvironmentSelectionGuard {
3337 control: Option<NativeLaunchProfileControl>,
3338 instance_id: AgentInstanceId,
3339}
3340
3341struct NativeInstanceOverlayGuard {
3342 control: Option<NativeLaunchProfileControl>,
3343 instance_id: AgentInstanceId,
3344}
3345
3346struct NativeHarnessMcpOverlayGuard {
3347 control: NativeLaunchProfileControl,
3348 instance_id: AgentInstanceId,
3349}
3350
3351impl NativeHarnessMcpOverlayGuard {
3352 fn retain(self) { std::mem::forget(self); }
3353}
3354
3355impl Drop for NativeHarnessMcpOverlayGuard {
3356 fn drop(&mut self) {
3357 self.control
3358 .clear_native_mcp_server_launch_overlay(self.instance_id);
3359 }
3360}
3361
3362impl NativeInstanceOverlayGuard {
3363 fn retain(mut self) {
3364 self.control = None;
3365 }
3366}
3367
3368impl Drop for NativeInstanceOverlayGuard {
3369 fn drop(&mut self) {
3370 if let Some(control) = self.control.as_ref() {
3371 control.clear_native_instance_launch_overlay(self.instance_id);
3372 }
3373 }
3374}
3375
3376enum PreparedNativeLaunchOverlay {
3377 Environment(NativeLaunchEnvironmentOverlay),
3378 Instance(NativeInstanceLaunchOverlay),
3379}
3380
3381impl NativeEnvironmentSelectionGuard {
3382 fn retain(mut self) {
3383 self.control = None;
3384 }
3385}
3386
3387impl Drop for NativeEnvironmentSelectionGuard {
3388 fn drop(&mut self) {
3389 if let Some(control) = self.control.as_ref() {
3390 control.clear_native_launch_profile_selection(self.instance_id);
3391 }
3392 }
3393}
3394
3395struct SessionMaterializationGuard<'a> {
3396 shared: &'a NodeShared,
3397 id: Option<MaterializationId>,
3398 provider_home: Option<PathBuf>,
3401}
3402
3403impl SessionMaterializationGuard<'_> {
3404 fn id(&self) -> Option<&MaterializationId> {
3405 self.id.as_ref()
3406 }
3407
3408 fn provider_home(&self) -> Option<&Path> {
3409 self.provider_home.as_deref()
3410 }
3411
3412 fn retain(mut self) {
3413 self.id = None;
3414 }
3415}
3416
3417impl Drop for SessionMaterializationGuard<'_> {
3418 fn drop(&mut self) {
3419 if let Some(id) = self.id.take() {
3420 let _ = self.shared.cleanup_materialization(&id);
3421 }
3422 }
3423}
3424
3425
3426trait SessionLifecycleHook {
3429 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3431 fn on_session_ended(&self, address: &SessionAddress);
3433 fn on_shutdown(&self);
3435 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3437 fn cleanup_session_materialization(
3439 &self,
3440 address: &SessionAddress,
3441 binding: &SessionBinding,
3442 ) -> Result<(), NodeFailure>;
3443}
3444
3445impl SessionLifecycleHook for NodeShared {
3446 fn on_session_ended(&self, address: &SessionAddress) {
3447 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3448 registry.revoke_session(address);
3449 }
3450 if let Some(control) = self.native_launch_profile_control.as_ref() {
3451 control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3452 }
3453 }
3454
3455 fn on_shutdown(&self) {
3456 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3457 let instances = registry.shutdown();
3458 if let Some(control) = self.native_launch_profile_control.as_ref() {
3459 for instance_id in instances {
3460 control.clear_native_mcp_server_launch_overlay(instance_id);
3461 }
3462 }
3463 }
3464 }
3465
3466 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3467 if let Some(control) = self.native_launch_profile_control.as_ref() {
3468 control.clear_native_mcp_server_launch_overlay(instance_id);
3469 }
3470 }
3471
3472 fn cleanup_session_materialization(
3473 &self,
3474 address: &SessionAddress,
3475 binding: &SessionBinding,
3476 ) -> Result<(), NodeFailure> {
3477 self.cleanup_session_owned_materialization(address, binding)
3478 }
3479
3480 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3481 #[cfg(feature = "dig2-station-probe")]
3484 {
3485 crate::dig2_station_lease::release_lease_for_holder(
3486 &self.browser_station_leases,
3487 address.session.instance_id,
3488 );
3489 }
3490 if let Some(control) = self.native_launch_profile_control.as_ref() {
3491 control.clear_native_mcp_server_launch_overlay(
3492 address.session.instance_id,
3493 );
3494 }
3495 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3496 registry.revoke_session(address);
3497 }
3498 if removed
3499 .is_some_and(|binding| {
3500 binding.environment_profile.is_some()
3501 || binding.bundle.is_some()
3502 || binding.context.is_some()
3503 })
3504 {
3505 if let Some(control) = self.native_launch_profile_control.as_ref() {
3506 control.clear_native_launch_profile_selection(
3507 address.session.instance_id,
3508 );
3509 control.clear_native_instance_launch_overlay(
3510 address.session.instance_id,
3511 );
3512 }
3513 }
3514 }
3515}
3516
3517struct SpawnPrepareRequest<'r> {
3518 instance_id: AgentInstanceId,
3519 address: &'r SessionAddress,
3520 provider: &'r AgentId,
3521 mode: SessionMode,
3522 environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3523 bundle: Option<&'r ResolvedBundleReceipt>,
3524 context: Option<&'r ResolvedContextPackReceipt>,
3525 managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3526 harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3527 approval_level: ApprovalLevel,
3528 network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3529 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3530}
3531
3532struct PreparedSpawn<'a> {
3536 instance_overlay: Option<NativeInstanceOverlayGuard>,
3537 environment_selection: Option<NativeEnvironmentSelectionGuard>,
3538 materialization_guard: Option<SessionMaterializationGuard<'a>>,
3539 harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3540 #[cfg(feature = "dig2-station-probe")]
3541 browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3542 materialization_id: Option<MaterializationId>,
3543}
3544
3545impl PreparedSpawn<'_> {
3546 fn retain_session_guards(&mut self) {
3548 if let Some(selection) = self.environment_selection.take() {
3549 selection.retain();
3550 }
3551 if let Some(overlay) = self.instance_overlay.take() {
3552 overlay.retain();
3553 }
3554 if let Some(guard) = self.materialization_guard.take() {
3555 guard.retain();
3556 }
3557 }
3558
3559 fn retain_launch_guards(&mut self) {
3561 if let Some(overlay) = self.harness_mcp_overlay.take() {
3562 overlay.retain();
3563 }
3564 #[cfg(feature = "dig2-station-probe")]
3565 if let Some(guard) = self.browser_station_lease_guard.take() {
3566 guard.retain();
3567 }
3568 }
3569}
3570
3571trait SpawnPreparer {
3575 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3576 fn select_environment(
3578 &self,
3579 instance_id: AgentInstanceId,
3580 provider: &AgentId,
3581 mode: SessionMode,
3582 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3583 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3584 fn install_launch_overlay(
3586 &self,
3587 instance_id: AgentInstanceId,
3588 overlay: PreparedNativeLaunchOverlay,
3589 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3590}
3591
3592impl SpawnPreparer for NodeShared {
3593 fn select_environment(
3594 &self,
3595 instance_id: AgentInstanceId,
3596 provider: &AgentId,
3597 mode: SessionMode,
3598 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3599 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3600 self.select_environment_profile(instance_id, provider, mode, environment_profile)
3601 }
3602
3603 fn install_launch_overlay(
3604 &self,
3605 instance_id: AgentInstanceId,
3606 overlay: PreparedNativeLaunchOverlay,
3607 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3608 self.install_prepared_launch_overlay(instance_id, overlay)
3609 }
3610
3611 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3612 let SpawnPrepareRequest {
3613 instance_id,
3614 address,
3615 provider,
3616 mode,
3617 environment_profile,
3618 bundle,
3619 context,
3620 managed_authority,
3621 harness_mcp,
3622 approval_level,
3623 network_allowlist,
3624 browser_profile_id,
3625 } = request;
3626 #[cfg(feature = "dig2-station-probe")]
3631 let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3632 use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3633 match acquire_lease(
3634 &self.browser_station_leases,
3635 profile_id,
3636 instance_id,
3637 unix_time_ms(),
3638 ) {
3639 Ok(guard) => Some(guard),
3640 Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3641 return Err(failure(
3642 NodeFailureCode::BrowserStationProfileBusy,
3643 "dig2browser station profile is already leased by another session",
3644 ));
3645 }
3646 Err(BrowserStationLeaseError::CapacityExceeded) => {
3647 return Err(failure(
3648 NodeFailureCode::BrowserStationProfileBusy,
3649 "dig2browser station profile lease capacity exhausted",
3650 ));
3651 }
3652 Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3653 return Err(failure(
3654 NodeFailureCode::BrowserStationProfileBusy,
3655 "session already holds a dig2browser station profile lease",
3656 ));
3657 }
3658 }
3659 } else {
3660 None
3661 };
3662 #[cfg(not(feature = "dig2-station-probe"))]
3663 let _ = browser_profile_id;
3664 let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3665 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3674 {
3675 return Err(failure(
3676 NodeFailureCode::BindingMismatch,
3677 "harness MCP reservation does not match the exact PTY/ACP provider",
3678 ));
3679 }
3680 prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3681 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3682 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3683 })?;
3684 let launch = prepared.launch();
3689 let mut env_entries = vec![
3690 (
3691 OsString::from(launch.endpoint_env.as_str()),
3692 prepared.endpoint().as_os_str().to_os_string(),
3693 ),
3694 (
3695 OsString::from(launch.token_env.as_str()),
3696 prepared.token().expose().into(),
3697 ),
3698 (
3699 OsString::from(launch.program_env.as_str()),
3700 prepared.helper_program().as_os_str().to_os_string(),
3701 ),
3702 ];
3703 if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3704 env_entries.push(trace_entry);
3705 }
3706 if let Some(door) = crate::door::door_listen_addr() {
3707 env_entries.push((
3708 OsString::from("HATCHERY_NODE_DOOR"),
3709 OsString::from(door.to_string()),
3710 ));
3711 let session_key = format!(
3715 "pty-{}-{}-{}",
3716 self.incarnation_id,
3717 address.workspace_id.as_str(),
3718 address.session.instance_id.0
3719 );
3720 env_entries.push((
3721 OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3722 OsString::from(session_key),
3723 ));
3724 }
3725 let overlay = NativeMcpServerLaunchOverlay::new(
3726 provider.clone(),
3727 launch.server_name.as_str(),
3728 prepared.helper_program().as_os_str().to_os_string(),
3729 launch.args.iter().map(OsString::from).collect(),
3730 env_entries,
3731 launch.scrub_env.iter().map(OsString::from).collect(),
3732 ).map_err(|_| failure(
3733 NodeFailureCode::HarnessMcpUnavailable,
3734 "harness MCP launch overlay is unavailable",
3735 ))?;
3736 control
3737 .install_native_mcp_server_launch_overlay(instance_id, overlay)
3738 .map_err(|_| failure(
3739 NodeFailureCode::HarnessMcpUnavailable,
3740 "harness MCP launch overlay could not be installed",
3741 ))?;
3742 tracing::info!(
3748 node_id = %self.node_id,
3749 instance_id = ?instance_id,
3750 provider = %provider,
3751 mode = ?mode,
3752 "harness MCP overlay installed for spawn",
3753 );
3754 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3755 } else {
3756 None
3757 };
3758 let prepared_materialization = self.prepare_session_materialization(
3759 &address,
3760 &provider,
3761 mode,
3762 environment_profile,
3763 bundle,
3764 context,
3765 managed_authority.cloned(),
3766 )?;
3767 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3768 Some((overlay, guard)) => (Some(overlay), Some(guard)),
3769 None => (None, None),
3770 };
3771 let materialization_id = materialization_guard
3772 .as_ref()
3773 .and_then(SessionMaterializationGuard::id)
3774 .cloned();
3775 let mut environment_selection = SpawnPreparer::select_environment(self,
3776 instance_id,
3777 &provider,
3778 mode,
3779 environment_profile,
3780 )?;
3781 let provider_home = materialization_guard
3784 .as_ref()
3785 .and_then(SessionMaterializationGuard::provider_home);
3786 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3787 &provider,
3788 mode,
3789 approval_level,
3790 network_allowlist,
3791 provider_home,
3792 )?;
3793 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3794 (prepared, args) if args.is_empty() => prepared,
3795 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3796 overlay.append_extra_args(args).map_err(|_| {
3797 failure(
3798 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3799 "network allowlist overlay could not merge with instance launch args",
3800 )
3801 })?;
3802 Some(PreparedNativeLaunchOverlay::Instance(overlay))
3803 }
3804 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3805 Some(PreparedNativeLaunchOverlay::Instance(
3806 overlay
3807 .into_instance_with_extra_args(args)
3808 .map_err(|_| {
3809 failure(
3810 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3811 "network allowlist overlay could not merge with environment overlay",
3812 )
3813 })?,
3814 ))
3815 }
3816 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3817 NativeInstanceLaunchOverlay::new(
3818 provider.clone(),
3819 match mode {
3820 SessionMode::Pty => TransportKind::Pty,
3821 SessionMode::Acp => TransportKind::Acp,
3822 SessionMode::Inline => TransportKind::Pipe,
3823 },
3824 Vec::new(),
3825 args,
3826 )
3827 .map_err(|_| {
3828 failure(
3829 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3830 "network allowlist overlay is invalid for this transport",
3831 )
3832 })?,
3833 )),
3834 };
3835 let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str())) {
3838 (SessionMode::Pty, Some(accounts), Some(m4a_provider)) => {
3839 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3840 match accounts.provision(m4a_provider, &session_key) {
3841 Ok(account) => {
3842 let identity = account.env_mutations();
3843 let merged = match environment_overlay {
3844 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3845 .map(PreparedNativeLaunchOverlay::Instance),
3846 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3847 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3848 }
3849 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3850 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3851 }
3852 }
3853 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3854 tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3855 self.m4a_live
3856 .lock()
3857 .unwrap_or_else(|p| p.into_inner())
3858 .insert(account.nick.clone(), (instance_id, provider.clone()));
3859 Some(merged)
3860 }
3861 Err(error) => {
3862 tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3863 environment_overlay
3864 }
3865 }
3866 }
3867 (SessionMode::Pty, Some(_), None) => {
3868 tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
3869 environment_overlay
3870 }
3871 _ => environment_overlay,
3872 };
3873 let mut instance_overlay = environment_overlay
3874 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
3875 .transpose()?
3876 .flatten();
3877 Ok(PreparedSpawn {
3878 instance_overlay,
3879 environment_selection,
3880 materialization_guard,
3881 harness_mcp_overlay,
3882 #[cfg(feature = "dig2-station-probe")]
3883 browser_station_lease_guard,
3884 materialization_id,
3885 })
3886 }
3887}
3888
3889struct SessionRecords {
3890 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3891}
3892
3893struct SpawnIdempotencyCache {
3894 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3895}
3896
3897struct SpawnIdempotencyEntry {
3898 value: SpawnIdempotencyValue,
3899 expires_at: Instant,
3900}
3901
3902enum SpawnIdempotencyValue {
3903 Standard {
3904 spec: SpawnSpec,
3905 result: Result<ResolvedSpawnReceipt, NodeFailure>,
3906 },
3907 Managed {
3908 request: ManagedWorktreeSpawnRequest,
3909 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3910 },
3911 HarnessMcp,
3912}
3913
3914enum DurableManagedSpawnReplayDecision {
3915 Reserved,
3916 PendingLinked,
3917 Committed(ManagedWorktreeSpawnReceipt),
3918}
3919
3920impl SpawnIdempotencyValue {
3921 fn references_context(&self, context_id: &SpawnContextId) -> bool {
3922 match self {
3923 Self::Standard { result, .. } => result
3924 .as_ref()
3925 .ok()
3926 .and_then(|receipt| receipt.context.as_ref()),
3927 Self::Managed { result, .. } => result
3928 .as_ref()
3929 .ok()
3930 .and_then(|receipt| receipt.spawn.context.as_ref()),
3931 Self::HarnessMcp => None,
3932 }
3933 .is_some_and(|receipt| &receipt.id == context_id)
3934 }
3935}
3936
3937#[derive(Clone, Copy)]
3938struct ControllerLease {
3939 connection_id: u64,
3940 expires_at: Instant,
3941}
3942
3943struct NodeEventHistory {
3944 last_sequence: u64,
3945 replay_floor_sequence: u64,
3946 events: VecDeque<NodeEventEnvelope>,
3947 record_providers: BTreeMap<SessionRecordId, AgentId>,
3948 removed_record_providers: BTreeMap<u64, AgentId>,
3949}
3950
3951impl NodeEventHistory {
3952 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
3953 Self {
3954 last_sequence: 0,
3955 replay_floor_sequence: 1,
3956 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
3957 record_providers,
3958 removed_record_providers: BTreeMap::new(),
3959 }
3960 }
3961}
3962
3963impl NodeShared {
3964 fn new_with_incarnation(
3965 handle: Gate4AgentHandle,
3966 access_token: String,
3967 node_id: NodeId,
3968 incarnation_id: NodeIncarnationId,
3969 workspaces: Vec<WorkspaceConfig>,
3970 enabled_providers: Vec<AgentId>,
3971 provider_runtime_statuses: ProviderRuntimeStatuses,
3972 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3973 provider_contracts: Vec<ProviderContractSupport>,
3974 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3975 spawn_profiles: SpawnProfileRegistry,
3976 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3977 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3978 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3979 state_path: Option<PathBuf>,
3980 records: Vec<ManagedSessionRecord>,
3981 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
3982 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
3983 materializations: Vec<MaterializationOwnershipRecord>,
3984 persistence_warning: Option<String>,
3985 input_settle_timeout_ms: u64,
3986 ) -> Self {
3987 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
3988 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
3989 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
3990 let (harness_mcp_proxy_event_tx, _) =
3991 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
3992 let record_providers = records
3993 .iter()
3994 .map(|record| (record.record_id.clone(), record.provider.clone()))
3995 .collect();
3996 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
3997 .iter()
3998 .map(|workspace| {
3999 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4000 })
4001 .collect();
4002 for lease in &managed_worktrees {
4003 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4004 }
4005 let managed_worktree_profiles = workspaces.iter()
4006 .map(|workspace| (
4007 workspace.workspace_id.clone(),
4008 workspace.managed_worktree_profiles.clone(),
4009 ))
4010 .collect();
4011 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4012 managed_worktrees,
4013 managed_worktree_tombstones,
4014 ).expect("durable managed worktree registry was validated while loading");
4015 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4016 managed_worktrees.reattach_record_holders(
4017 &managed_worktree_record_holders(&records),
4018 unix_time_ms(),
4019 );
4020 Self {
4021 handle,
4022 access_token,
4023 node_id,
4024 incarnation_id,
4025 started_at_unix_ms: std::time::SystemTime::now()
4026 .duration_since(std::time::UNIX_EPOCH)
4027 .unwrap_or_default()
4028 .as_millis()
4029 .min(u64::MAX as u128) as u64,
4030 workspaces: RwLock::new(
4031 workspaces
4032 .into_iter()
4033 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4034 .collect(),
4035 ),
4036 worktree_service_modes,
4037 managed_worktree_profiles,
4038 managed_worktrees: Mutex::new(managed_worktrees),
4039 enabled_providers,
4040 provider_runtime_statuses,
4041 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4042 provider_runtime_monitor,
4043 provider_contracts,
4044 provider_adapter_contracts,
4045 spawn_profiles,
4046 environment_profiles: RwLock::new(BTreeMap::new()),
4047 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4048 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4049 #[cfg(feature = "dig2-station-probe")]
4050 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4051 bundle_catalog: RwLock::new(BundleCatalog::default()),
4052 delivery_store: Mutex::new(None),
4053 context_pack_store: Mutex::new(None),
4054 context_catalog: RwLock::new(ContextPackCatalog::default()),
4055 native_launch_profile_control,
4056 harness_mcp_registry: None,
4057 native_session_catalog,
4058 session_environment_materializer,
4059 m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4060 m4a_live: Mutex::new(BTreeMap::new()),
4061 materializations: Mutex::new(
4062 materializations
4063 .into_iter()
4064 .map(|record| (record.id().clone(), record))
4065 .collect(),
4066 ),
4067 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4068 entries: BTreeMap::new(),
4069 }),
4070 managed_spawn_replays: Mutex::new(BTreeMap::new()),
4071 controller: Mutex::new(None),
4072 provisional_spawns: Mutex::new(BTreeMap::new()),
4073 history: Mutex::new(NodeEventHistory::new(record_providers)),
4074 event_tx,
4075 terminal_event_tx,
4076 agent_stream_event_tx,
4077 harness_mcp_proxy_event_tx,
4078 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4079 pty_input_wake: tokio::sync::Notify::new(),
4080 pty_input_marks: Mutex::new(BTreeMap::new()),
4081 next_connection_id: AtomicU64::new(1),
4082 next_instance_id: AtomicU64::new(1),
4083 next_command_id: AtomicU64::new(1),
4084 shutdown: AtomicBool::new(false),
4085 shutdown_notify: Notify::new(),
4086 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4087 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4088 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4089 mutation_gate: AsyncMutex::new(()),
4090 session_bindings: Mutex::new(BTreeMap::new()),
4091 session_records: Mutex::new(SessionRecords {
4092 records: records
4093 .into_iter()
4094 .map(|record| (record.record_id.clone(), record))
4095 .collect(),
4096 }),
4097 state_path,
4098 persistence_error: RwLock::new(
4099 persistence_warning
4100 .map(|message| session_registry::sanitized_persistence_summary(&message)),
4101 ),
4102 state_transaction: Mutex::new(()),
4103 input_settle_timeout_ms,
4104 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4105 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4106 shell_efficiency_profile: OnceLock::new(),
4107 native_session_gauge: AtomicUsize::new(0),
4108 connection_loop_iterations: AtomicU64::new(0),
4109 connection_events_sent: AtomicU64::new(0),
4110 rejected_commands_total: AtomicU64::new(0),
4111 rejected_command_reasons: Mutex::new(BTreeMap::new()),
4112 drive_loop_iterations_total: AtomicU64::new(0),
4113 drive_loop_iterations_idle: AtomicU64::new(0),
4114 session_record_retention: SessionRecordRetentionConfig::default(),
4115 retired_records_total: AtomicU64::new(0),
4116 #[cfg(feature = "fixture")]
4117 fixture_semantic_hook_policy: false,
4118 #[cfg(feature = "fixture")]
4119 fixture_spawn_managed_worktree_v2_failure_probe:
4120 SpawnManagedWorktreeV2FailureProbe::default(),
4121 }
4122 }
4123
4124 #[cfg(test)]
4125 fn new(
4126 handle: Gate4AgentHandle,
4127 access_token: String,
4128 node_id: NodeId,
4129 workspaces: Vec<WorkspaceConfig>,
4130 enabled_providers: Vec<AgentId>,
4131 ) -> Self {
4132 Self::new_with_incarnation(
4133 handle,
4134 access_token,
4135 node_id,
4136 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4137 workspaces,
4138 enabled_providers,
4139 ProviderRuntimeStatuses::default(),
4140 None,
4141 Vec::new(),
4142 Vec::new(),
4143 SpawnProfileRegistry::default(),
4144 None,
4145 None,
4146 None,
4147 None,
4148 Vec::new(),
4149 Vec::new(),
4150 Vec::new(),
4151 Vec::new(),
4152 None,
4153 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4154 )
4155 }
4156
4157 fn persistence_error(&self) -> Option<String> {
4158 self.persistence_error
4159 .read()
4160 .unwrap_or_else(|poisoned| poisoned.into_inner())
4161 .clone()
4162 }
4163
4164 async fn admit_provider_runtime(
4165 &self,
4166 provider: &AgentId,
4167 requirement: ProviderRuntimeRequirement,
4168 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4169 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4170 let pipe_transport = monitor.supports_pipe_transport(provider);
4171 let acp_transport = monitor.supports_acp_transport(provider);
4172 let provider = provider.clone();
4173 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4174 let (status, admission) = timeout(
4175 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4176 refresh,
4177 )
4178 .await
4179 .map_err(|_| failure(
4180 NodeFailureCode::BackendBusy,
4181 "provider runtime probe exceeded its bounded deadline",
4182 ))?
4183 .map_err(|_| failure(
4184 NodeFailureCode::BackendOperationFailed,
4185 "provider runtime probe task failed",
4186 ))?;
4187 if let Some(status) = status {
4188 self
4189 .provider_runtime_status_updates
4190 .write()
4191 .unwrap_or_else(|poisoned| poisoned.into_inner())
4192 .insert(status.provider().clone(), status);
4193 }
4194 admission.and_then(|policy| {
4195 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4196 })
4197 } else {
4198 crate::provider_runtime::admit_status(
4204 &self.provider_runtime_statuses,
4205 provider,
4206 false,
4207 false,
4208 requirement,
4209 )
4210 };
4211 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4212 admission.map_err(|error| match error {
4213 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4214 NodeFailureCode::BackendOperationFailed,
4215 "provider launcher is unavailable",
4216 ),
4217 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4218 NodeFailureCode::UnsupportedCapability,
4219 "provider semantic capability is not verified",
4220 ),
4221 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4222 NodeFailureCode::BackendBusy,
4223 "provider runtime probe is already in progress",
4224 ),
4225 })
4226 }
4227
4228 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4234 match self.provider_runtime_monitor.as_deref() {
4235 Some(monitor) => monitor.supports_pipe_transport(provider),
4236 None => false,
4237 }
4238 }
4239
4240 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4246 match self.provider_runtime_monitor.as_deref() {
4247 Some(monitor) => monitor.supports_acp_transport(provider),
4248 None => false,
4249 }
4250 }
4251
4252 fn admit_declared_hook_semantics(
4268 &self,
4269 provider: &AgentId,
4270 policy: ProviderRuntimePolicy,
4271 ) -> ProviderRuntimePolicy {
4272 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4273 return policy;
4274 }
4275 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4276 contract.provider == *provider && contract.family == AdapterFamily::Hook
4277 });
4278 if !declares_hook {
4279 return policy;
4280 }
4281 ProviderRuntimePolicy::new(
4282 policy.raw_pty_lifecycle,
4283 policy.semantic_readiness,
4284 policy.structured_prompt,
4285 policy.provider_session_identity,
4286 policy.semantic_resume,
4287 true,
4288 )
4289 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4290 }
4291
4292 fn resolve_network_allowlist_overlay_args(
4307 &self,
4308 provider: &AgentId,
4309 mode: SessionMode,
4310 approval_level: ApprovalLevel,
4311 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4312 provider_home: Option<&Path>,
4313 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4314 let Some(allowlist_id) = network_allowlist else {
4315 return Ok(Vec::new());
4316 };
4317 let catalog = self
4318 .network_allowlist_catalog
4319 .read()
4320 .unwrap_or_else(|poisoned| poisoned.into_inner());
4321 let Some(entry) = catalog.get(allowlist_id) else {
4322 return Err(failure(
4323 NodeFailureCode::UnknownNetworkAllowlist,
4324 "spawn network allowlist is unavailable on this node",
4325 ));
4326 };
4327
4328 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4330 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4331 |message| {
4332 failure(
4333 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4334 &message,
4335 )
4336 },
4337 )?;
4338 if !matches!(mode, SessionMode::Pty) {
4339 return Err(failure(
4340 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4341 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4342 ));
4343 }
4344 let Some(home) = provider_home else {
4345 return Err(failure(
4346 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4347 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4348 ));
4349 };
4350 let domains =
4351 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4352 if domains.is_empty() {
4353 return Err(failure(
4354 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4355 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4356 ));
4357 }
4358 let settings_path = home.join(
4359 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4360 );
4361 let json =
4362 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4363 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4364 |_| {
4365 failure(
4366 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4367 "Claude station network settings sidecar could not be written under ProviderHome",
4368 )
4369 },
4370 )?;
4371 let overlay =
4372 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4373 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4374 }
4375
4376 let Some(native) = entry.provider_native.as_ref() else {
4377 return Ok(Vec::new());
4378 };
4379 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4380 provider.as_str(),
4381 approval_level,
4382 native,
4383 )
4384 .map_err(|message| {
4385 failure(
4386 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4387 &message,
4388 )
4389 })?;
4390 if overlay.is_empty() {
4391 return Ok(Vec::new());
4392 }
4393 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4394 return Err(failure(
4395 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4396 "Codex network_access -c overlay requires PTY or ACP transport",
4397 ));
4398 }
4399 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4400 }
4401
4402 fn resolve_codex_network_access_overlay_args(
4404 &self,
4405 provider: &AgentId,
4406 mode: SessionMode,
4407 approval_level: ApprovalLevel,
4408 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4409 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4410 self.resolve_network_allowlist_overlay_args(
4411 provider,
4412 mode,
4413 approval_level,
4414 network_allowlist,
4415 None,
4416 )
4417 }
4418
4419 fn resolve_environment_profile(
4420 &self,
4421 resolved: &ResolvedSpawnSpec,
4422 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4423 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4429 let catalog = self
4430 .network_allowlist_catalog
4431 .read()
4432 .unwrap_or_else(|poisoned| poisoned.into_inner());
4433 let Some(entry) = catalog.get(allowlist_id) else {
4434 return Err(failure(
4435 NodeFailureCode::UnknownNetworkAllowlist,
4436 "spawn network allowlist is unavailable on this node",
4437 ));
4438 };
4439 if let Some(native) = entry.provider_native.as_ref() {
4442 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4443 resolved.provider.as_str(),
4444 resolved.approval_level,
4445 native,
4446 ) {
4447 return Err(failure(
4448 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4449 &message,
4450 ));
4451 }
4452 }
4453 }
4454 #[cfg(feature = "dig2-station-probe")]
4463 if resolved.browser_profile_id.is_some() {
4464 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4465 match probe_station_reachable() {
4466 Ok(()) => {}
4467 Err(StationProbeError::UnsupportedPlatform)
4468 | Err(StationProbeError::InvalidPipeSuffix) => {
4469 return Err(failure(
4470 NodeFailureCode::BrowserStationProbeUnavailable,
4471 "dig2browser station probe unavailable on this platform",
4472 ));
4473 }
4474 Err(StationProbeError::Unreachable) => {
4475 return Err(failure(
4476 NodeFailureCode::BrowserStationUnreachable,
4477 "dig2browser station named-pipe is unreachable",
4478 ));
4479 }
4480 }
4481 }
4482 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4483 return Ok(None);
4484 };
4485 let profiles = self
4486 .environment_profiles
4487 .read()
4488 .unwrap_or_else(|poisoned| poisoned.into_inner());
4489 let binding = profiles.get(profile_id).ok_or_else(|| {
4490 failure(
4491 NodeFailureCode::UnknownEnvironmentProfile,
4492 "spawn environment profile is unavailable on this node",
4493 )
4494 })?;
4495 if binding.provider != resolved.provider
4496 || binding.native_profile_id(resolved.mode).is_none()
4497 {
4498 return Err(failure(
4499 NodeFailureCode::EnvironmentProfileBindingMismatch,
4500 "spawn environment profile does not match the provider and mode",
4501 ));
4502 }
4503 Ok(Some(ResolvedEnvironmentProfileReceipt {
4507 profile_id: binding.id.clone(),
4508 profile_revision: binding.revision.clone(),
4509 network_allowlist: resolved.network_allowlist.clone(),
4510 browser_profile_id: resolved.browser_profile_id.clone(),
4511 }))
4512 }
4513
4514 fn resolve_bundle(
4515 &self,
4516 resolved: &ResolvedSpawnSpec,
4517 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4518 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4519 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4520 return Ok(None);
4521 };
4522 let catalog = self.bundle_catalog
4523 .read()
4524 .unwrap_or_else(|poisoned| poisoned.into_inner());
4525 let bundle = catalog.get(bundle_id).ok_or_else(|| {
4526 failure(
4527 NodeFailureCode::UnknownBundle,
4528 "spawn bundle is unavailable on this node",
4529 )
4530 })?;
4531 self.bundle_layout(
4532 &resolved.provider,
4533 resolved.mode,
4534 environment_profile,
4535 bundle,
4536 )?;
4537 Ok(Some(bundle.receipt()))
4538 }
4539
4540 fn begin_delivery_stage(
4541 &self,
4542 manifest: DeliveryBundleManifestV2,
4543 ) -> Result<NodeResponse, NodeFailure> {
4544 let mut store = self
4545 .delivery_store
4546 .lock()
4547 .unwrap_or_else(|poisoned| poisoned.into_inner());
4548 let store = store.as_mut().ok_or_else(|| {
4549 failure(
4550 NodeFailureCode::DeliveryStageStorageFailed,
4551 "node delivery store is unavailable",
4552 )
4553 })?;
4554 let begun = store.begin(manifest).map_err(delivery_failure)?;
4555 Ok(NodeResponse::DeliveryStageBegun {
4556 stage_id: begun.stage_id,
4557 manifest_digest: begun.manifest_digest,
4558 missing_blobs: begun.missing_blobs,
4559 })
4560 }
4561
4562 fn put_delivery_blob_chunk(
4563 &self,
4564 stage_id: DeliveryStageId,
4565 blob_digest: DeliveryBlobDigestV1,
4566 offset: u64,
4567 chunk_hex: DeliveryBlobChunkHexV1,
4568 ) -> Result<NodeResponse, NodeFailure> {
4569 let mut store = self
4570 .delivery_store
4571 .lock()
4572 .unwrap_or_else(|poisoned| poisoned.into_inner());
4573 let store = store.as_mut().ok_or_else(|| {
4574 failure(
4575 NodeFailureCode::DeliveryStageStorageFailed,
4576 "node delivery store is unavailable",
4577 )
4578 })?;
4579 let accepted = store
4580 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4581 .map_err(delivery_failure)?;
4582 Ok(NodeResponse::DeliveryBlobChunkAccepted {
4583 stage_id: accepted.stage_id,
4584 blob_digest: accepted.blob_digest,
4585 next_offset: accepted.next_offset,
4586 })
4587 }
4588
4589 fn commit_delivery_stage(
4590 &self,
4591 stage_id: DeliveryStageId,
4592 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4593 let mut store = self
4594 .delivery_store
4595 .lock()
4596 .unwrap_or_else(|poisoned| poisoned.into_inner());
4597 let store = store.as_mut().ok_or_else(|| {
4598 failure(
4599 NodeFailureCode::DeliveryStageStorageFailed,
4600 "node delivery store is unavailable",
4601 )
4602 })?;
4603 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4604 let mut catalog = self
4605 .bundle_catalog
4606 .write()
4607 .unwrap_or_else(|poisoned| poisoned.into_inner());
4608 let mut next_catalog = catalog.clone();
4609 next_catalog
4610 .insert_idempotent(prepared.bundle().clone())
4611 .map_err(|_| {
4612 failure(
4613 NodeFailureCode::DeliveryStageConflict,
4614 "delivery bundle identity conflicts with installed content",
4615 )
4616 })?;
4617 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4618 *catalog = next_catalog;
4619 Ok(receipt)
4620 }
4621
4622 fn abort_delivery_stage(
4623 &self,
4624 stage_id: &DeliveryStageId,
4625 ) -> Result<(), NodeFailure> {
4626 let mut store = self
4627 .delivery_store
4628 .lock()
4629 .unwrap_or_else(|poisoned| poisoned.into_inner());
4630 let store = store.as_mut().ok_or_else(|| {
4631 failure(
4632 NodeFailureCode::DeliveryStageStorageFailed,
4633 "node delivery store is unavailable",
4634 )
4635 })?;
4636 store.abort(stage_id).map_err(delivery_failure)
4637 }
4638
4639 fn resolve_context(
4640 &self,
4641 resolved: &ResolvedSpawnSpec,
4642 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4643 let Some(context_id) = resolved.context_id.as_ref() else {
4644 return Ok(None);
4645 };
4646 self.context_catalog
4647 .read()
4648 .unwrap_or_else(|poisoned| poisoned.into_inner())
4649 .get(context_id)
4650 .map(|context| context.receipt().clone())
4651 .ok_or_else(|| {
4652 failure(
4653 NodeFailureCode::UnknownContextPack,
4654 "spawn context pack is unavailable on this node",
4655 )
4656 })
4657 .map(Some)
4658 }
4659
4660 fn bundle_layout(
4661 &self,
4662 provider: &AgentId,
4663 mode: SessionMode,
4664 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4665 bundle: &NodeBundle,
4666 ) -> Result<BundleProviderLayout, NodeFailure> {
4667 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4668 failure(
4669 NodeFailureCode::BundleBindingMismatch,
4670 "spawn bundle does not match the provider and mode",
4671 )
4672 })?;
4673 if layout != BundleProviderLayout::Codex {
4674 return Ok(layout);
4675 }
4676 let profile = environment_profile.and_then(|receipt| {
4677 let profiles = self
4678 .environment_profiles
4679 .read()
4680 .unwrap_or_else(|poisoned| poisoned.into_inner());
4681 let binding = profiles.get(&receipt.profile_id)?;
4682 if binding.revision != receipt.profile_revision
4683 || &binding.provider != provider
4684 || binding.native_profile_id(mode).is_none()
4685 {
4686 return None;
4687 }
4688 self.environment_materialization_profiles
4689 .read()
4690 .unwrap_or_else(|poisoned| poisoned.into_inner())
4691 .get(&receipt.profile_id)
4692 .cloned()
4693 });
4694 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4695 return Err(failure(
4696 NodeFailureCode::BundleBindingMismatch,
4697 "Codex bundle requires an exact isolated CODEX_HOME profile",
4698 ));
4699 }
4700 Ok(layout)
4701 }
4702
4703 fn select_environment_profile(
4704 &self,
4705 instance_id: AgentInstanceId,
4706 provider: &AgentId,
4707 mode: SessionMode,
4708 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4709 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4710 let Some(environment_profile) = environment_profile else {
4711 return Ok(None);
4712 };
4713 let native_profile_id = {
4714 let profiles = self
4715 .environment_profiles
4716 .read()
4717 .unwrap_or_else(|poisoned| poisoned.into_inner());
4718 let binding = profiles
4719 .get(&environment_profile.profile_id)
4720 .ok_or_else(|| {
4721 failure(
4722 NodeFailureCode::UnknownEnvironmentProfile,
4723 "session environment profile is unavailable on this node",
4724 )
4725 })?;
4726 if &binding.revision != &environment_profile.profile_revision
4727 || &binding.provider != provider
4728 {
4729 return Err(failure(
4730 NodeFailureCode::EnvironmentProfileBindingMismatch,
4731 "session environment profile revision or provider changed",
4732 ));
4733 }
4734 binding.native_profile_id(mode).cloned().ok_or_else(|| {
4735 failure(
4736 NodeFailureCode::EnvironmentProfileBindingMismatch,
4737 "session environment profile does not support this mode",
4738 )
4739 })?
4740 };
4741 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4742 failure(
4743 NodeFailureCode::BackendOperationFailed,
4744 "native environment profile controller is unavailable",
4745 )
4746 })?;
4747 control
4748 .select_native_launch_profile(instance_id, native_profile_id)
4749 .map_err(|_| {
4750 failure(
4751 NodeFailureCode::BackendOperationFailed,
4752 "native environment profile selection failed",
4753 )
4754 })?;
4755 Ok(Some(NativeEnvironmentSelectionGuard {
4756 control: Some(control),
4757 instance_id,
4758 }))
4759 }
4760
4761 fn install_prepared_launch_overlay(
4762 &self,
4763 instance_id: AgentInstanceId,
4764 overlay: PreparedNativeLaunchOverlay,
4765 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4766 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4767 failure(
4768 NodeFailureCode::BackendOperationFailed,
4769 "native launch profile controller is unavailable",
4770 )
4771 })?;
4772 match overlay {
4773 PreparedNativeLaunchOverlay::Environment(overlay) => {
4774 control
4775 .install_native_launch_environment_overlay(instance_id, overlay)
4776 .map_err(|_| failure(
4777 NodeFailureCode::BackendOperationFailed,
4778 "session environment overlay installation failed",
4779 ))?;
4780 Ok(None)
4781 }
4782 PreparedNativeLaunchOverlay::Instance(overlay) => {
4783 control
4784 .install_native_instance_launch_overlay(instance_id, overlay)
4785 .map_err(|_| failure(
4786 NodeFailureCode::BundleBindingMismatch,
4787 "session bundle launch overlay installation failed",
4788 ))?;
4789 Ok(Some(NativeInstanceOverlayGuard {
4790 control: Some(control),
4791 instance_id,
4792 }))
4793 }
4794 }
4795 }
4796
4797 fn environment_materialization_profile(
4798 &self,
4799 environment_profile: &ResolvedEnvironmentProfileReceipt,
4800 ) -> Option<NodeSessionMaterializationProfile> {
4801 self.environment_materialization_profiles
4802 .read()
4803 .unwrap_or_else(|poisoned| poisoned.into_inner())
4804 .get(&environment_profile.profile_id)
4805 .cloned()
4806 }
4807
4808 fn materialization_profile(
4809 &self,
4810 provider: &AgentId,
4811 mode: SessionMode,
4812 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4813 bundle: Option<&ResolvedBundleReceipt>,
4814 context: Option<&ResolvedContextPackReceipt>,
4815 ) -> Result<Option<(
4816 NodeSessionMaterializationProfile,
4817 bool,
4818 Option<BundleProviderLayout>,
4819 )>, NodeFailure> {
4820 let environment = environment_profile
4821 .and_then(|receipt| self.environment_materialization_profile(receipt));
4822 let (bundle, bundle_layout) = match bundle {
4823 Some(receipt) => {
4824 let catalog = self.bundle_catalog.read()
4825 .unwrap_or_else(|poisoned| poisoned.into_inner());
4826 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4827 NodeFailureCode::UnknownBundle,
4828 "session bundle is unavailable on this node",
4829 ))?;
4830 if installed.receipt() != *receipt {
4831 return Err(failure(
4832 NodeFailureCode::BundleBindingMismatch,
4833 "session bundle revision or digest changed",
4834 ));
4835 }
4836 let layout = self.bundle_layout(
4837 provider,
4838 mode,
4839 environment_profile,
4840 installed,
4841 )?;
4842 (Some(installed.clone()), Some(layout))
4843 }
4844 None => (None, None),
4845 };
4846 let has_environment_overlay = environment.is_some() || context.is_some();
4847 let profile = match (environment, bundle.as_ref(), bundle_layout) {
4848 (None, None, None) => None,
4849 (Some(profile), None, None) => Some(Ok(profile)),
4850 (None, Some(bundle), Some(layout)) => {
4851 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4852 }
4853 (Some(profile), Some(bundle), Some(layout)) => {
4854 Some(profile.with_bundle(bundle, layout))
4855 }
4856 _ => {
4857 return Err(failure(
4858 NodeFailureCode::BundleBindingMismatch,
4859 "session bundle provider layout is unavailable",
4860 ));
4861 }
4862 };
4863 let profile = profile
4864 .transpose()
4865 .map_err(|_| failure(
4866 NodeFailureCode::BundleMaterializationFailed,
4867 "session bundle materialization profile is invalid",
4868 ))?;
4869 let profile = match (profile, context) {
4870 (Some(profile), Some(receipt)) => {
4871 let catalog = self.context_catalog
4872 .read()
4873 .unwrap_or_else(|poisoned| poisoned.into_inner());
4874 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4875 NodeFailureCode::UnknownContextPack,
4876 "session context pack is unavailable on this node",
4877 ))?;
4878 if pack.receipt() != receipt {
4879 return Err(failure(
4880 NodeFailureCode::ContextPackMaterializationFailed,
4881 "session context pack identity changed",
4882 ));
4883 }
4884 profile.with_context(pack)
4885 }
4886 (None, Some(receipt)) => {
4887 let catalog = self.context_catalog
4888 .read()
4889 .unwrap_or_else(|poisoned| poisoned.into_inner());
4890 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4891 NodeFailureCode::UnknownContextPack,
4892 "session context pack is unavailable on this node",
4893 ))?;
4894 if pack.receipt() != receipt {
4895 return Err(failure(
4896 NodeFailureCode::ContextPackMaterializationFailed,
4897 "session context pack identity changed",
4898 ));
4899 }
4900 NodeSessionMaterializationProfile::from_context(pack)
4901 }
4902 (Some(profile), None) => Ok(profile),
4903 (None, None) => return Ok(None),
4904 }
4905 .map_err(|_| failure(
4906 NodeFailureCode::ContextPackMaterializationFailed,
4907 "session context pack materialization profile is invalid",
4908 ))?;
4909 Ok(Some((profile, has_environment_overlay, bundle_layout)))
4910 }
4911
4912 fn prepare_session_materialization<'a>(
4913 &'a self,
4914 address: &SessionAddress,
4915 provider: &AgentId,
4916 mode: SessionMode,
4917 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4918 bundle: Option<&ResolvedBundleReceipt>,
4919 context: Option<&ResolvedContextPackReceipt>,
4920 managed_lease_id: Option<ManagedWorktreeLeaseId>,
4921 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
4922 let Some((profile, has_environment_overlay, bundle_layout)) =
4923 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
4924 else {
4925 return Ok(None);
4926 };
4927 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4928 failure(
4929 NodeFailureCode::BackendOperationFailed,
4930 "session environment materializer is unavailable",
4931 )
4932 })?;
4933 let materialization_failure_code = if context.is_some() {
4934 NodeFailureCode::ContextPackMaterializationFailed
4935 } else if bundle.is_some() {
4936 NodeFailureCode::BundleMaterializationFailed
4937 } else {
4938 NodeFailureCode::BackendOperationFailed
4939 };
4940 let id = MaterializationId::new(format!(
4941 "mat-{}-{}-{}",
4942 self.incarnation_id,
4943 address.session.instance_id.0,
4944 address.session.generation.0,
4945 ))
4946 .map_err(|_| {
4947 failure(
4948 NodeFailureCode::BackendOperationFailed,
4949 "session environment materialization identity failed",
4950 )
4951 })?;
4952 let owner = MaterializationOwner::Session {
4953 incarnation_id: self.incarnation_id,
4954 instance_id: address.session.instance_id,
4955 generation: address.session.generation,
4956 };
4957 let mut ownership = materializer
4958 .begin(
4959 id.clone(),
4960 environment_profile.cloned(),
4961 bundle.cloned(),
4962 context.cloned(),
4963 owner,
4964 managed_lease_id,
4965 &profile,
4966 unix_time_ms(),
4967 )
4968 .map_err(|_| {
4969 failure(
4970 materialization_failure_code,
4971 "session materialization preparation failed",
4972 )
4973 })?;
4974 {
4975 let _transaction = self
4976 .state_transaction
4977 .lock()
4978 .unwrap_or_else(|poisoned| poisoned.into_inner());
4979 let duplicate = self
4980 .materializations
4981 .lock()
4982 .unwrap_or_else(|poisoned| poisoned.into_inner())
4983 .insert(id.clone(), ownership.clone());
4984 if duplicate.is_some() {
4985 return Err(failure(
4986 NodeFailureCode::BackendOperationFailed,
4987 "session environment materialization identity conflict",
4988 ));
4989 }
4990 if let Err(error) = self.persist_state_locked() {
4991 self.materializations
4992 .lock()
4993 .unwrap_or_else(|poisoned| poisoned.into_inner())
4994 .remove(&id);
4995 return Err(persistence_failure(error));
4996 }
4997 }
4998 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
4999 Ok(environment) => environment,
5000 Err(_) => {
5001 let transaction = self
5002 .state_transaction
5003 .lock()
5004 .unwrap_or_else(|poisoned| poisoned.into_inner());
5005 self.materializations
5006 .lock()
5007 .unwrap_or_else(|poisoned| poisoned.into_inner())
5008 .insert(id.clone(), ownership.clone());
5009 if self.persist_state_locked().is_err() {
5010 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5011 }
5012 drop(transaction);
5013 if ownership.state() == MaterializationState::CleanupRequired {
5014 let _ = self.cleanup_materialization(&id);
5015 }
5016 return Err(failure(
5017 materialization_failure_code,
5018 "session materialization failed",
5019 ));
5020 }
5021 };
5022 {
5023 let _transaction = self
5024 .state_transaction
5025 .lock()
5026 .unwrap_or_else(|poisoned| poisoned.into_inner());
5027 self.materializations
5028 .lock()
5029 .unwrap_or_else(|poisoned| poisoned.into_inner())
5030 .insert(id.clone(), ownership.clone());
5031 if let Err(error) = self.persist_state_locked() {
5032 drop(_transaction);
5033 let _ = self.cleanup_materialization(&id);
5034 return Err(persistence_failure(error));
5035 }
5036 }
5037 let guard = SessionMaterializationGuard {
5038 shared: self,
5039 id: Some(id),
5040 provider_home: Some(ownership.provider_home().to_path_buf()),
5041 };
5042 let bundle_arguments = match bundle {
5043 Some(receipt) => {
5044 let catalog = self.bundle_catalog.read()
5045 .unwrap_or_else(|poisoned| poisoned.into_inner());
5046 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5047 NodeFailureCode::UnknownBundle,
5048 "session bundle is unavailable on this node",
5049 ))?;
5050 if installed.receipt() != *receipt {
5051 return Err(failure(
5052 NodeFailureCode::BundleBindingMismatch,
5053 "session bundle revision or digest changed",
5054 ));
5055 }
5056 let layout = bundle_layout.ok_or_else(|| failure(
5057 NodeFailureCode::BundleBindingMismatch,
5058 "materialized bundle provider layout is unavailable",
5059 ))?;
5060 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5061 failure(
5062 NodeFailureCode::BundleMaterializationFailed,
5063 "session bundle byte revalidation failed",
5064 )
5065 })?;
5066 Some(bundle_launch_arguments(
5067 layout,
5068 ownership.bundle_root(),
5069 ownership.provider_home(),
5070 ).map_err(|_| failure(
5071 NodeFailureCode::BundleBindingMismatch,
5072 "session bundle launch binding is invalid",
5073 ))?)
5074 }
5075 None => None,
5076 };
5077 if let Some(receipt) = context {
5078 materializer
5079 .revalidate_context(&ownership, receipt)
5080 .map_err(|_| failure(
5081 NodeFailureCode::ContextPackMaterializationFailed,
5082 "session context pack byte revalidation failed",
5083 ))?;
5084 }
5085 let transport = match mode {
5086 SessionMode::Pty => TransportKind::Pty,
5087 SessionMode::Inline => TransportKind::Pipe,
5088 SessionMode::Acp => TransportKind::Acp,
5089 };
5090 let overlay = if let Some(extra_args) = bundle_arguments {
5091 Some(PreparedNativeLaunchOverlay::Instance(
5092 NativeInstanceLaunchOverlay::new(
5093 provider.clone(),
5094 transport,
5095 if has_environment_overlay {
5096 environment
5097 } else {
5098 Vec::new()
5099 },
5100 extra_args,
5101 )
5102 .map_err(|_| {
5103 failure(
5104 NodeFailureCode::BundleBindingMismatch,
5105 "session bundle launch overlay is invalid",
5106 )
5107 })?,
5108 ))
5109 } else if has_environment_overlay {
5110 let context_only = environment_profile.is_none()
5111 && bundle.is_none()
5112 && context.is_some();
5113 let overlay = if context_only {
5114 NativeLaunchEnvironmentOverlay::new_context_root(
5115 provider.clone(),
5116 transport,
5117 environment,
5118 )
5119 .map_err(|_| {
5120 failure(
5121 NodeFailureCode::ContextPackMaterializationFailed,
5122 "context-only launch overlay is invalid",
5123 )
5124 })?
5125 } else {
5126 NativeLaunchEnvironmentOverlay::new(
5127 provider.clone(),
5128 transport,
5129 environment,
5130 )
5131 .map_err(|_| {
5132 failure(
5133 NodeFailureCode::BackendOperationFailed,
5134 "session environment overlay is invalid",
5135 )
5136 })?
5137 };
5138 Some(PreparedNativeLaunchOverlay::Environment(overlay))
5139 } else {
5140 None
5141 };
5142 Ok(Some((overlay, guard)))
5143 }
5144
5145 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5146 let Some(mut ownership) = self
5147 .materializations
5148 .lock()
5149 .unwrap_or_else(|poisoned| poisoned.into_inner())
5150 .get(id)
5151 .cloned()
5152 else {
5153 return Ok(());
5154 };
5155 if ownership.state() != MaterializationState::CleanupRequired {
5156 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5157 failure(
5158 NodeFailureCode::BackendOperationFailed,
5159 "session environment cleanup state is invalid",
5160 )
5161 })?;
5162 }
5163 {
5164 let _transaction = self
5165 .state_transaction
5166 .lock()
5167 .unwrap_or_else(|poisoned| poisoned.into_inner());
5168 let previous = self
5169 .materializations
5170 .lock()
5171 .unwrap_or_else(|poisoned| poisoned.into_inner())
5172 .insert(id.clone(), ownership.clone());
5173 if let Err(error) = self.persist_state_locked() {
5174 let mut materializations = self
5175 .materializations
5176 .lock()
5177 .unwrap_or_else(|poisoned| poisoned.into_inner());
5178 if let Some(previous) = previous {
5179 materializations.insert(id.clone(), previous);
5180 }
5181 return Err(persistence_failure(error));
5182 }
5183 }
5184 let cleanup = self
5185 .session_environment_materializer
5186 .as_ref()
5187 .ok_or(())
5188 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5189 if cleanup.is_err() {
5190 let _ = ownership.mark_recovery_required(unix_time_ms());
5191 self.materializations
5192 .lock()
5193 .unwrap_or_else(|poisoned| poisoned.into_inner())
5194 .insert(id.clone(), ownership);
5195 if self.persist_state().is_err() {
5196 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5197 }
5198 return Err(failure(
5199 NodeFailureCode::BackendOperationFailed,
5200 "session environment cleanup requires recovery",
5201 ));
5202 }
5203 let _transaction = self
5204 .state_transaction
5205 .lock()
5206 .unwrap_or_else(|poisoned| poisoned.into_inner());
5207 self.materializations
5208 .lock()
5209 .unwrap_or_else(|poisoned| poisoned.into_inner())
5210 .remove(id);
5211 if let Err(error) = self.persist_state_locked() {
5212 self.materializations
5213 .lock()
5214 .unwrap_or_else(|poisoned| poisoned.into_inner())
5215 .insert(id.clone(), ownership);
5216 return Err(persistence_failure(error));
5217 }
5218 Ok(())
5219 }
5220
5221 fn resolve_record_materialization(
5222 &self,
5223 record_id: &SessionRecordId,
5224 provider: &AgentId,
5225 mode: SessionMode,
5226 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5227 bundle: Option<&ResolvedBundleReceipt>,
5228 context: Option<&ResolvedContextPackReceipt>,
5229 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5230 let ownership = {
5231 let materializations = self
5232 .materializations
5233 .lock()
5234 .unwrap_or_else(|poisoned| poisoned.into_inner());
5235 let mut matches = materializations.values().filter(|record| {
5236 record.owner()
5237 == &MaterializationOwner::Record {
5238 record_id: record_id.clone(),
5239 }
5240 });
5241 let ownership = matches.next().cloned();
5242 if matches.next().is_some() {
5243 return Err(failure(
5244 NodeFailureCode::BackendOperationFailed,
5245 "managed session has conflicting environment ownership",
5246 ));
5247 }
5248 ownership
5249 };
5250 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5251 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5252 failure(
5253 NodeFailureCode::ContextPackMaterializationFailed,
5254 "session context materializer is unavailable",
5255 )
5256 })?;
5257 let pack = materializer
5258 .revalidate_context(ownership, receipt)
5259 .map_err(|_| {
5260 self.mark_materialization_recovery_required(ownership.id());
5261 failure(
5262 NodeFailureCode::ContextPackMaterializationFailed,
5263 "managed session context pack revalidation failed",
5264 )
5265 })?;
5266 self.context_catalog
5267 .write()
5268 .unwrap_or_else(|poisoned| poisoned.into_inner())
5269 .insert(pack)
5270 .map_err(|_| failure(
5271 NodeFailureCode::ContextPackMaterializationFailed,
5272 "managed session context pack catalog restore failed",
5273 ))?;
5274 }
5275 let materialization = self.materialization_profile(
5276 provider,
5277 mode,
5278 environment_profile,
5279 bundle,
5280 context,
5281 )?;
5282 match (ownership, materialization) {
5283 (None, None) => Ok(None),
5284 (None, Some(_)) | (Some(_), None) => Err(failure(
5285 NodeFailureCode::BackendOperationFailed,
5286 "managed session materialization is unavailable",
5287 )),
5288 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5289 if ownership.environment_profile() != environment_profile
5290 || ownership.bundle() != bundle
5291 || ownership.context() != context
5292 || ownership.state() != MaterializationState::Ready
5293 {
5294 self.mark_materialization_recovery_required(ownership.id());
5295 return Err(failure(
5296 NodeFailureCode::BackendOperationFailed,
5297 "managed session environment ownership requires recovery",
5298 ));
5299 }
5300 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5301 failure(
5302 NodeFailureCode::BackendOperationFailed,
5303 "session environment materializer is unavailable",
5304 )
5305 })?;
5306 if materializer.revalidate(&ownership).is_err() {
5307 self.mark_materialization_recovery_required(ownership.id());
5308 return Err(failure(
5309 NodeFailureCode::BackendOperationFailed,
5310 "managed session environment revalidation failed",
5311 ));
5312 }
5313 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5314 let catalog = self.bundle_catalog.read()
5315 .unwrap_or_else(|poisoned| poisoned.into_inner());
5316 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5317 return Err(failure(
5318 NodeFailureCode::UnknownBundle,
5319 "managed session bundle is unavailable",
5320 ));
5321 };
5322 let layout = bundle_layout.ok_or_else(|| failure(
5323 NodeFailureCode::BundleBindingMismatch,
5324 "record bundle provider layout is unavailable",
5325 ))?;
5326 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5327 drop(catalog);
5328 self.mark_materialization_recovery_required(ownership.id());
5329 return Err(failure(
5330 NodeFailureCode::BundleMaterializationFailed,
5331 "managed session bundle revalidation failed",
5332 ));
5333 }
5334 Some(bundle_launch_arguments(
5335 layout,
5336 ownership.bundle_root(),
5337 ownership.provider_home(),
5338 ).map_err(|_| failure(
5339 NodeFailureCode::BundleBindingMismatch,
5340 "managed session bundle launch binding is invalid",
5341 ))?)
5342 } else {
5343 None
5344 };
5345 if !has_environment_materialization {
5346 let overlay = bundle_arguments.map(|extra_args| {
5347 NativeInstanceLaunchOverlay::new(
5348 provider.clone(),
5349 TransportKind::Pty,
5350 Vec::new(),
5351 extra_args,
5352 )
5353 .map(PreparedNativeLaunchOverlay::Instance)
5354 .map_err(|_| failure(
5355 NodeFailureCode::BundleBindingMismatch,
5356 "managed session bundle launch overlay is invalid",
5357 ))
5358 }).transpose()?;
5359 return Ok(Some((ownership.id().clone(), overlay)));
5360 }
5361 let environment = match materializer.resolve_environment(&ownership, &profile) {
5362 Ok(environment) => environment,
5363 Err(
5364 SessionEnvironmentMaterializeError::SecretUnavailable
5365 | SessionEnvironmentMaterializeError::SecretDenied
5366 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5367 ) => {
5368 return Err(failure(
5369 NodeFailureCode::BackendOperationFailed,
5370 "managed session environment resolution failed",
5371 ));
5372 }
5373 Err(
5374 SessionEnvironmentMaterializeError::InvalidRoot
5375 | SessionEnvironmentMaterializeError::OwnershipMismatch
5376 | SessionEnvironmentMaterializeError::Filesystem(_)
5377 | SessionEnvironmentMaterializeError::Record(_),
5378 ) => {
5379 self.mark_materialization_recovery_required(ownership.id());
5380 return Err(failure(
5381 NodeFailureCode::BackendOperationFailed,
5382 "managed session environment resolution failed",
5383 ));
5384 }
5385 };
5386 let transport = match mode {
5387 SessionMode::Pty => TransportKind::Pty,
5388 SessionMode::Inline => TransportKind::Pipe,
5389 SessionMode::Acp => TransportKind::Acp,
5390 };
5391 let overlay = match bundle_arguments {
5392 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5393 NativeInstanceLaunchOverlay::new(
5394 provider.clone(),
5395 transport,
5396 environment,
5397 extra_args,
5398 )
5399 .map_err(|_| failure(
5400 NodeFailureCode::BundleBindingMismatch,
5401 "managed session bundle launch overlay is invalid",
5402 ))?,
5403 ),
5404 None => PreparedNativeLaunchOverlay::Environment(
5405 NativeLaunchEnvironmentOverlay::new(
5406 provider.clone(),
5407 transport,
5408 environment,
5409 )
5410 .map_err(|_| {
5411 failure(
5412 NodeFailureCode::BackendOperationFailed,
5413 "managed session environment overlay is invalid",
5414 )
5415 })?,
5416 ),
5417 };
5418 Ok(Some((ownership.id().clone(), Some(overlay))))
5419 }
5420 }
5421 }
5422
5423 fn destroy_record_materialization(
5424 &self,
5425 record_id: &SessionRecordId,
5426 ) -> Result<bool, NodeFailure> {
5427 let ids = self
5428 .materializations
5429 .lock()
5430 .unwrap_or_else(|poisoned| poisoned.into_inner())
5431 .values()
5432 .filter_map(|record| {
5433 (record.owner()
5434 == &MaterializationOwner::Record {
5435 record_id: record_id.clone(),
5436 })
5437 .then(|| record.id().clone())
5438 })
5439 .collect::<Vec<_>>();
5440 if ids.len() > 1 {
5441 return Err(failure(
5442 NodeFailureCode::BackendOperationFailed,
5443 "managed session has conflicting environment ownership",
5444 ));
5445 }
5446 let Some(id) = ids.first().cloned() else {
5447 return Ok(false);
5448 };
5449 let (terminal_record, mut cleanup_ownership) = {
5450 let _transaction = self
5451 .state_transaction
5452 .lock()
5453 .unwrap_or_else(|poisoned| poisoned.into_inner());
5454 let original_record = self.record(record_id)?;
5455 let mut terminal_record = original_record.clone();
5456 terminal_record.provider_session = None;
5457 terminal_record.active_session = None;
5458 terminal_record.state = ManagedSessionState::Unavailable;
5459 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5460 terminal_record.updated_at_unix_ms = unix_time_ms();
5461 let original_ownership = self
5462 .materializations
5463 .lock()
5464 .unwrap_or_else(|poisoned| poisoned.into_inner())
5465 .get(&id)
5466 .cloned()
5467 .ok_or_else(|| {
5468 failure(
5469 NodeFailureCode::BackendOperationFailed,
5470 "managed session environment ownership disappeared during cleanup",
5471 )
5472 })?;
5473 let mut cleanup_ownership = original_ownership.clone();
5474 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5475 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5476 failure(
5477 NodeFailureCode::BackendOperationFailed,
5478 "managed session environment cleanup state is invalid",
5479 )
5480 })?;
5481 }
5482 self.session_records
5483 .lock()
5484 .unwrap_or_else(|poisoned| poisoned.into_inner())
5485 .records
5486 .insert(record_id.clone(), terminal_record.clone());
5487 self.materializations
5488 .lock()
5489 .unwrap_or_else(|poisoned| poisoned.into_inner())
5490 .insert(id.clone(), cleanup_ownership.clone());
5491 if let Err(error) = self.persist_state_locked() {
5492 self.session_records
5493 .lock()
5494 .unwrap_or_else(|poisoned| poisoned.into_inner())
5495 .records
5496 .insert(record_id.clone(), original_record);
5497 self.materializations
5498 .lock()
5499 .unwrap_or_else(|poisoned| poisoned.into_inner())
5500 .insert(id.clone(), original_ownership);
5501 return Err(persistence_failure(error));
5502 }
5503 (terminal_record, cleanup_ownership)
5504 };
5505 let cleanup = self
5506 .session_environment_materializer
5507 .as_ref()
5508 .ok_or(())
5509 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5510 if cleanup.is_err() {
5511 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5512 self.materializations
5513 .lock()
5514 .unwrap_or_else(|poisoned| poisoned.into_inner())
5515 .insert(id.clone(), cleanup_ownership);
5516 if self.persist_state().is_err() {
5517 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5518 }
5519 return Err(failure(
5520 NodeFailureCode::BackendOperationFailed,
5521 "managed session environment cleanup requires recovery",
5522 ));
5523 }
5524 let _transaction = self
5525 .state_transaction
5526 .lock()
5527 .unwrap_or_else(|poisoned| poisoned.into_inner());
5528 self.remove_record_memory(record_id);
5529 self.materializations
5530 .lock()
5531 .unwrap_or_else(|poisoned| poisoned.into_inner())
5532 .remove(&id);
5533 if let Err(error) = self.persist_state_locked() {
5534 self.session_records
5535 .lock()
5536 .unwrap_or_else(|poisoned| poisoned.into_inner())
5537 .records
5538 .insert(record_id.clone(), terminal_record);
5539 self.materializations
5540 .lock()
5541 .unwrap_or_else(|poisoned| poisoned.into_inner())
5542 .insert(id, cleanup_ownership);
5543 return Err(persistence_failure(error));
5544 }
5545 Ok(true)
5546 }
5547
5548 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5549 let changed = {
5550 let mut materializations = self
5551 .materializations
5552 .lock()
5553 .unwrap_or_else(|poisoned| poisoned.into_inner());
5554 let Some(ownership) = materializations.get_mut(id) else {
5555 return;
5556 };
5557 ownership.mark_recovery_required(unix_time_ms()).is_ok()
5558 };
5559 if changed && self.persist_state().is_err() {
5560 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5561 }
5562 }
5563
5564 fn reconcile_materializations(&self) {
5565 let records = self
5566 .materializations
5567 .lock()
5568 .unwrap_or_else(|poisoned| poisoned.into_inner())
5569 .values()
5570 .cloned()
5571 .collect::<Vec<_>>();
5572 for mut ownership in records {
5573 let result = match (ownership.state(), ownership.owner()) {
5574 (MaterializationState::Preparing, _)
5575 | (MaterializationState::CleanupRequired, _) => {
5576 self.cleanup_materialization(ownership.id())
5577 }
5578 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5579 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5580 self.materializations
5581 .lock()
5582 .unwrap_or_else(|poisoned| poisoned.into_inner())
5583 .insert(ownership.id().clone(), ownership.clone());
5584 self.persist_state().map_err(persistence_failure)
5585 } else {
5586 Err(failure(
5587 NodeFailureCode::BackendOperationFailed,
5588 "session environment reconciliation failed",
5589 ))
5590 }
5591 }
5592 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5593 .session_environment_materializer
5594 .as_ref()
5595 .ok_or_else(|| {
5596 failure(
5597 NodeFailureCode::BackendOperationFailed,
5598 "session environment materializer is unavailable",
5599 )
5600 })
5601 .and_then(|materializer| {
5602 materializer.revalidate(&ownership).map_err(|_| failure(
5603 NodeFailureCode::BackendOperationFailed,
5604 "session materialization revalidation failed",
5605 ))?;
5606 let record = self.record(record_id)?;
5607 if record.environment_profile.as_ref() != ownership.environment_profile()
5608 || record.bundle.as_ref() != ownership.bundle()
5609 || record.context.as_ref() != ownership.context()
5610 {
5611 return Err(failure(
5612 NodeFailureCode::BackendOperationFailed,
5613 "session materialization ownership changed after restart",
5614 ));
5615 }
5616 if let Some(receipt) = ownership.bundle() {
5617 let catalog = self.bundle_catalog.read()
5618 .unwrap_or_else(|poisoned| poisoned.into_inner());
5619 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5620 NodeFailureCode::UnknownBundle,
5621 "session bundle is unavailable after restart",
5622 ))?;
5623 if bundle.receipt() != *receipt {
5624 return Err(failure(
5625 NodeFailureCode::BundleBindingMismatch,
5626 "session bundle changed after restart",
5627 ));
5628 }
5629 let layout = self.bundle_layout(
5630 &record.provider,
5631 record.mode,
5632 record.environment_profile.as_ref(),
5633 bundle,
5634 )?;
5635 materializer
5636 .revalidate_bundle(&ownership, bundle, layout)
5637 .map_err(|_| failure(
5638 NodeFailureCode::BundleMaterializationFailed,
5639 "session bundle revalidation failed",
5640 ))?;
5641 }
5642 if let Some(receipt) = ownership.context() {
5643 let pack = materializer
5644 .revalidate_context(&ownership, receipt)
5645 .map_err(|_| {
5646 failure(
5647 NodeFailureCode::ContextPackMaterializationFailed,
5648 "session context revalidation failed",
5649 )
5650 })?;
5651 self.context_catalog
5652 .write()
5653 .unwrap_or_else(|poisoned| poisoned.into_inner())
5654 .insert(pack)
5655 .map_err(|_| {
5656 failure(
5657 NodeFailureCode::ContextPackMaterializationFailed,
5658 "session context catalog restore failed",
5659 )
5660 })?;
5661 }
5662 Ok(())
5663 }),
5664 (MaterializationState::RecoveryRequired, _) => Ok(()),
5665 };
5666 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5667 let _ = ownership.mark_recovery_required(unix_time_ms());
5668 self.materializations
5669 .lock()
5670 .unwrap_or_else(|poisoned| poisoned.into_inner())
5671 .insert(ownership.id().clone(), ownership);
5672 if self.persist_state().is_err() {
5673 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5674 }
5675 }
5676 }
5677 let materialized_profiles = self
5678 .environment_materialization_profiles
5679 .read()
5680 .unwrap_or_else(|poisoned| poisoned.into_inner())
5681 .keys()
5682 .cloned()
5683 .collect::<Vec<_>>();
5684 let ownership = self
5685 .materializations
5686 .lock()
5687 .unwrap_or_else(|poisoned| poisoned.into_inner())
5688 .values()
5689 .cloned()
5690 .collect::<Vec<_>>();
5691 let changed = {
5692 let mut session_records = self
5693 .session_records
5694 .lock()
5695 .unwrap_or_else(|poisoned| poisoned.into_inner());
5696 let mut changed = false;
5697 for record in session_records.records.values_mut() {
5698 let environment_profile = record.environment_profile.as_ref();
5699 if environment_profile.is_none()
5700 && record.bundle.is_none()
5701 && record.context.is_none()
5702 {
5703 continue;
5704 }
5705 if record.bundle.is_none()
5706 && environment_profile.is_some_and(|profile| {
5707 !materialized_profiles.contains(&profile.profile_id)
5708 })
5709 {
5710 continue;
5711 }
5712 let has_exact_ownership = ownership.iter().any(|candidate| {
5713 candidate.state() == MaterializationState::Ready
5714 && candidate.environment_profile() == environment_profile
5715 && candidate.bundle() == record.bundle.as_ref()
5716 && candidate.context() == record.context.as_ref()
5717 && candidate.owner()
5718 == &MaterializationOwner::Record {
5719 record_id: record.record_id.clone(),
5720 }
5721 });
5722 if !has_exact_ownership {
5723 record.provider_session = None;
5724 record.active_session = None;
5725 record.state = ManagedSessionState::Unavailable;
5726 record.last_error = Some(if record.bundle.is_some() {
5727 "bundle-unavailable".to_owned()
5728 } else if record.context.is_some() {
5729 "context-unavailable".to_owned()
5730 } else {
5731 "environment-profile-unavailable".to_owned()
5732 });
5733 record.updated_at_unix_ms = unix_time_ms();
5734 changed = true;
5735 }
5736 }
5737 changed
5738 };
5739 if changed && self.persist_state().is_err() {
5740 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5741 }
5742 }
5743
5744 fn replay_spawn_spec(
5745 &self,
5746 spec: &SpawnSpec,
5747 now: Instant,
5748 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5749 let mut cache = self
5750 .spawn_idempotency
5751 .lock()
5752 .unwrap_or_else(|poisoned| poisoned.into_inner());
5753 cache.entries.retain(|_, entry| entry.expires_at > now);
5754 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5755 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5756 return Err(failure(
5757 NodeFailureCode::SpawnIdempotencyConflict,
5758 "spawn idempotency key was reused across spawn kinds",
5759 ));
5760 };
5761 if existing != spec {
5762 return Err(failure(
5763 NodeFailureCode::SpawnIdempotencyConflict,
5764 "spawn idempotency key was reused with a different specification",
5765 ));
5766 }
5767 return Ok(Some(result.clone()));
5768 }
5769 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5770 return Err(failure(
5771 NodeFailureCode::SpawnIdempotencyCapacity,
5772 "spawn idempotency cache reached its bounded capacity",
5773 ));
5774 }
5775 Ok(None)
5776 }
5777
5778 fn remember_spawn_spec(
5779 &self,
5780 spec: SpawnSpec,
5781 result: Result<ResolvedSpawnReceipt, NodeFailure>,
5782 accepted_at: Instant,
5783 ) {
5784 self
5785 .spawn_idempotency
5786 .lock()
5787 .unwrap_or_else(|poisoned| poisoned.into_inner())
5788 .entries
5789 .insert(
5790 spec.idempotency_key.clone(),
5791 SpawnIdempotencyEntry {
5792 value: SpawnIdempotencyValue::Standard { spec, result },
5793 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5794 },
5795 );
5796 }
5797
5798 fn claim_harness_mcp_spawn(
5799 &self,
5800 _reservation_id: &HarnessMcpReservationId,
5801 _activation_digest: &HarnessMcpActivationDigest,
5802 spec: &SpawnSpec,
5803 now: Instant,
5804 ) -> Result<(), NodeFailure> {
5805 let mut cache = self.spawn_idempotency.lock()
5806 .unwrap_or_else(|poisoned| poisoned.into_inner());
5807 cache.entries.retain(|_, entry| entry.expires_at > now);
5808 if cache.entries.contains_key(&spec.idempotency_key) {
5809 return Err(failure(
5810 NodeFailureCode::SpawnIdempotencyConflict,
5811 "spawn idempotency key was reused across spawn kinds",
5812 ));
5813 }
5814 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5815 return Err(failure(
5816 NodeFailureCode::SpawnIdempotencyCapacity,
5817 "spawn idempotency cache reached its bounded capacity",
5818 ));
5819 }
5820 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5821 value: SpawnIdempotencyValue::HarnessMcp,
5822 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5823 });
5824 Ok(())
5825 }
5826
5827 fn replay_managed_spawn(
5828 &self,
5829 request: &ManagedWorktreeSpawnRequest,
5830 now: Instant,
5831 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5832 let mut cache = self.spawn_idempotency.lock()
5833 .unwrap_or_else(|poisoned| poisoned.into_inner());
5834 cache.entries.retain(|_, entry| entry.expires_at > now);
5835 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5836 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5837 return Err(failure(
5838 NodeFailureCode::SpawnIdempotencyConflict,
5839 "spawn idempotency key was reused across spawn kinds",
5840 ));
5841 };
5842 if existing != request {
5843 return Err(failure(
5844 NodeFailureCode::SpawnIdempotencyConflict,
5845 "spawn idempotency key was reused with a different managed specification",
5846 ));
5847 }
5848 return Ok(Some(result.clone()));
5849 }
5850 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5851 return Err(failure(
5852 NodeFailureCode::SpawnIdempotencyCapacity,
5853 "spawn idempotency cache reached its bounded capacity",
5854 ));
5855 }
5856 Ok(None)
5857 }
5858
5859 fn remember_managed_spawn(
5860 &self,
5861 request: ManagedWorktreeSpawnRequest,
5862 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5863 accepted_at: Instant,
5864 ) {
5865 self.spawn_idempotency.lock()
5866 .unwrap_or_else(|poisoned| poisoned.into_inner())
5867 .entries
5868 .insert(
5869 request.spawn_spec.idempotency_key.clone(),
5870 SpawnIdempotencyEntry {
5871 value: SpawnIdempotencyValue::Managed { request, result },
5872 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5873 },
5874 );
5875 }
5876
5877 fn remember_managed_spawn_attempt(
5878 &self,
5879 durable_v2_key: Option<&SpawnIdempotencyKey>,
5880 request: ManagedWorktreeSpawnRequest,
5881 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5882 accepted_at: Instant,
5883 ) {
5884 if durable_v2_key.is_none() {
5885 self.remember_managed_spawn(request, result, accepted_at);
5886 }
5887 }
5888
5889 fn acquire_durable_managed_spawn_v2(
5890 &self,
5891 request: &ManagedWorktreeSpawnRequestV2,
5892 request_digest: String,
5893 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5894 if self.state_path.is_none() {
5895 return Err(failure(
5896 NodeFailureCode::BackendOperationFailed,
5897 "managed worktree V2 requires durable node state",
5898 ));
5899 }
5900 let _transaction = self.state_transaction
5901 .lock()
5902 .unwrap_or_else(|poisoned| poisoned.into_inner());
5903 let key = request.spawn_spec.idempotency_key.clone();
5904 let mut previous = None;
5905 {
5906 let mut replays = self.managed_spawn_replays
5907 .lock()
5908 .unwrap_or_else(|poisoned| poisoned.into_inner());
5909 if let Some(record) = replays.get_mut(&key) {
5910 if record.request_digest != request_digest {
5911 return Err(failure(
5912 NodeFailureCode::SpawnIdempotencyConflict,
5913 "managed worktree V2 idempotency key was reused with a different request",
5914 ));
5915 }
5916 match &record.state {
5917 ManagedWorktreeSpawnReplayStateV10::Pending {
5918 owner_incarnation_id,
5919 lease_id: None,
5920 } if owner_incarnation_id != &self.incarnation_id => {
5921 previous = Some(record.clone());
5922 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
5923 owner_incarnation_id: self.incarnation_id,
5924 lease_id: None,
5925 };
5926 record.updated_at_unix_ms = unix_time_ms();
5927 }
5928 ManagedWorktreeSpawnReplayStateV10::Pending {
5929 lease_id: None,
5930 ..
5931 } => {
5932 return Err(failure(
5933 NodeFailureCode::BackendBusy,
5934 "managed worktree V2 reservation is already active",
5935 ));
5936 }
5937 ManagedWorktreeSpawnReplayStateV10::Pending {
5938 lease_id: Some(_),
5939 ..
5940 } => {
5941 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
5942 }
5943 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
5944 return Ok(DurableManagedSpawnReplayDecision::Committed(
5945 receipt.clone(),
5946 ));
5947 }
5948 }
5949 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
5950 return Err(failure(
5951 NodeFailureCode::SpawnIdempotencyCapacity,
5952 "durable managed worktree replay ledger reached its bounded capacity",
5953 ));
5954 } else {
5955 let now = unix_time_ms();
5956 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
5957 idempotency_key: key.clone(),
5958 request_digest,
5959 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
5960 profile_id: request.worktree_profile_id.clone(),
5961 expected_profile_revision: request.expected_profile_revision.clone(),
5962 state: ManagedWorktreeSpawnReplayStateV10::Pending {
5963 owner_incarnation_id: self.incarnation_id,
5964 lease_id: None,
5965 },
5966 created_at_unix_ms: now,
5967 updated_at_unix_ms: now,
5968 });
5969 }
5970 }
5971 if let Err(error) = self.persist_state_locked() {
5972 let mut replays = self.managed_spawn_replays
5973 .lock()
5974 .unwrap_or_else(|poisoned| poisoned.into_inner());
5975 match previous {
5976 Some(previous) => {
5977 replays.insert(key, previous);
5978 }
5979 None => {
5980 replays.remove(&key);
5981 }
5982 }
5983 return Err(persistence_failure(error));
5984 }
5985 Ok(DurableManagedSpawnReplayDecision::Reserved)
5986 }
5987
5988 fn link_durable_managed_spawn_v2_lease_locked(
5989 &self,
5990 key: &SpawnIdempotencyKey,
5991 lease_id: &ManagedWorktreeLeaseId,
5992 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
5993 let mut replays = self.managed_spawn_replays
5994 .lock()
5995 .unwrap_or_else(|poisoned| poisoned.into_inner());
5996 let record = replays.get_mut(key).ok_or_else(|| {
5997 failure(
5998 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5999 "managed worktree V2 reservation disappeared before allocation",
6000 )
6001 })?;
6002 let previous = record.clone();
6003 match &mut record.state {
6004 ManagedWorktreeSpawnReplayStateV10::Pending {
6005 lease_id: current,
6006 ..
6007 }
6008 if current.is_none() =>
6009 {
6010 *current = Some(lease_id.clone());
6011 record.updated_at_unix_ms = unix_time_ms();
6012 Ok(previous)
6013 }
6014 _ => Err(failure(
6015 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6016 "managed worktree V2 reservation is not allocation-ready",
6017 )),
6018 }
6019 }
6020
6021 fn restore_durable_managed_spawn_v2_locked(
6022 &self,
6023 key: &SpawnIdempotencyKey,
6024 previous: ManagedWorktreeSpawnReplayRecordV10,
6025 ) {
6026 self.managed_spawn_replays
6027 .lock()
6028 .unwrap_or_else(|poisoned| poisoned.into_inner())
6029 .insert(key.clone(), previous);
6030 }
6031
6032 fn commit_durable_managed_spawn_v2(
6033 &self,
6034 request: &ManagedWorktreeSpawnRequestV2,
6035 receipt: ManagedWorktreeSpawnReceipt,
6036 ) -> Result<(), NodeFailure> {
6037 let _transaction = self.state_transaction
6038 .lock()
6039 .unwrap_or_else(|poisoned| poisoned.into_inner());
6040 let key = &request.spawn_spec.idempotency_key;
6041 let previous = {
6042 let mut replays = self.managed_spawn_replays
6043 .lock()
6044 .unwrap_or_else(|poisoned| poisoned.into_inner());
6045 let record = replays.get_mut(key).ok_or_else(|| {
6046 failure(
6047 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6048 "managed worktree V2 reservation disappeared before commit",
6049 )
6050 })?;
6051 let previous = record.clone();
6052 match &record.state {
6053 ManagedWorktreeSpawnReplayStateV10::Pending {
6054 lease_id: Some(lease_id),
6055 ..
6056 } if lease_id == &receipt.lease.lease_id => {}
6057 _ => {
6058 return Err(failure(
6059 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6060 "managed worktree V2 reservation is not linked to the spawned lease",
6061 ));
6062 }
6063 }
6064 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6065 record.updated_at_unix_ms = unix_time_ms();
6066 previous
6067 };
6068 if let Err(error) = self.persist_state_locked() {
6069 self.restore_durable_managed_spawn_v2_locked(key, previous);
6070 return Err(persistence_failure(error));
6071 }
6072 Ok(())
6073 }
6074
6075 fn remove_unallocated_durable_managed_spawn_v2(
6076 &self,
6077 request: &ManagedWorktreeSpawnRequestV2,
6078 ) -> Result<(), NodeFailure> {
6079 let _transaction = self.state_transaction
6080 .lock()
6081 .unwrap_or_else(|poisoned| poisoned.into_inner());
6082 let key = &request.spawn_spec.idempotency_key;
6083 let removed = {
6084 let mut replays = self.managed_spawn_replays
6085 .lock()
6086 .unwrap_or_else(|poisoned| poisoned.into_inner());
6087 match replays.get(key).map(|record| &record.state) {
6088 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6089 lease_id: None,
6090 ..
6091 }) => {
6092 replays.remove(key)
6093 }
6094 _ => None,
6095 }
6096 };
6097 let Some(removed) = removed else {
6098 return Ok(());
6099 };
6100 if let Err(error) = self.persist_state_locked() {
6101 self.managed_spawn_replays
6102 .lock()
6103 .unwrap_or_else(|poisoned| poisoned.into_inner())
6104 .insert(key.clone(), removed);
6105 return Err(persistence_failure(error));
6106 }
6107 Ok(())
6108 }
6109
6110 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6111 if spec.target.node_id != self.node_id {
6112 return Err(failure(
6113 NodeFailureCode::SpawnTargetMismatch,
6114 "spawn target does not match this node",
6115 ));
6116 }
6117 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6118 failure(
6119 NodeFailureCode::UnknownSpawnProfile,
6120 "spawn profile is unavailable on this node",
6121 )
6122 })?;
6123 let resolved = spec.resolve(defaults).map_err(|error| match error {
6124 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6125 NodeFailureCode::SpawnProfileRevisionMismatch,
6126 "spawn profile revision does not match the loaded profile",
6127 ),
6128 _ => failure(
6129 NodeFailureCode::InvalidRequest,
6130 "spawn specification could not be resolved",
6131 ),
6132 })?;
6133 if resolved.target.worktree_id.is_some() {
6134 self.require_worktree_service(&resolved.target.workspace_id)?;
6135 }
6136 let environment_profile = self.resolve_environment_profile(&resolved)?;
6137 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6138 self.resolve_context(&resolved)?;
6139 Ok(resolved)
6140 }
6141
6142 async fn resolve_spawn_workspace(
6143 &self,
6144 resolved: &ResolvedSpawnSpec,
6145 deadline: Instant,
6146 ) -> Result<WorkspaceId, NodeFailure> {
6147 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6148 return Ok(resolved.target.workspace_id.clone());
6149 };
6150 if self.managed_worktrees.lock()
6151 .unwrap_or_else(|poisoned| poisoned.into_inner())
6152 .lease_for_workspace(worktree_id)
6153 .is_some()
6154 {
6155 return Err(failure(
6156 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6157 "managed worktree targets may only be spawned through their lease request",
6158 ));
6159 }
6160 self.require_worktree_service(&resolved.target.workspace_id)?;
6161 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6162 let selected_root = self.workspace_root(worktree_id)?;
6163 let remaining = spawn_deadline_remaining(deadline)?;
6164 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6165 .await
6166 .map_err(|_| {
6167 failure(
6168 NodeFailureCode::SpawnDeadlineExceeded,
6169 "Git worktree selection exceeded the spawn deadline",
6170 )
6171 })?
6172 .map_err(git_worktree_failure)?;
6173 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6174 Ok(worktree_id.clone())
6175 }
6176
6177 async fn spawn_from_spec(
6178 &self,
6179 spec: SpawnSpec,
6180 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6181 self.spawn_from_spec_with_proxy(spec, None).await
6182 }
6183
6184 async fn spawn_from_spec_with_proxy(
6185 &self,
6186 spec: SpawnSpec,
6187 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6188 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6189 let accepted_at = Instant::now();
6190 if harness_mcp.is_none() {
6191 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6192 return replayed;
6193 }
6194 }
6195 let resolved = self.resolve_spawn_spec(&spec)?;
6196 let environment_profile = self.resolve_environment_profile(&resolved)?;
6197 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6198 let context = self.resolve_context(&resolved)?;
6199 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6200 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6201 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6202 let spawn_workspace_id = match spawn_workspace_id {
6203 Ok(workspace_id) => workspace_id,
6204 Err(error) => {
6205 let result = Err(error);
6206 if harness_mcp.is_none() {
6207 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6208 }
6209 return result;
6210 }
6211 };
6212 if self.managed_worktrees.lock()
6213 .unwrap_or_else(|poisoned| poisoned.into_inner())
6214 .lease_for_workspace(&spawn_workspace_id)
6215 .is_some()
6216 {
6217 let result = Err(failure(
6218 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6219 "managed worktree targets may only be spawned through their lease request",
6220 ));
6221 if harness_mcp.is_none() {
6222 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6223 }
6224 return result;
6225 }
6226 let result = self
6227 .spawn_session_with_deadline(
6228 spawn_workspace_id,
6229 resolved.provider.clone(),
6230 resolved.mode,
6231 resolved.terminal_size,
6232 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6233 environment_profile.clone(),
6234 bundle.clone(),
6235 context.clone(),
6236 None,
6237 None,
6238 SpawnRecordPolicy::Always,
6239 Some(deadline),
6240 &required_capabilities,
6241 harness_mcp,
6242 resolved.approval_level,
6243 resolved.network_allowlist.clone(),
6244 resolved.browser_profile_id.clone(),
6245 )
6246 .await
6247 .map(|(session, _runtime_policy)| {
6248 resolved.receipt_with_materialization(
6249 self.incarnation_id,
6250 session,
6251 environment_profile,
6252 bundle,
6253 context,
6254 )
6255 });
6256 if harness_mcp.is_none() {
6257 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6258 }
6259 result
6260 }
6261
6262 async fn spawn_from_spec_with_harness_mcp(
6263 &self,
6264 reservation_id: HarnessMcpReservationId,
6265 activation_digest: HarnessMcpActivationDigest,
6266 spec: SpawnSpec,
6267 deadline_unix_ms: u64,
6268 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6269 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6270 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6271 })?;
6272 if let Some(receipt) = registry
6273 .replay_spawn(&reservation_id, &activation_digest, &spec)
6274 .map_err(harness_mcp_failure)?
6275 {
6276 return Ok(receipt);
6277 }
6278 let resolved = self.resolve_spawn_spec(&spec)?;
6279 let prepared = registry
6280 .prepare_spawn(
6281 &reservation_id,
6282 &activation_digest,
6283 &spec,
6284 deadline_unix_ms,
6285 resolved.provider.clone(),
6286 )
6287 .map_err(harness_mcp_failure)?;
6288 self.claim_harness_mcp_spawn(
6289 &reservation_id,
6290 &activation_digest,
6291 &spec,
6292 Instant::now(),
6293 )?;
6294 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6295 let mut receipt = match spawn {
6296 Ok(receipt) => receipt,
6297 Err(error) => {
6298 let _ = registry.abort(&reservation_id, &activation_digest);
6299 return Err(error);
6300 }
6301 };
6302 let record_id = self
6303 .session_bindings
6304 .lock()
6305 .unwrap_or_else(|poisoned| poisoned.into_inner())
6306 .get(&receipt.session.session.instance_id)
6307 .and_then(|binding| binding.record_id.clone());
6308 let record_id = match record_id {
6309 Some(record_id) => record_id,
6310 None => {
6311 let rollback = self.remove_session(&receipt.session).await;
6312 let _ = registry.abort(&reservation_id, &activation_digest);
6313 if let Err(rollback_error) = rollback {
6314 return Err(rollback_error);
6315 }
6316 return Err(failure(
6317 NodeFailureCode::BindingMismatch,
6318 "harness MCP spawn did not retain its exact managed record",
6319 ));
6320 }
6321 };
6322 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6323 reservation_id: reservation_id.clone(),
6324 activation_digest: activation_digest.clone(),
6325 });
6326 if let Err(error) = registry.mark_spawned(
6327 &prepared,
6328 receipt.session.clone(),
6329 record_id,
6330 receipt.clone(),
6331 ) {
6332 let rollback = self.remove_session(&receipt.session).await;
6333 let _ = registry.abort(&reservation_id, &activation_digest);
6334 if let Err(rollback_error) = rollback {
6335 return Err(rollback_error);
6336 }
6337 return Err(harness_mcp_failure(error));
6338 }
6339 Ok(receipt)
6340 }
6341
6342 async fn spawn_managed_worktree(
6343 &self,
6344 request: ManagedWorktreeSpawnRequest,
6345 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6346 self.spawn_managed_worktree_inner(request, None).await
6347 }
6348
6349 async fn spawn_managed_worktree_inner(
6350 &self,
6351 request: ManagedWorktreeSpawnRequest,
6352 durable_v2_key: Option<&SpawnIdempotencyKey>,
6353 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6354 let accepted_at = Instant::now();
6355 if durable_v2_key.is_none() {
6356 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6357 return replayed;
6358 }
6359 }
6360 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6361 if request.spawn_spec.target.worktree_id.is_some() {
6362 let result = Err(failure(
6363 NodeFailureCode::InvalidRequest,
6364 "managed worktree spawn must not select a caller-provided worktree",
6365 ));
6366 self.remember_managed_spawn_attempt(
6367 durable_v2_key,
6368 request,
6369 result.clone(),
6370 accepted_at,
6371 );
6372 return result;
6373 }
6374 let environment_profile = self.resolve_environment_profile(&resolved)?;
6375 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6376 let context = self.resolve_context(&resolved)?;
6377 let source_workspace_id = resolved.target.workspace_id.clone();
6378 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6379 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6380 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6381 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6382 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6383 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6384 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6385 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6386 };
6387 let runtime_policy = timeout(
6388 spawn_deadline_remaining(deadline)?,
6389 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6390 ).await.map_err(|_| failure(
6391 NodeFailureCode::SpawnDeadlineExceeded,
6392 "provider admission exceeded the managed spawn deadline",
6393 ))??;
6394 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6395 return Err(failure(
6396 NodeFailureCode::UnsupportedSpawnCapability,
6397 "provider runtime does not admit a required spawn capability",
6398 ));
6399 }
6400 self.ensure_binding_capacity()?;
6401 let source_root = self.workspace_root(&source_workspace_id)?;
6402 profile.validate_for_workspace(&source_root)
6403 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6404 let base_timeout_ms = u64::try_from(
6405 spawn_deadline_remaining(deadline)?.as_millis(),
6406 ).unwrap_or(u64::MAX).max(1);
6407 let base_commit = resolve_base_commit_with_timeout(
6408 &source_root,
6409 profile.base(),
6410 base_timeout_ms,
6411 ).await.map_err(|error| {
6412 if Instant::now() >= deadline || error.message.contains("timed out") {
6413 failure(
6414 NodeFailureCode::SpawnDeadlineExceeded,
6415 "managed worktree base resolution exceeded the spawn deadline",
6416 )
6417 } else {
6418 managed_git_worktree_failure(error)
6419 }
6420 })?;
6421
6422 let lease = {
6423 let _transaction = self.state_transaction.lock()
6424 .unwrap_or_else(|poisoned| poisoned.into_inner());
6425 let previous = self.managed_worktrees.lock()
6426 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6427 let lease = self.managed_worktrees.lock()
6428 .unwrap_or_else(|poisoned| poisoned.into_inner())
6429 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6430 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6431 let previous_replay = match durable_v2_key
6432 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6433 .transpose()
6434 {
6435 Ok(previous) => previous,
6436 Err(error) => {
6437 *self.managed_worktrees.lock()
6438 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6439 return Err(error);
6440 }
6441 };
6442 if let Err(error) = self.persist_state_locked() {
6443 *self.managed_worktrees.lock()
6444 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6445 if let (Some(key), Some(previous_replay)) =
6446 (durable_v2_key, previous_replay)
6447 {
6448 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6449 }
6450 return Err(persistence_failure(error));
6451 }
6452 lease
6453 };
6454 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6455 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6456 self.managed_worktrees.lock()
6457 .unwrap_or_else(|poisoned| poisoned.into_inner())
6458 .remove_unmutated(&lease.lease_id);
6459 if self.persist_state().is_err() {
6460 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6461 }
6462 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6463 self.remember_managed_spawn_attempt(
6464 durable_v2_key,
6465 request,
6466 result.clone(),
6467 accepted_at,
6468 );
6469 return result;
6470 }
6471 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6472 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6473 Err(error) => {
6474 self.reconcile_failed_allocation(&lease, &source_root).await;
6475 let result = Err(error);
6476 self.remember_managed_spawn_attempt(
6477 durable_v2_key,
6478 request,
6479 result.clone(),
6480 accepted_at,
6481 );
6482 return result;
6483 }
6484 };
6485 let created = match create_git_worktree_with_timeout(
6486 &source_root,
6487 &lease.target_root,
6488 &lease.branch,
6489 Some(&base_commit),
6490 mutation_timeout_ms,
6491 ).await {
6492 Ok(created) => created,
6493 Err(error) => {
6494 self.reconcile_failed_allocation(&lease, &source_root).await;
6495 let result = Err(if Instant::now() >= deadline
6496 || error.message.contains("timed out")
6497 || error.message.contains("deadline")
6498 {
6499 failure(
6500 NodeFailureCode::SpawnDeadlineExceeded,
6501 "managed worktree creation exceeded the spawn deadline",
6502 )
6503 } else {
6504 managed_git_worktree_failure(error)
6505 });
6506 self.remember_managed_spawn_attempt(
6507 durable_v2_key,
6508 request,
6509 result.clone(),
6510 accepted_at,
6511 );
6512 return result;
6513 }
6514 };
6515 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6516 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6517 let result = Err(failure(
6518 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6519 "created worktree identity did not match its durable lease",
6520 ));
6521 self.remember_managed_spawn_attempt(
6522 durable_v2_key,
6523 request,
6524 result.clone(),
6525 accepted_at,
6526 );
6527 return result;
6528 }
6529 {
6530 let mut registry = self.managed_worktrees.lock()
6531 .unwrap_or_else(|poisoned| poisoned.into_inner());
6532 let current = registry.get_mut(&lease.lease_id)
6533 .expect("allocated managed worktree lease remains present");
6534 current.expected_head = Some(created.head.clone());
6535 current.state = ManagedWorktreeLeaseState::Ready;
6536 current.updated_at_unix_ms = unix_time_ms();
6537 }
6538 if let Err(error) = self.persist_state() {
6539 self.mark_managed_recovery_required(
6540 &lease.lease_id,
6541 ManagedWorktreeCleanupFailure::Backend,
6542 );
6543 let result = Err(persistence_failure(error));
6544 self.remember_managed_spawn_attempt(
6545 durable_v2_key,
6546 request,
6547 result.clone(),
6548 accepted_at,
6549 );
6550 return result;
6551 }
6552 let ready = self.managed_worktrees.lock()
6553 .unwrap_or_else(|poisoned| poisoned.into_inner())
6554 .get(&lease.lease_id)
6555 .expect("ready managed worktree lease remains present")
6556 .snapshot();
6557 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6558 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6559 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6560 let result = Err(error);
6561 self.remember_managed_spawn_attempt(
6562 durable_v2_key,
6563 request,
6564 result.clone(),
6565 accepted_at,
6566 );
6567 return result;
6568 }
6569 let spawn = self.spawn_session_with_deadline(
6570 lease.workspace_id.clone(),
6571 resolved.provider.clone(),
6572 resolved.mode,
6573 resolved.terminal_size,
6574 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6575 environment_profile.clone(),
6576 bundle.clone(),
6577 context.clone(),
6578 Some(lease.lease_id.clone()),
6579 Some(runtime_policy),
6580 SpawnRecordPolicy::Always,
6581 Some(deadline),
6582 &required_capabilities,
6583 None,
6584 resolved.approval_level,
6585 resolved.network_allowlist.clone(),
6586 resolved.browser_profile_id.clone(),
6587 ).await;
6588 let (session, effective_runtime_policy) = match spawn {
6589 Ok(spawned) => spawned,
6590 Err(error) => {
6591 if self
6592 .cleanup_managed_worktree(&lease.lease_id, true)
6593 .await
6594 .is_err()
6595 {
6596 self.mark_managed_recovery_required(
6597 &lease.lease_id,
6598 ManagedWorktreeCleanupFailure::Backend,
6599 );
6600 }
6601 let result = Err(error);
6602 self.remember_managed_spawn_attempt(
6603 durable_v2_key,
6604 request,
6605 result.clone(),
6606 accepted_at,
6607 );
6608 return result;
6609 }
6610 };
6611 let (record_id, raw_inventory_record_id) = {
6612 let mut bindings = self.session_bindings.lock()
6613 .unwrap_or_else(|poisoned| poisoned.into_inner());
6614 let binding = bindings.get_mut(&session.session.instance_id)
6615 .expect("successful spawn retains its session binding");
6616 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6617 managed_spawn_record_ownership(binding, effective_runtime_policy)
6618 };
6619 let bound = self.managed_worktrees.lock()
6620 .unwrap_or_else(|poisoned| poisoned.into_inner())
6621 .bind_session(
6622 &lease.lease_id,
6623 ManagedWorktreeSessionHolder {
6624 incarnation_id: self.incarnation_id,
6625 instance_id: session.session.instance_id,
6626 generation: session.session.generation,
6627 },
6628 record_id,
6629 unix_time_ms(),
6630 )
6631 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6632 let snapshot = match bound {
6633 Ok(snapshot) => snapshot,
6634 Err(error) => {
6635 if raw_inventory_record_id
6636 .as_ref()
6637 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6638 {
6639 self.mark_managed_recovery_required(
6640 &lease.lease_id,
6641 ManagedWorktreeCleanupFailure::Backend,
6642 );
6643 }
6644 let _ = self.remove_session(&session).await;
6645 self.mark_managed_recovery_required(
6646 &lease.lease_id,
6647 ManagedWorktreeCleanupFailure::Backend,
6648 );
6649 let result = Err(error);
6650 self.remember_managed_spawn_attempt(
6651 durable_v2_key,
6652 request,
6653 result.clone(),
6654 accepted_at,
6655 );
6656 return result;
6657 }
6658 };
6659 if let Err(error) = self.persist_state() {
6660 if raw_inventory_record_id
6661 .as_ref()
6662 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6663 {
6664 self.mark_managed_recovery_required(
6665 &lease.lease_id,
6666 ManagedWorktreeCleanupFailure::Backend,
6667 );
6668 }
6669 let _ = self.remove_session(&session).await;
6670 if self
6671 .cleanup_managed_worktree(&lease.lease_id, true)
6672 .await
6673 .is_err()
6674 {
6675 self.mark_managed_recovery_required(
6676 &lease.lease_id,
6677 ManagedWorktreeCleanupFailure::Backend,
6678 );
6679 }
6680 let result = Err(persistence_failure(error));
6681 self.remember_managed_spawn_attempt(
6682 durable_v2_key,
6683 request,
6684 result.clone(),
6685 accepted_at,
6686 );
6687 return result;
6688 }
6689 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6690 let receipt = managed_spawn_receipt(
6691 &resolved,
6692 self.incarnation_id,
6693 session,
6694 snapshot,
6695 environment_profile,
6696 bundle,
6697 context,
6698 );
6699 let result = Ok(receipt);
6700 self.remember_managed_spawn_attempt(
6701 durable_v2_key,
6702 request,
6703 result.clone(),
6704 accepted_at,
6705 );
6706 result
6707 }
6708
6709 async fn spawn_managed_worktree_v2(
6710 &self,
6711 request: ManagedWorktreeSpawnRequestV2,
6712 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6713 if request.spawn_spec.target.node_id != self.node_id {
6714 return Err(failure(
6715 NodeFailureCode::SpawnTargetMismatch,
6716 "spawn target does not match this node",
6717 ));
6718 }
6719 if request.spawn_spec.target.worktree_id.is_some() {
6720 return Err(failure(
6721 NodeFailureCode::InvalidRequest,
6722 "managed worktree spawn must not select a caller-provided worktree",
6723 ));
6724 }
6725 let request_digest = managed_spawn_request_digest_v2(&request)?;
6726 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6727 match replay {
6728 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6729 DurableManagedSpawnReplayDecision::PendingLinked => {
6730 return Err(failure(
6731 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6732 "managed worktree V2 reservation has an incomplete durable allocation",
6733 ));
6734 }
6735 DurableManagedSpawnReplayDecision::Reserved => {}
6736 }
6737 let validated = (|| {
6738 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6739 let profile = self.managed_profile(
6740 &resolved.target.workspace_id,
6741 &request.worktree_profile_id,
6742 )?;
6743 if profile.revision() != &request.expected_profile_revision {
6744 return Err(failure(
6745 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6746 "managed worktree profile revision does not match the loaded profile",
6747 ));
6748 }
6749 Ok(())
6750 })();
6751 match validated {
6752 Ok(()) => {}
6753 Err(error) => {
6754 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6755 return Err(error);
6756 }
6757 }
6758 let legacy = ManagedWorktreeSpawnRequest {
6759 spawn_spec: request.spawn_spec.clone(),
6760 worktree_profile_id: request.worktree_profile_id.clone(),
6761 };
6762 match self
6763 .spawn_managed_worktree_inner(
6764 legacy,
6765 Some(&request.spawn_spec.idempotency_key),
6766 )
6767 .await
6768 {
6769 Ok(receipt) => {
6770 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6771 Ok(receipt)
6772 }
6773 Err(error) => {
6774 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6775 Err(error)
6776 }
6777 }
6778 }
6779
6780 fn managed_profile(
6781 &self,
6782 workspace_id: &WorkspaceId,
6783 profile_id: &WorktreeProfileId,
6784 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6785 if self.worktree_service_modes.get(workspace_id).copied()
6786 != Some(WorktreeServiceMode::Managed)
6787 {
6788 return Err(failure(
6789 NodeFailureCode::UnsupportedSpawnCapability,
6790 "workspace does not enable managed worktrees",
6791 ));
6792 }
6793 self.managed_worktree_profiles.get(workspace_id)
6794 .and_then(|profiles| profiles.get(profile_id))
6795 .cloned()
6796 .ok_or_else(|| failure(
6797 NodeFailureCode::InvalidRequest,
6798 "managed worktree profile is unavailable for this workspace",
6799 ))
6800 }
6801
6802 async fn reconcile_failed_allocation(
6803 &self,
6804 lease: &ManagedWorktreeLeaseRecord,
6805 source_root: &str,
6806 ) {
6807 match list_git_worktrees(source_root).await {
6808 Ok(worktrees) => {
6809 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6810 None => {
6811 match std::fs::symlink_metadata(&lease.target_root) {
6812 Err(error) if error.kind() == io::ErrorKind::NotFound => {
6813 self.managed_worktrees.lock()
6814 .unwrap_or_else(|poisoned| poisoned.into_inner())
6815 .remove_unmutated(&lease.lease_id);
6816 let _ = self.persist_state();
6817 }
6818 _ => self.mark_managed_recovery_required(
6819 &lease.lease_id,
6820 ManagedWorktreeCleanupFailure::OwnershipConflict,
6821 ),
6822 }
6823 }
6824 Some(item) if exact_owned_worktree(
6825 lease,
6826 &item.path,
6827 item.branch.as_deref(),
6828 &item.head,
6829 ) => {
6830 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6831 }
6832 Some(_) => self.mark_managed_recovery_required(
6833 &lease.lease_id,
6834 ManagedWorktreeCleanupFailure::OwnershipConflict,
6835 ),
6836 }
6837 }
6838 Err(_) => self.mark_managed_recovery_required(
6839 &lease.lease_id,
6840 ManagedWorktreeCleanupFailure::Backend,
6841 ),
6842 }
6843 }
6844
6845 async fn reconcile_managed_worktrees(&self) {
6846 let leases = self.managed_worktrees.lock()
6847 .unwrap_or_else(|poisoned| poisoned.into_inner())
6848 .active_records()
6849 .cloned()
6850 .collect::<Vec<_>>();
6851 for lease in leases {
6852 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6853 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6854 _ => {
6855 self.mark_managed_recovery_required(
6856 &lease.lease_id,
6857 ManagedWorktreeCleanupFailure::OwnershipConflict,
6858 );
6859 continue;
6860 }
6861 };
6862 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6863 self.mark_managed_recovery_required(
6864 &lease.lease_id,
6865 ManagedWorktreeCleanupFailure::OwnershipConflict,
6866 );
6867 continue;
6868 }
6869 let source_root = match self.workspace_root(&lease.source_workspace_id) {
6870 Ok(root) => root,
6871 Err(_) => {
6872 self.mark_managed_recovery_required(
6873 &lease.lease_id,
6874 ManagedWorktreeCleanupFailure::OwnershipConflict,
6875 );
6876 continue;
6877 }
6878 };
6879 let worktrees = match list_git_worktrees(&source_root).await {
6880 Ok(worktrees) => worktrees,
6881 Err(_) => {
6882 self.mark_managed_recovery_required(
6883 &lease.lease_id,
6884 ManagedWorktreeCleanupFailure::Backend,
6885 );
6886 continue;
6887 }
6888 };
6889 let listed = worktrees.iter()
6890 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6891 let Some(listed) = listed else {
6892 match std::fs::symlink_metadata(&lease.target_root) {
6893 Err(error) if error.kind() == io::ErrorKind::NotFound
6894 && lease.state == ManagedWorktreeLeaseState::Allocating
6895 && !lease.has_holders() => {
6896 let removed = self.managed_worktrees.lock()
6897 .unwrap_or_else(|poisoned| poisoned.into_inner())
6898 .tombstone(&lease.lease_id, unix_time_ms());
6899 if self.persist_state().is_ok() && removed.is_some() {
6900 self.publish(NodeEvent::ManagedWorktreeRemoved {
6901 lease_id: lease.lease_id.clone(),
6902 });
6903 }
6904 }
6905 _ => self.mark_managed_recovery_required(
6906 &lease.lease_id,
6907 ManagedWorktreeCleanupFailure::OwnershipConflict,
6908 ),
6909 }
6910 continue;
6911 };
6912 if listed.is_main || listed.is_bare || !exact_owned_worktree(
6913 &lease,
6914 &listed.path,
6915 listed.branch.as_deref(),
6916 &listed.head,
6917 ) {
6918 self.mark_managed_recovery_required(
6919 &lease.lease_id,
6920 ManagedWorktreeCleanupFailure::OwnershipConflict,
6921 );
6922 continue;
6923 }
6924 if !self.workspaces.read()
6925 .unwrap_or_else(|poisoned| poisoned.into_inner())
6926 .contains_key(&lease.workspace_id)
6927 {
6928 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
6929 self.mark_managed_recovery_required(
6930 &lease.lease_id,
6931 ManagedWorktreeCleanupFailure::Backend,
6932 );
6933 continue;
6934 }
6935 }
6936 let snapshot = {
6937 let mut registry = self.managed_worktrees.lock()
6938 .unwrap_or_else(|poisoned| poisoned.into_inner());
6939 let current = registry.get_mut(&lease.lease_id)
6940 .expect("reconciled managed lease remains present");
6941 current.expected_head = Some(listed.head.clone());
6942 current.cleanup_failure = None;
6943 current.state = if current.has_holders() {
6944 ManagedWorktreeLeaseState::InUse
6945 } else if current.retention == ManagedWorktreeRetention::Retain {
6946 ManagedWorktreeLeaseState::Retained
6947 } else {
6948 ManagedWorktreeLeaseState::Ready
6949 };
6950 current.updated_at_unix_ms = unix_time_ms();
6951 current.snapshot()
6952 };
6953 if self.persist_state().is_err() {
6954 self.mark_managed_recovery_required(
6955 &lease.lease_id,
6956 ManagedWorktreeCleanupFailure::Backend,
6957 );
6958 continue;
6959 }
6960 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6961 if snapshot.state == ManagedWorktreeLeaseState::Ready {
6962 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
6963 }
6964 }
6965 }
6966
6967 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
6987 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
6988 .session_records
6989 .lock()
6990 .unwrap_or_else(|poisoned| poisoned.into_inner())
6991 .records
6992 .values()
6993 .filter(|record| {
6994 record.state == ManagedSessionState::Live
6995 && record.exported_context.is_none()
6996 && record.provider_session.is_some()
6997 })
6998 .filter_map(|record| {
6999 record
7000 .active_session
7001 .clone()
7002 .map(|session| (record.record_id.clone(), session))
7003 })
7004 .collect();
7005 for (record_id, session) in candidates {
7006 let shared = Arc::clone(self);
7007 tokio::spawn(async move {
7008 shared
7009 .reconcile_context_pack_export_for_record(&record_id, session)
7010 .await;
7011 });
7012 }
7013 }
7014
7015 async fn reconcile_context_pack_export_for_record(
7034 &self,
7035 record_id: &SessionRecordId,
7036 session: SessionAddress,
7037 ) {
7038 let Ok(record) = self.record(record_id) else {
7039 return;
7040 };
7041 if !matches!(
7042 record.state,
7043 ManagedSessionState::Live | ManagedSessionState::Dormant
7044 ) || record.exported_context.is_some()
7045 || record.provider_session.is_none()
7046 {
7047 return;
7048 }
7049 let Ok(receipt) = self
7056 .export_context_pack_for_session_record(&record.record_id, &session, true)
7057 .await
7058 else {
7059 return;
7060 };
7061 self.set_exported_context(&record.record_id, receipt);
7062 }
7063
7064 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7069 let transaction = self
7070 .state_transaction
7071 .lock()
7072 .unwrap_or_else(|poisoned| poisoned.into_inner());
7073 let (previous, updated) = {
7074 let mut records = self
7075 .session_records
7076 .lock()
7077 .unwrap_or_else(|poisoned| poisoned.into_inner());
7078 let Some(record) = records.records.get_mut(record_id) else {
7079 return;
7080 };
7081 if record.exported_context.is_some() {
7082 return;
7083 }
7084 let previous = record.clone();
7085 record.exported_context = Some(receipt);
7086 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7087 (previous, record.clone())
7088 };
7089 if self.persist_state_locked().is_err() {
7090 self.session_records
7091 .lock()
7092 .unwrap_or_else(|poisoned| poisoned.into_inner())
7093 .records
7094 .insert(record_id.clone(), previous);
7095 return;
7096 }
7097 drop(transaction);
7098 self.publish_record(updated);
7099 }
7100
7101 fn managed_record_export_target_for_instance(
7111 &self,
7112 instance_id: AgentInstanceId,
7113 generation: SessionGeneration,
7114 ) -> Option<(SessionRecordId, SessionAddress)> {
7115 let bindings = self
7116 .session_bindings
7117 .lock()
7118 .unwrap_or_else(|poisoned| poisoned.into_inner());
7119 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7120 let record_id = binding.record_id.clone()?;
7121 Some((
7122 record_id,
7123 SessionAddress {
7124 workspace_id: binding.workspace_id.clone(),
7125 session: SessionKey { instance_id, generation },
7126 },
7127 ))
7128 }
7129
7130 fn mark_managed_recovery_required(
7131 &self,
7132 lease_id: &ManagedWorktreeLeaseId,
7133 failure_kind: ManagedWorktreeCleanupFailure,
7134 ) {
7135 let snapshot = {
7136 let mut registry = self.managed_worktrees.lock()
7137 .unwrap_or_else(|poisoned| poisoned.into_inner());
7138 let Some(lease) = registry.get_mut(lease_id) else { return };
7139 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7140 lease.cleanup_failure = Some(failure_kind);
7141 lease.updated_at_unix_ms = unix_time_ms();
7142 lease.snapshot()
7143 };
7144 let _ = self.persist_state();
7145 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7146 }
7147
7148 fn set_persistence_error(&self, error: Option<String>) {
7149 *self
7150 .persistence_error
7151 .write()
7152 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7153 .map(|message| session_registry::sanitized_persistence_summary(&message));
7154 }
7155
7156 fn persist_state(&self) -> io::Result<()> {
7157 let _transaction = self
7158 .state_transaction
7159 .lock()
7160 .unwrap_or_else(|poisoned| poisoned.into_inner());
7161 self.persist_state_locked()
7162 }
7163
7164 fn persist_state_locked(&self) -> io::Result<()> {
7165 let workspaces = self
7166 .workspaces
7167 .read()
7168 .unwrap_or_else(|poisoned| poisoned.into_inner())
7169 .clone();
7170 let records = self
7171 .session_records
7172 .lock()
7173 .unwrap_or_else(|poisoned| poisoned.into_inner())
7174 .records
7175 .values()
7176 .cloned()
7177 .collect::<Vec<_>>();
7178 let mut managed = self.managed_worktrees
7179 .lock()
7180 .unwrap_or_else(|poisoned| poisoned.into_inner());
7181 managed.reattach_record_holders(
7182 &managed_worktree_record_holders(&records),
7183 unix_time_ms(),
7184 );
7185 let materializations = self
7186 .materializations
7187 .lock()
7188 .unwrap_or_else(|poisoned| poisoned.into_inner())
7189 .values()
7190 .cloned()
7191 .collect::<Vec<_>>();
7192 let managed_spawn_replays = self.managed_spawn_replays
7193 .lock()
7194 .unwrap_or_else(|poisoned| poisoned.into_inner())
7195 .values()
7196 .cloned()
7197 .collect::<Vec<_>>();
7198 let result = session_registry::save_v10(
7199 self.state_path.as_deref(),
7200 &self.node_id,
7201 &workspaces,
7202 &records,
7203 &managed.records(),
7204 &managed.tombstones(),
7205 &materializations,
7206 &managed_spawn_replays,
7207 );
7208 match &result {
7209 Ok(warning) => self.set_persistence_error(warning.clone()),
7210 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7211 }
7212 result.map(|_| ())
7213 }
7214
7215 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7216 let _mutation_guard = self.mutation_gate.lock().await;
7217 self.begin_shutdown_locked().await
7218 }
7219
7220 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7221 if self.shutdown.swap(true, Ordering::AcqRel) {
7222 return Ok(());
7223 }
7224 SessionLifecycleHook::on_shutdown(self);
7225 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7226 let mut first_error = None;
7227 for session in &self.handle.snapshot().sessions {
7228 let remaining = deadline.saturating_duration_since(Instant::now());
7229 if remaining.is_zero() {
7230 first_error.get_or_insert_with(|| {
7231 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7232 });
7233 break;
7234 }
7235 if let Err(error) = self
7236 .dispatch_bounded(
7237 ControlCommand::Stop {
7238 instance_id: session.instance_id,
7239 force: true,
7240 },
7241 remaining,
7242 )
7243 .await
7244 {
7245 first_error.get_or_insert(error);
7246 }
7247 }
7248 self.shutdown_notify.notify_waiters();
7249 match first_error {
7250 Some(error) => Err(error),
7251 None => Ok(()),
7252 }
7253 }
7254
7255 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7256 let bindings = self
7257 .session_bindings
7258 .lock()
7259 .unwrap_or_else(|poisoned| poisoned.into_inner());
7260 if bindings.len() >= CONTROL_SESSIONS_MAX {
7261 Err(failure(
7262 NodeFailureCode::BackendBusy,
7263 "node session-binding capacity is full; remove an existing session first",
7264 ))
7265 } else {
7266 Ok(())
7267 }
7268 }
7269
7270 fn ensure_workspace_environment_profile_compatible(
7277 &self,
7278 workspace_id: &WorkspaceId,
7279 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7280 ) -> Result<(), NodeFailure> {
7281 let bindings = self
7282 .session_bindings
7283 .lock()
7284 .unwrap_or_else(|poisoned| poisoned.into_inner());
7285 for binding in bindings.values() {
7286 if &binding.workspace_id != workspace_id {
7287 continue;
7288 }
7289 if binding.environment_profile.as_ref() != environment_profile {
7290 return Err(failure(
7291 NodeFailureCode::WorkspaceBusy,
7292 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7293 ));
7294 }
7295 }
7296 Ok(())
7297 }
7298
7299 #[cfg(test)]
7300 fn bind_session(
7301 &self,
7302 instance_id: AgentInstanceId,
7303 workspace_id: WorkspaceId,
7304 generation: SessionGeneration,
7305 ) {
7306 self.bind_session_with_policy(
7307 &SessionAddress {
7308 workspace_id,
7309 session: SessionKey { instance_id, generation },
7310 },
7311 ProviderRuntimePolicy::raw_pty(),
7312 None,
7313 );
7314 }
7315
7316 #[cfg(test)]
7317 fn bind_session_with_policy(
7318 &self,
7319 address: &SessionAddress,
7320 runtime_policy: ProviderRuntimePolicy,
7321 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7322 ) {
7323 self.bind_session_with_materialization(
7324 address,
7325 runtime_policy,
7326 environment_profile,
7327 None,
7328 None,
7329 None,
7330 );
7331 }
7332
7333 fn bind_session_with_materialization(
7334 &self,
7335 address: &SessionAddress,
7336 runtime_policy: ProviderRuntimePolicy,
7337 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7338 bundle: Option<ResolvedBundleReceipt>,
7339 context: Option<ResolvedContextPackReceipt>,
7340 materialization_id: Option<MaterializationId>,
7341 ) {
7342 let mut bindings = self
7343 .session_bindings
7344 .lock()
7345 .unwrap_or_else(|poisoned| poisoned.into_inner());
7346 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7347 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7348 bindings.insert(address.session.instance_id, SessionBinding {
7349 workspace_id: address.workspace_id.clone(),
7350 generation: address.session.generation,
7351 runtime_policy,
7352 pending_resume: None,
7353 record_id: None,
7354 managed_worktree_lease_id: None,
7355 environment_profile,
7356 bundle,
7357 context,
7358 materialization_id,
7359 });
7360 }
7361
7362 #[cfg(test)]
7363 fn bind_managed_session(
7364 &self,
7365 address: &SessionAddress,
7366 record_id: SessionRecordId,
7367 runtime_policy: ProviderRuntimePolicy,
7368 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7369 ) {
7370 self.bind_managed_session_with_materialization(
7371 address,
7372 record_id,
7373 runtime_policy,
7374 environment_profile,
7375 None,
7376 None,
7377 None,
7378 );
7379 }
7380
7381 fn bind_managed_session_with_materialization(
7382 &self,
7383 address: &SessionAddress,
7384 record_id: SessionRecordId,
7385 runtime_policy: ProviderRuntimePolicy,
7386 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7387 bundle: Option<ResolvedBundleReceipt>,
7388 context: Option<ResolvedContextPackReceipt>,
7389 materialization_id: Option<MaterializationId>,
7390 ) {
7391 let mut bindings = self
7392 .session_bindings
7393 .lock()
7394 .unwrap_or_else(|poisoned| poisoned.into_inner());
7395 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7396 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7397 bindings.insert(
7398 address.session.instance_id,
7399 SessionBinding {
7400 workspace_id: address.workspace_id.clone(),
7401 generation: address.session.generation,
7402 runtime_policy,
7403 pending_resume: None,
7404 record_id: Some(record_id),
7405 managed_worktree_lease_id: None,
7406 environment_profile,
7407 bundle,
7408 context,
7409 materialization_id,
7410 },
7411 );
7412 }
7413
7414 #[cfg(test)]
7415 fn bind_spawn_session(
7416 &self,
7417 address: &SessionAddress,
7418 provider: AgentId,
7419 mode: SessionMode,
7420 runtime_policy: ProviderRuntimePolicy,
7421 record_policy: SpawnRecordPolicy,
7422 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7423 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7424 self.bind_spawn_session_with_materialization(
7425 address,
7426 provider,
7427 mode,
7428 runtime_policy,
7429 record_policy,
7430 environment_profile,
7431 None,
7432 None,
7433 None,
7434 )
7435 }
7436
7437 fn bind_spawn_session_with_materialization(
7438 &self,
7439 address: &SessionAddress,
7440 provider: AgentId,
7441 mode: SessionMode,
7442 runtime_policy: ProviderRuntimePolicy,
7443 record_policy: SpawnRecordPolicy,
7444 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7445 bundle: Option<ResolvedBundleReceipt>,
7446 context: Option<ResolvedContextPackReceipt>,
7447 materialization_id: Option<MaterializationId>,
7448 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7449 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7450 && !runtime_policy.provider_session_identity
7451 {
7452 self.bind_session_with_materialization(
7453 address,
7454 runtime_policy,
7455 environment_profile,
7456 bundle,
7457 context,
7458 materialization_id,
7459 );
7460 return Ok(None);
7461 }
7462 let record_owns_lifecycle = runtime_policy.provider_session_identity;
7463 let record = self.new_record(
7464 address,
7465 provider,
7466 mode,
7467 active_record_state(runtime_policy.provider_session_identity, false),
7468 None,
7469 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7470 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7471 record_owns_lifecycle.then(|| context.clone()).flatten(),
7472 )?;
7473 let record_id = record.record_id.clone();
7474 let transaction = self
7475 .state_transaction
7476 .lock()
7477 .unwrap_or_else(|poisoned| poisoned.into_inner());
7478 self.insert_record(record.clone())?;
7479 let previous_materialization = if record_owns_lifecycle {
7480 if let Some(materialization_id) = materialization_id.as_ref() {
7481 let mut materializations = self
7482 .materializations
7483 .lock()
7484 .unwrap_or_else(|poisoned| poisoned.into_inner());
7485 let Some(ownership) = materializations.get_mut(materialization_id) else {
7486 drop(materializations);
7487 self.remove_record_memory(&record_id);
7488 return Err(failure(
7489 NodeFailureCode::BackendOperationFailed,
7490 "session environment ownership disappeared before binding",
7491 ));
7492 };
7493 let previous = ownership.clone();
7494 if ownership
7495 .transfer_to_record(record_id.clone(), unix_time_ms())
7496 .is_err()
7497 {
7498 drop(materializations);
7499 self.remove_record_memory(&record_id);
7500 return Err(failure(
7501 NodeFailureCode::BackendOperationFailed,
7502 "session environment ownership transfer failed",
7503 ));
7504 }
7505 Some(previous)
7506 } else {
7507 None
7508 }
7509 } else {
7510 None
7511 };
7512 self.bind_managed_session_with_materialization(
7513 address,
7514 record_id.clone(),
7515 runtime_policy,
7516 environment_profile,
7517 bundle,
7518 context,
7519 materialization_id.clone(),
7520 );
7521 if let Err(error) = self.persist_state_locked() {
7522 self.remove_binding(address);
7523 self.remove_record_memory(&record_id);
7524 if let (Some(materialization_id), Some(previous)) =
7525 (materialization_id.as_ref(), previous_materialization)
7526 {
7527 self.materializations
7528 .lock()
7529 .unwrap_or_else(|poisoned| poisoned.into_inner())
7530 .insert(materialization_id.clone(), previous);
7531 }
7532 return Err(persistence_failure(error));
7533 }
7534 drop(transaction);
7535 self.publish_record(record);
7536 Ok(Some(record_id))
7537 }
7538
7539 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7540 for _ in 0..8 {
7541 let bytes = random_nonce().map_err(|error| {
7542 failure(
7543 NodeFailureCode::BackendOperationFailed,
7544 &format!("session record identity generation failed: {error}"),
7545 )
7546 })?;
7547 let mut value = String::with_capacity(27);
7548 value.push_str("sr-");
7549 for byte in &bytes[..12] {
7550 use std::fmt::Write as _;
7551 let _ = write!(&mut value, "{byte:02x}");
7552 }
7553 let record_id = SessionRecordId::new(value).map_err(|error| {
7554 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7555 })?;
7556 let records = self
7557 .session_records
7558 .lock()
7559 .unwrap_or_else(|poisoned| poisoned.into_inner());
7560 if !records.records.contains_key(&record_id) {
7561 return Ok(record_id);
7562 }
7563 }
7564 Err(failure(
7565 NodeFailureCode::BackendBusy,
7566 "could not allocate a unique session record ID",
7567 ))
7568 }
7569
7570 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7571 for _ in 0..8 {
7572 let bytes = random_nonce().map_err(|error| {
7573 failure(
7574 NodeFailureCode::BackendOperationFailed,
7575 &format!("task identity generation failed: {error}"),
7576 )
7577 })?;
7578 let mut nonce = [0_u8; 12];
7579 nonce.copy_from_slice(&bytes[..12]);
7580 let task_id = TaskId::from_nonce(nonce);
7581 let records = self
7582 .session_records
7583 .lock()
7584 .unwrap_or_else(|poisoned| poisoned.into_inner());
7585 if !records.records.values().any(|record| {
7586 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7587 == Some(&task_id)
7588 }) {
7589 return Ok(task_id);
7590 }
7591 }
7592 Err(failure(
7593 NodeFailureCode::BackendBusy,
7594 "could not allocate a unique task ID",
7595 ))
7596 }
7597
7598 fn default_record_name(&self, provider: &AgentId) -> String {
7599 let records = self
7600 .session_records
7601 .lock()
7602 .unwrap_or_else(|poisoned| poisoned.into_inner());
7603 let ordinal = records
7604 .records
7605 .values()
7606 .filter(|record| &record.provider == provider)
7607 .count()
7608 .saturating_add(1);
7609 format!("{} #{ordinal}", provider.as_str())
7610 }
7611
7612 fn new_record(
7613 &self,
7614 address: &SessionAddress,
7615 provider: AgentId,
7616 mode: SessionMode,
7617 state: ManagedSessionState,
7618 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7619 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7620 bundle: Option<ResolvedBundleReceipt>,
7621 context: Option<ResolvedContextPackReceipt>,
7622 ) -> Result<ManagedSessionRecord, NodeFailure> {
7623 let canonical_root = self.workspace_root(&address.workspace_id)?;
7624 let now = unix_time_ms();
7625 Ok(ManagedSessionRecord {
7626 record_id: self.allocate_record_id()?,
7627 display_name: self.default_record_name(&provider),
7628 provider,
7629 mode,
7630 state,
7631 workspace_id: address.workspace_id.clone(),
7632 canonical_root: opaque_windows_path(canonical_root),
7633 provider_session,
7634 active_session: Some(address.clone()),
7635 environment_profile,
7636 bundle,
7637 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7638 context,
7639 exported_context: None,
7640 task_binding: None,
7641 created_at_unix_ms: now,
7642 updated_at_unix_ms: now,
7643 last_error: None,
7644 })
7645 }
7646
7647 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7648 let mut records = self
7649 .session_records
7650 .lock()
7651 .unwrap_or_else(|poisoned| poisoned.into_inner());
7652 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7653 return Err(failure(
7654 NodeFailureCode::BackendBusy,
7655 "managed session record capacity is full; forget an inactive record first",
7656 ));
7657 }
7658 if let Some(identity) = record.provider_session.as_ref() {
7659 if records.records.values().any(|candidate| {
7660 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7661 session_registry::same_provider_session(
7662 &candidate.provider,
7663 candidate_identity,
7664 &record.provider,
7665 identity,
7666 )
7667 })
7668 }) {
7669 return Err(failure(
7670 NodeFailureCode::SessionRecordConflict,
7671 "provider session is already represented by another managed record",
7672 ));
7673 }
7674 }
7675 match records.records.entry(record.record_id.clone()) {
7676 std::collections::btree_map::Entry::Vacant(entry) => {
7677 entry.insert(record);
7678 }
7679 std::collections::btree_map::Entry::Occupied(_) => {
7680 return Err(failure(
7681 NodeFailureCode::SessionRecordConflict,
7682 "session record ID collision",
7683 ));
7684 }
7685 }
7686 Ok(())
7687 }
7688
7689 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7690 self.session_records
7691 .lock()
7692 .unwrap_or_else(|poisoned| poisoned.into_inner())
7693 .records
7694 .get(record_id)
7695 .cloned()
7696 .ok_or_else(|| {
7697 failure(
7698 NodeFailureCode::UnknownSessionRecord,
7699 "managed session record does not exist",
7700 )
7701 })
7702 }
7703
7704 fn revalidate_session_record_preview(
7705 &self,
7706 expected: &ManagedSessionRecord,
7707 identity: &ProviderSessionIdentity,
7708 ) -> Result<(), NodeFailure> {
7709 let current = self.record(&expected.record_id)?;
7710 let current_root = self.workspace_root(¤t.workspace_id)?;
7711 if current.provider != expected.provider
7712 || current.workspace_id != expected.workspace_id
7713 || current.canonical_root != expected.canonical_root
7714 || current.provider_session.as_ref() != Some(identity)
7715 || !platform::roots_equal(
7716 ¤t_root,
7717 windows_path_text(¤t.canonical_root),
7718 )
7719 {
7720 return Err(failure(
7721 NodeFailureCode::SessionRecordConflict,
7722 "managed session changed while its preview was loading",
7723 ));
7724 }
7725 Ok(())
7726 }
7727
7728 fn revalidate_session_record_context_export(
7729 &self,
7730 expected: &ManagedSessionRecord,
7731 identity: Option<&ProviderSessionIdentity>,
7732 session: &SessionAddress,
7733 allow_clean_detachment: bool,
7734 ) -> Result<(), NodeFailure> {
7735 let current = self.record(&expected.record_id)?;
7736 let current_root = self.workspace_root(¤t.workspace_id)?;
7737 let snapshot = self.internal_session_snapshot(session)?;
7738 let binding = self
7739 .session_bindings
7740 .lock()
7741 .unwrap_or_else(|poisoned| poisoned.into_inner())
7742 .get(&session.session.instance_id)
7743 .cloned()
7744 .ok_or_else(|| {
7745 failure(
7746 NodeFailureCode::SessionRecordConflict,
7747 "managed session runtime binding disappeared during context export",
7748 )
7749 })?;
7750 if !session_record_context_export_binding_is_exact(
7751 expected,
7752 ¤t,
7753 identity,
7754 session,
7755 &binding,
7756 &snapshot.agent_id,
7757 ¤t_root,
7758 allow_clean_detachment,
7759 ) || !session_record_context_export_source_is_usable(
7760 ¤t,
7761 &snapshot.status,
7762 allow_clean_detachment,
7763 ) {
7764 return Err(failure(
7765 NodeFailureCode::SessionRecordConflict,
7766 "managed session binding changed during context export",
7767 ));
7768 }
7769 Ok(())
7770 }
7771
7772 fn revalidate_loaded_history(
7773 &self,
7774 session: &SessionAddress,
7775 candidate_id: &str,
7776 loaded: &HistorySessionRecord,
7777 ) -> Result<(), NodeFailure> {
7778 let snapshot = self.internal_session_snapshot(session)?;
7779 if snapshot.history.pending.is_some()
7780 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7781 || snapshot.history.loaded.as_ref() != Some(loaded)
7782 {
7783 return Err(failure(
7784 NodeFailureCode::SessionRecordConflict,
7785 "managed session history changed during context export",
7786 ));
7787 }
7788 Ok(())
7789 }
7790
7791 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7792 self.session_records
7793 .lock()
7794 .unwrap_or_else(|poisoned| poisoned.into_inner())
7795 .records
7796 .remove(record_id)
7797 }
7798
7799 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7800 if self.destroy_record_materialization(record_id)? {
7801 self.publish(NodeEvent::SessionRecordRemoved {
7802 record_id: record_id.clone(),
7803 });
7804 return Ok(());
7805 }
7806 let transaction = self
7807 .state_transaction
7808 .lock()
7809 .unwrap_or_else(|poisoned| poisoned.into_inner());
7810 let Some(removed) = self.remove_record_memory(record_id) else {
7811 return Ok(());
7812 };
7813 if let Err(error) = self.persist_state_locked() {
7814 let _ = self.insert_record(removed);
7815 return Err(persistence_failure(error));
7816 }
7817 drop(transaction);
7818 self.publish(NodeEvent::SessionRecordRemoved {
7819 record_id: record_id.clone(),
7820 });
7821 Ok(())
7822 }
7823
7824 fn publish_record(&self, record: ManagedSessionRecord) {
7825 self.publish(NodeEvent::SessionRecordUpserted { record });
7826 }
7827
7828 fn mark_record_error(
7829 &self,
7830 record_id: &SessionRecordId,
7831 message: &str,
7832 ) -> Result<(), NodeFailure> {
7833 let transaction = self
7834 .state_transaction
7835 .lock()
7836 .unwrap_or_else(|poisoned| poisoned.into_inner());
7837 let previous = {
7838 let mut records = self
7839 .session_records
7840 .lock()
7841 .unwrap_or_else(|poisoned| poisoned.into_inner());
7842 let Some(record) = records.records.get_mut(record_id) else {
7843 return Ok(());
7844 };
7845 let previous = record.clone();
7846 record.state = ManagedSessionState::Unavailable;
7847 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7848 record.updated_at_unix_ms = unix_time_ms();
7849 previous
7850 };
7851 if let Err(error) = self.persist_state_locked() {
7852 self.session_records
7853 .lock()
7854 .unwrap_or_else(|poisoned| poisoned.into_inner())
7855 .records
7856 .insert(record_id.clone(), previous);
7857 return Err(persistence_failure(error));
7858 }
7859 let record = self.record(record_id)?;
7860 drop(transaction);
7861 self.publish_record(record);
7862 Ok(())
7863 }
7864
7865 fn rename_session_record(
7866 &self,
7867 record_id: &SessionRecordId,
7868 display_name: String,
7869 ) -> Result<ManagedSessionRecord, NodeFailure> {
7870 validate_display_name(&display_name)
7871 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7872 let _transaction = self
7873 .state_transaction
7874 .lock()
7875 .unwrap_or_else(|poisoned| poisoned.into_inner());
7876 let previous = {
7877 let mut records = self
7878 .session_records
7879 .lock()
7880 .unwrap_or_else(|poisoned| poisoned.into_inner());
7881 let record = records.records.get_mut(record_id).ok_or_else(|| {
7882 failure(
7883 NodeFailureCode::UnknownSessionRecord,
7884 "managed session record does not exist",
7885 )
7886 })?;
7887 let previous = record.clone();
7888 record.display_name = display_name;
7889 record.updated_at_unix_ms = unix_time_ms();
7890 previous
7891 };
7892 if let Err(error) = self.persist_state_locked() {
7893 self.session_records
7894 .lock()
7895 .unwrap_or_else(|poisoned| poisoned.into_inner())
7896 .records
7897 .insert(record_id.clone(), previous);
7898 return Err(persistence_failure(error));
7899 }
7900 let updated = self.record(record_id)?;
7901 self.publish_record(updated.clone());
7902 Ok(updated)
7903 }
7904
7905 fn set_session_task(
7906 &self,
7907 record_id: &SessionRecordId,
7908 expected_revision: u64,
7909 target: SessionTaskTargetV1,
7910 ) -> Result<ManagedSessionRecord, NodeFailure> {
7911 let transaction = self
7912 .state_transaction
7913 .lock()
7914 .unwrap_or_else(|poisoned| poisoned.into_inner());
7915 let current = self.record(record_id)?;
7916 let current_task_id = current
7917 .task_binding
7918 .as_ref()
7919 .and_then(|binding| binding.task_id.as_ref());
7920 let current_revision = current
7921 .task_binding
7922 .as_ref()
7923 .map_or(0, |binding| binding.revision);
7924 if expected_revision != current_revision {
7925 return Err(failure(
7926 NodeFailureCode::SessionRecordConflict,
7927 "managed session task binding revision changed",
7928 ));
7929 }
7930 let exact = match &target {
7931 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
7932 SessionTaskTargetV1::Clear => current_task_id.is_none(),
7933 SessionTaskTargetV1::New => false,
7934 };
7935 if exact {
7936 return Ok(current);
7937 }
7938 let revision = current_revision.checked_add(1).ok_or_else(|| {
7939 failure(
7940 NodeFailureCode::SessionRecordConflict,
7941 "managed session task binding revision is exhausted",
7942 )
7943 })?;
7944 let task_id = match target {
7945 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
7946 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
7947 SessionTaskTargetV1::Clear => None,
7948 };
7949 let changed_at_unix_ms = unix_time_ms()
7950 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
7951 failure(
7952 NodeFailureCode::SessionRecordConflict,
7953 "managed session record timestamp is exhausted",
7954 )
7955 })?);
7956 let updated = {
7957 let mut records = self
7958 .session_records
7959 .lock()
7960 .unwrap_or_else(|poisoned| poisoned.into_inner());
7961 let record = records.records.get_mut(record_id).ok_or_else(|| {
7962 failure(
7963 NodeFailureCode::UnknownSessionRecord,
7964 "managed session record does not exist",
7965 )
7966 })?;
7967 record.task_binding = Some(SessionTaskBindingV1 {
7968 revision,
7969 task_id,
7970 changed_at_unix_ms,
7971 });
7972 record.updated_at_unix_ms = changed_at_unix_ms;
7973 record.clone()
7974 };
7975 if let Err(error) = self.persist_state_locked() {
7976 self.session_records
7977 .lock()
7978 .unwrap_or_else(|poisoned| poisoned.into_inner())
7979 .records
7980 .insert(record_id.clone(), current);
7981 return Err(persistence_failure(error));
7982 }
7983 drop(transaction);
7984 self.publish_record(updated.clone());
7985 Ok(updated)
7986 }
7987
7988 fn index_provider_session(
7989 &self,
7990 workspace_id: WorkspaceId,
7991 provider: AgentId,
7992 identity: ProviderSessionIdentity,
7993 display_name: String,
7994 ) -> Result<ManagedSessionRecord, NodeFailure> {
7995 self.index_provider_session_with_policy(
7996 workspace_id,
7997 provider,
7998 identity,
7999 display_name,
8000 false,
8001 )
8002 }
8003
8004 fn index_provider_session_with_policy(
8005 &self,
8006 workspace_id: WorkspaceId,
8007 provider: AgentId,
8008 identity: ProviderSessionIdentity,
8009 display_name: String,
8010 allow_validated_transcript_path: bool,
8011 ) -> Result<ManagedSessionRecord, NodeFailure> {
8012 validate_display_name(&display_name)
8013 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8014 identity
8015 .validate()
8016 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8017 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8018 return Err(failure(
8019 NodeFailureCode::InvalidRequest,
8020 "provider session reference index v1 does not accept transcript paths",
8021 ));
8022 }
8023 let canonical_root = self.workspace_root(&workspace_id)?;
8024 if !self.enabled_providers.contains(&provider) {
8025 return Err(failure(
8026 NodeFailureCode::InvalidRequest,
8027 "provider is not enabled on this node",
8028 ));
8029 }
8030
8031 let transaction = self
8032 .state_transaction
8033 .lock()
8034 .unwrap_or_else(|poisoned| poisoned.into_inner());
8035 {
8036 let records = self
8037 .session_records
8038 .lock()
8039 .unwrap_or_else(|poisoned| poisoned.into_inner());
8040 if let Some(existing) = records.records.values().find(|candidate| {
8041 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8042 candidate_identity.key == identity.key && candidate_identity.id == identity.id
8043 })
8044 }) {
8045 if existing.provider == provider && existing.workspace_id == workspace_id {
8046 return Ok(existing.clone());
8047 }
8048 return Err(failure(
8049 NodeFailureCode::SessionRecordConflict,
8050 "provider session reference conflicts with another provider or workspace",
8051 ));
8052 }
8053 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8054 return Err(failure(
8055 NodeFailureCode::BackendBusy,
8056 "managed session record capacity is full; forget an inactive record first",
8057 ));
8058 }
8059 }
8060
8061 let now = unix_time_ms();
8062 let record = ManagedSessionRecord {
8063 record_id: self.allocate_record_id()?,
8064 display_name,
8065 provider,
8066 mode: SessionMode::Pty,
8067 state: ManagedSessionState::Dormant,
8068 workspace_id,
8069 canonical_root: opaque_windows_path(canonical_root),
8070 provider_session: Some(identity),
8071 active_session: None,
8072 environment_profile: None,
8073 bundle: None,
8074 context_id: None,
8075 context: None,
8076 exported_context: None,
8077 task_binding: None,
8078 created_at_unix_ms: now,
8079 updated_at_unix_ms: now,
8080 last_error: None,
8081 };
8082 self.insert_record(record.clone())?;
8083 if let Err(error) = self.persist_state_locked() {
8084 self.remove_record_memory(&record.record_id);
8085 return Err(persistence_failure(error));
8086 }
8087 drop(transaction);
8088 self.publish_record(record.clone());
8089 Ok(record)
8090 }
8091
8092 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8093 self.session_bindings
8094 .lock()
8095 .unwrap_or_else(|poisoned| poisoned.into_inner())
8096 .iter()
8097 .find_map(|(instance_id, binding)| {
8098 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8099 workspace_id: binding.workspace_id.clone(),
8100 session: SessionKey {
8101 instance_id: *instance_id,
8102 generation: binding.generation,
8103 },
8104 })
8105 })
8106 }
8107
8108 async fn forget_session_record(
8109 &self,
8110 record_id: &SessionRecordId,
8111 ) -> Result<(), NodeFailure> {
8112 let record = self.record(record_id)?;
8113 if record.active_session.is_some()
8114 || matches!(
8115 record.state,
8116 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8117 )
8118 {
8119 return Err(failure(
8120 NodeFailureCode::SessionRecordBusy,
8121 "stop the managed session before forgetting it",
8122 ));
8123 }
8124 if let Some(address) = self.bound_address_for_record(record_id) {
8125 self.remove_session(&address).await?;
8126 }
8127 self.remove_managed_session_record(record_id, &record.workspace_id).await
8128 }
8129
8130 async fn remove_managed_session_record(
8145 &self,
8146 record_id: &SessionRecordId,
8147 workspace_id: &WorkspaceId,
8148 ) -> Result<(), NodeFailure> {
8149 let managed_lease_id = self.managed_worktrees.lock()
8150 .unwrap_or_else(|poisoned| poisoned.into_inner())
8151 .lease_for_workspace(workspace_id);
8152 if !self.destroy_record_materialization(record_id)? {
8153 let transaction = self
8154 .state_transaction
8155 .lock()
8156 .unwrap_or_else(|poisoned| poisoned.into_inner());
8157 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8158 failure(
8159 NodeFailureCode::UnknownSessionRecord,
8160 "managed session record does not exist",
8161 )
8162 })?;
8163 if let Err(error) = self.persist_state_locked() {
8164 let _ = self.insert_record(removed);
8165 return Err(persistence_failure(error));
8166 }
8167 drop(transaction);
8168 }
8169 self.publish(NodeEvent::SessionRecordRemoved {
8170 record_id: record_id.clone(),
8171 });
8172 if let Some(lease_id) = managed_lease_id {
8173 if let Some(snapshot) = self.managed_worktrees.lock()
8174 .unwrap_or_else(|poisoned| poisoned.into_inner())
8175 .get(&lease_id)
8176 .map(ManagedWorktreeLeaseRecord::snapshot)
8177 {
8178 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8179 }
8180 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8181 }
8182 Ok(())
8183 }
8184
8185 async fn retire_unavailable_session_records(&self) {
8198 let policy = self.session_record_retention;
8199 let age_enabled = policy.age_ms > 0;
8200 let keep_enabled = policy.keep_per_workspace > 0;
8201 if !age_enabled && !keep_enabled {
8202 return;
8203 }
8204 let now = unix_time_ms();
8205 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8206 self.session_records
8207 .lock()
8208 .unwrap_or_else(|poisoned| poisoned.into_inner())
8209 .records
8210 .values()
8211 .filter(|record| record.state == ManagedSessionState::Unavailable)
8212 .map(|record| {
8213 (
8214 record.record_id.clone(),
8215 record.workspace_id.clone(),
8216 record.provider.clone(),
8217 record.updated_at_unix_ms,
8218 )
8219 })
8220 .collect()
8221 };
8222 if candidates.is_empty() {
8223 return;
8224 }
8225 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8226 BTreeMap::new();
8227 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8228 by_workspace
8229 .entry(workspace_id)
8230 .or_default()
8231 .push((record_id, provider, updated_at_unix_ms));
8232 }
8233 let reason = if age_enabled && keep_enabled {
8234 "age+keep-n"
8235 } else if age_enabled {
8236 "age"
8237 } else {
8238 "keep-n"
8239 };
8240 let mut retired: u64 = 0;
8241 for (workspace_id, mut records) in by_workspace {
8242 records.sort_by(|left, right| right.2.cmp(&left.2));
8245 for (index, (record_id, provider, updated_at_unix_ms)) in
8246 records.into_iter().enumerate()
8247 {
8248 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8249 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8250 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8251 let should_retire = if age_enabled && keep_enabled {
8252 age_hit && keep_hit
8253 } else {
8254 age_hit || keep_hit
8255 };
8256 if !should_retire {
8257 continue;
8258 }
8259 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8260 Ok(()) => {
8261 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8262 retired += 1;
8263 tracing::info!(
8264 node_id = %self.node_id,
8265 record_id = %record_id,
8266 workspace_id = %workspace_id,
8267 provider = %provider,
8268 age_ms = record_age_ms,
8269 reason,
8270 "managed session record retired"
8271 );
8272 }
8273 Err(error) => tracing::warn!(
8274 node_id = %self.node_id,
8275 record_id = %record_id,
8276 workspace_id = %workspace_id,
8277 code = ?error.code,
8278 cause = %error.message,
8279 "session record retention sweep failed to retire a record"
8280 ),
8281 }
8282 }
8283 }
8284 if retired > 0 {
8285 let remaining_unavailable = self
8286 .session_records
8287 .lock()
8288 .unwrap_or_else(|poisoned| poisoned.into_inner())
8289 .records
8290 .values()
8291 .filter(|record| record.state == ManagedSessionState::Unavailable)
8292 .count();
8293 tracing::info!(
8294 node_id = %self.node_id,
8295 retired,
8296 remaining_unavailable,
8297 "session record retention sweep completed"
8298 );
8299 }
8300 }
8301
8302 async fn resume_session_record(
8303 &self,
8304 record_id: &SessionRecordId,
8305 terminal_size: gate4agent_types::TerminalSize,
8306 initial_prompt: Option<String>,
8307 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8308 let record = self.record(record_id)?;
8309 if record.active_session.is_some()
8310 || matches!(
8311 record.state,
8312 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8313 )
8314 {
8315 return Err(failure(
8316 NodeFailureCode::SessionRecordBusy,
8317 "managed session already has a live runtime binding",
8318 ));
8319 }
8320 let identity = record.provider_session.clone().ok_or_else(|| {
8321 failure(
8322 NodeFailureCode::SessionRecordNotResumable,
8323 "managed session has no verified provider session identity",
8324 )
8325 })?;
8326 let working_directory = self.workspace_root(&record.workspace_id)?;
8327 if !platform::roots_equal(
8328 &working_directory,
8329 windows_path_text(&record.canonical_root),
8330 ) {
8331 return Err(failure(
8332 NodeFailureCode::SessionRecordConflict,
8333 "managed session workspace root changed; refusing to resume in another directory",
8334 ));
8335 }
8336 {
8337 let records = self
8338 .session_records
8339 .lock()
8340 .unwrap_or_else(|poisoned| poisoned.into_inner());
8341 if records.records.values().any(|candidate| {
8342 candidate.record_id != *record_id
8343 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8344 session_registry::same_provider_session(
8345 &candidate.provider,
8346 candidate_identity,
8347 &record.provider,
8348 &identity,
8349 )
8350 })
8351 && (candidate.active_session.is_some()
8352 || matches!(
8353 candidate.state,
8354 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8355 ))
8356 }) {
8357 return Err(failure(
8358 NodeFailureCode::SessionRecordConflict,
8359 "provider session is already active through another managed record",
8360 ));
8361 }
8362 }
8363 let runtime_requirement = if initial_prompt.is_some() {
8364 ProviderRuntimeRequirement::ResumeWithPrompt
8365 } else {
8366 ProviderRuntimeRequirement::Resume
8367 };
8368 let runtime_policy = self.admit_provider_runtime(
8369 &record.provider,
8370 runtime_requirement,
8371 )
8372 .await?;
8373 let request = ResumeLaunchRequest {
8374 working_directory,
8375 terminal_size,
8376 initial_prompt,
8377 };
8378 request
8379 .validate()
8380 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8381 self.ensure_binding_capacity()?;
8382 let resolved_materialization = match self.resolve_record_materialization(
8383 record_id,
8384 &record.provider,
8385 record.mode,
8386 record.environment_profile.as_ref(),
8387 record.bundle.as_ref(),
8388 record.context.as_ref(),
8389 ) {
8390 Ok(materialization) => materialization,
8391 Err(error) => {
8392 let label = match error.code {
8393 NodeFailureCode::UnknownBundle => "bundle-unavailable",
8394 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8395 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8396 _ => "environment-profile-unavailable",
8397 };
8398 self.mark_record_error(record_id, label)?;
8399 return Err(error);
8400 }
8401 };
8402 let (materialization_id, environment_overlay) = match resolved_materialization {
8403 Some((id, overlay)) => (Some(id), overlay),
8404 None => (None, None),
8405 };
8406 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8407 let mut environment_selection = match SpawnPreparer::select_environment(self,
8408 instance_id,
8409 &record.provider,
8410 record.mode,
8411 record.environment_profile.as_ref(),
8412 ) {
8413 Ok(selection) => selection,
8414 Err(error) => {
8415 self.mark_record_error(record_id, "environment-profile-unavailable")?;
8416 return Err(error);
8417 }
8418 };
8419 let mut instance_overlay = environment_overlay
8420 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8421 .transpose()?
8422 .flatten();
8423 if let Some(stale_address) = self.bound_address_for_record(record_id) {
8424 self.remove_session(&stale_address).await?;
8425 }
8426 let address = SessionAddress {
8427 workspace_id: record.workspace_id.clone(),
8428 session: SessionKey {
8429 instance_id,
8430 generation: SessionGeneration(1),
8431 },
8432 };
8433 let transaction = self
8434 .state_transaction
8435 .lock()
8436 .unwrap_or_else(|poisoned| poisoned.into_inner());
8437 let current = self.record(record_id)?;
8438 if current.active_session.is_some()
8439 || matches!(
8440 current.state,
8441 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8442 )
8443 {
8444 return Err(failure(
8445 NodeFailureCode::SessionRecordBusy,
8446 "managed session became active while resume was being prepared",
8447 ));
8448 }
8449 self.bind_managed_session_with_materialization(
8450 &address,
8451 record_id.clone(),
8452 runtime_policy,
8453 record.environment_profile.clone(),
8454 record.bundle.clone(),
8455 record.context.clone(),
8456 materialization_id,
8457 );
8458 if let Some(selection) = environment_selection.take() {
8459 selection.retain();
8460 }
8461 if let Some(overlay) = instance_overlay.take() {
8462 overlay.retain();
8463 }
8464 drop(transaction);
8465
8466 let transport = match record.mode {
8467 SessionMode::Pty => TransportKind::Pty,
8468 SessionMode::Inline => TransportKind::Pipe,
8469 SessionMode::Acp => TransportKind::Acp,
8470 };
8471 let agent_id = record.provider.clone();
8472 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8473 if let Err(error) = self
8474 .dispatch_bounded(
8475 ControlCommand::Register {
8476 instance_id,
8477 agent_id,
8478 transport,
8479 },
8480 dispatch_timeout,
8481 )
8482 .await
8483 {
8484 if let Some(binding) = self.remove_binding(&address) {
8485 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8486 }
8487 return Err(error);
8488 }
8489 let command = self.prepare_command(ControlCommand::Resume {
8490 instance_id,
8491 target: ResumeTarget::ProviderSession { identity },
8492 runtime_policy,
8493 request,
8494 });
8495 let started_after = self.current_sequence();
8496 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8497 self.rollback_spawn(&address, dispatch_timeout).await?;
8498 return Err(error);
8499 }
8500 if let Err(error) = self.dispatch_envelope(command) {
8501 self.clear_armed_resume(&address);
8502 self.rollback_spawn(&address, dispatch_timeout).await?;
8503 return Err(error);
8504 }
8505 match self
8506 .wait_for_record_resume(&address, started_after)
8507 .await
8508 {
8509 Ok(settled_address) => {
8510 let managed_lease_id = {
8511 self.managed_worktrees.lock()
8512 .unwrap_or_else(|poisoned| poisoned.into_inner())
8513 .lease_for_workspace(&record.workspace_id)
8514 };
8515 if let Some(lease_id) = managed_lease_id {
8516 {
8517 let mut bindings = self.session_bindings.lock()
8518 .unwrap_or_else(|poisoned| poisoned.into_inner());
8519 let binding = bindings.get_mut(&settled_address.session.instance_id)
8520 .ok_or_else(|| failure(
8521 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8522 "resumed managed session lost its exact binding",
8523 ))?;
8524 if binding.generation != settled_address.session.generation {
8525 return Err(failure(
8526 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8527 "resumed managed session generation diverged",
8528 ));
8529 }
8530 binding.managed_worktree_lease_id = Some(lease_id.clone());
8531 }
8532 let snapshot = self.managed_worktrees.lock()
8533 .unwrap_or_else(|poisoned| poisoned.into_inner())
8534 .bind_session(
8535 &lease_id,
8536 ManagedWorktreeSessionHolder {
8537 incarnation_id: self.incarnation_id,
8538 instance_id: settled_address.session.instance_id,
8539 generation: settled_address.session.generation,
8540 },
8541 Some(record_id.clone()),
8542 unix_time_ms(),
8543 ).map_err(|message| failure(
8544 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8545 &message,
8546 ))?;
8547 if let Err(error) = self.persist_state() {
8548 let _ = self.remove_session(&settled_address).await;
8549 return Err(persistence_failure(error));
8550 }
8551 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8552 }
8553 Ok((self.record(record_id)?, settled_address))
8554 }
8555 Err(error) => {
8556 if matches!(
8557 error.code,
8558 NodeFailureCode::SessionRecordNotResumable
8559 | NodeFailureCode::BackendOperationFailed
8560 ) {
8561 if let Some(bound) = self.bound_address_for_record(record_id) {
8562 let _ = self.remove_session(&bound).await;
8563 }
8564 }
8565 Err(error)
8566 }
8567 }
8568 }
8569
8570 async fn wait_for_record_resume(
8571 &self,
8572 initial_address: &SessionAddress,
8573 after_sequence: u64,
8574 ) -> Result<SessionAddress, NodeFailure> {
8575 let deadline = Instant::now()
8576 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8577 let mut scan_after = after_sequence;
8578 loop {
8579 let events = self
8580 .history
8581 .lock()
8582 .unwrap_or_else(|poisoned| poisoned.into_inner())
8583 .events
8584 .iter()
8585 .filter(|event| event.sequence > scan_after)
8586 .cloned()
8587 .collect::<Vec<_>>();
8588 for envelope in events {
8589 scan_after = scan_after.max(envelope.sequence);
8590 let NodeEvent::Control { address, event } = envelope.event else {
8591 continue;
8592 };
8593 if address.session.instance_id != initial_address.session.instance_id {
8594 continue;
8595 }
8596 match event.event {
8597 ControlEventKind::Resumed { .. } => return Ok(address),
8598 ControlEventKind::ResumeDenied { reason } => {
8599 return Err(failure(
8600 NodeFailureCode::SessionRecordNotResumable,
8601 &reason,
8602 ));
8603 }
8604 ControlEventKind::ResumeFailed { message }
8605 | ControlEventKind::CommandRejected { message } => {
8606 return Err(failure(
8607 NodeFailureCode::BackendOperationFailed,
8608 &message,
8609 ));
8610 }
8611 _ => {}
8612 }
8613 }
8614 if Instant::now() >= deadline {
8615 return Err(failure(
8616 NodeFailureCode::BackendBusy,
8617 "managed resume did not settle before the bounded deadline",
8618 ));
8619 }
8620 sleep(Duration::from_millis(5)).await;
8621 }
8622 }
8623
8624 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8625 self.workspaces
8626 .read()
8627 .unwrap_or_else(|poisoned| poisoned.into_inner())
8628 .get(workspace_id)
8629 .cloned()
8630 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8631 }
8632
8633 fn workspace_roots(&self) -> Vec<PathBuf> {
8634 self.workspaces
8635 .read()
8636 .unwrap_or_else(|poisoned| poisoned.into_inner())
8637 .values()
8638 .map(PathBuf::from)
8639 .collect()
8640 }
8641
8642 async fn inspect_workspace(
8643 &self,
8644 workspace_id: WorkspaceId,
8645 ) -> Result<WorkspaceInspection, NodeFailure> {
8646 let _permit = self
8647 .inspection_slots
8648 .clone()
8649 .try_acquire_owned()
8650 .map_err(|_| {
8651 tracing::warn!(
8652 workspace_id = %workspace_id,
8653 "workspace inspection rejected: inspection capacity is busy",
8654 );
8655 failure(
8656 NodeFailureCode::BackendBusy,
8657 "workspace inspection capacity is busy",
8658 )
8659 })?;
8660 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8661 tracing::warn!(
8662 workspace_id = %workspace_id,
8663 code = ?error.code,
8664 "workspace inspection rejected: workspace is not registered",
8665 );
8666 error
8667 })?;
8668 let tree_root = canonical_root.clone();
8669 let started_at = Instant::now();
8670 let time_budget_ms = workspace_inspection_time_budget_ms();
8671 let entry_cap = workspace_inspection_entry_cap();
8672 let deadline = started_at + Duration::from_millis(time_budget_ms);
8673 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8674 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8675 })
8676 .await
8677 .map_err(|error| {
8678 tracing::warn!(
8679 workspace_id = %workspace_id,
8680 error = %error,
8681 "workspace inspection rejected: walk task failed",
8682 );
8683 failure(
8684 NodeFailureCode::BackendOperationFailed,
8685 &format!("workspace inspection task failed: {error}"),
8686 )
8687 })?;
8688 let (mut git, git_time_budget_exceeded) =
8689 inspect_git_workspace(&canonical_root, deadline).await;
8690 if !git.worktrees.is_empty() {
8691 let registered = self
8692 .workspaces
8693 .read()
8694 .unwrap_or_else(|poisoned| poisoned.into_inner())
8695 .iter()
8696 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8697 .collect::<Vec<_>>();
8698 for worktree in &mut git.worktrees {
8699 worktree.workspace_id = registered
8700 .iter()
8701 .find(|(_, root)| {
8702 worktree_paths_equal(root, windows_path_text(&worktree.path))
8703 })
8704 .map(|(workspace_id, _)| workspace_id.clone());
8705 }
8706 }
8707 if git.is_repository {
8708 git.managed_worktree = self
8709 .managed_worktrees
8710 .lock()
8711 .unwrap_or_else(|poisoned| poisoned.into_inner())
8712 .git_scope(&workspace_id, git.branch.as_deref());
8713 }
8714 let truncation = workspace_inspection_truncation(
8715 &walk_budget,
8716 git_time_budget_exceeded,
8717 started_at.elapsed(),
8718 );
8719 if let Some(truncation) = &truncation {
8720 tracing::info!(
8721 workspace_id = %workspace_id,
8722 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8723 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8724 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8725 entries_visited = truncation.entries_visited,
8726 elapsed_ms = truncation.elapsed_ms,
8727 "workspace inspection truncated by its inner budget",
8728 );
8729 }
8730 Ok(WorkspaceInspection {
8731 workspace_id,
8732 entries,
8733 tree_truncated,
8734 git,
8735 truncation,
8736 })
8737 }
8738
8739 async fn browse_host_directories(
8740 &self,
8741 directory: Option<OpaqueHostPath>,
8742 after: Option<OpaqueHostPath>,
8743 ) -> Result<HostDirectoryListing, NodeFailure> {
8744 let permit = self
8745 .inspection_slots
8746 .clone()
8747 .try_acquire_owned()
8748 .map_err(|_| {
8749 failure(
8750 NodeFailureCode::BackendBusy,
8751 "host directory browse capacity is busy",
8752 )
8753 })?;
8754 let task = tokio::task::spawn_blocking(move || {
8755 let _permit = permit;
8756 browse_host_directories(directory, after)
8757 });
8758 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8759 .await
8760 .map_err(|_| {
8761 failure(
8762 NodeFailureCode::HostDirectoryReadTimedOut,
8763 "host directory browse exceeded its bounded deadline",
8764 )
8765 })?
8766 .map_err(|_| {
8767 failure(
8768 NodeFailureCode::HostDirectoryReadFailed,
8769 "host directory browse task failed",
8770 )
8771 })?
8772 .map_err(host_directory_failure)
8773 }
8774
8775 async fn read_workspace_file(
8776 &self,
8777 workspace_id: WorkspaceId,
8778 path: RepositoryPath,
8779 ) -> Result<WorkspaceFileRead, NodeFailure> {
8780 let canonical_root = self.workspace_root(&workspace_id)?;
8781 let permit = self
8782 .inspection_slots
8783 .clone()
8784 .try_acquire_owned()
8785 .map_err(|_| {
8786 failure(
8787 NodeFailureCode::BackendBusy,
8788 "workspace file read capacity is busy",
8789 )
8790 })?;
8791 let path_to_read = path.clone();
8792 let task = tokio::task::spawn_blocking(move || {
8793 let _permit = permit;
8794 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8795 });
8796 let content = timeout(
8797 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8798 task,
8799 )
8800 .await
8801 .map_err(|_| {
8802 failure(
8803 NodeFailureCode::RepositoryFileReadTimedOut,
8804 "repository file read exceeded its bounded deadline",
8805 )
8806 })?
8807 .map_err(|_| {
8808 failure(
8809 NodeFailureCode::RepositoryFileReadFailed,
8810 "repository file read task failed",
8811 )
8812 })?
8813 .map_err(workspace_file_failure)?;
8814 let content = match content {
8815 WorkspaceFileBytes::Utf8(text) => {
8816 let revision = Some(workspace_file_revision(text.as_bytes()));
8817 return Ok(WorkspaceFileRead {
8818 workspace_id,
8819 path,
8820 content: WorkspaceFileContent::Utf8 {
8821 byte_len: u32::try_from(text.len())
8822 .expect("bounded workspace text length must fit u32"),
8823 text,
8824 },
8825 revision,
8826 });
8827 }
8828 WorkspaceFileBytes::NonUtf8 { byte_length } => {
8829 WorkspaceFileContent::NonUtf8 {
8830 byte_len: u32::try_from(byte_length)
8831 .expect("bounded workspace file length must fit u32"),
8832 }
8833 }
8834 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8835 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8836 },
8837 };
8838 Ok(WorkspaceFileRead {
8839 workspace_id,
8840 path,
8841 content,
8842 revision: None,
8843 })
8844 }
8845
8846 async fn write_workspace_file(
8847 &self,
8848 workspace_id: WorkspaceId,
8849 path: RepositoryPath,
8850 expected_revision: WorkspaceFileRevision,
8851 text: String,
8852 ) -> Result<WorkspaceFileRead, NodeFailure> {
8853 let canonical_root = self.workspace_root(&workspace_id)?;
8854 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8855 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8856 })?;
8857 let path_to_write = path.clone();
8858 let expected = expected_revision.as_str().to_owned();
8859 let text_to_write = text.clone();
8860 let task = tokio::task::spawn_blocking(move || {
8861 let _permit = permit;
8862 write_workspace_file_to_disk(
8863 Path::new(&canonical_root),
8864 &path_to_write,
8865 &expected,
8866 &text_to_write,
8867 )
8868 });
8869 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8870 .await
8871 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8872 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8873 .map_err(workspace_file_write_failure)?;
8874 Ok(WorkspaceFileRead {
8875 workspace_id,
8876 path,
8877 content: WorkspaceFileContent::Utf8 {
8878 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8879 text,
8880 },
8881 revision: Some(WorkspaceFileRevision::new(revision)
8882 .expect("workspace writer returns a valid SHA-256 revision")),
8883 })
8884 }
8885
8886 async fn create_workspace_file(
8887 &self,
8888 workspace_id: WorkspaceId,
8889 path: RepositoryPath,
8890 ) -> Result<WorkspaceFileRead, NodeFailure> {
8891 let canonical_root = self.workspace_root(&workspace_id)?;
8892 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8893 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8894 })?;
8895 let path_to_create = path.clone();
8896 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8897 let task_commit_state = Arc::clone(&commit_state);
8898 let task = tokio::task::spawn_blocking(move || {
8899 let _permit = permit;
8900 create_workspace_file_on_disk(
8901 Path::new(&canonical_root),
8902 &path_to_create,
8903 &task_commit_state,
8904 )
8905 });
8906 let revision = settle_workspace_entry_create(
8907 task,
8908 commit_state,
8909 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8910 "repository file create exceeded its bounded deadline",
8911 "repository file create task failed",
8912 )
8913 .await?;
8914 Ok(WorkspaceFileRead {
8915 workspace_id,
8916 path,
8917 content: WorkspaceFileContent::Utf8 {
8918 text: String::new(),
8919 byte_len: 0,
8920 },
8921 revision: Some(WorkspaceFileRevision::new(revision)
8922 .expect("workspace creator returns a valid SHA-256 revision")),
8923 })
8924 }
8925
8926 async fn create_workspace_directory(
8927 &self,
8928 workspace_id: WorkspaceId,
8929 path: RepositoryPath,
8930 ) -> Result<WorkspaceEntry, NodeFailure> {
8931 let canonical_root = self.workspace_root(&workspace_id)?;
8932 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8933 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8934 })?;
8935 let path_to_create = path.clone();
8936 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8937 let task_commit_state = Arc::clone(&commit_state);
8938 let task = tokio::task::spawn_blocking(move || {
8939 let _permit = permit;
8940 create_workspace_directory_on_disk(
8941 Path::new(&canonical_root),
8942 &path_to_create,
8943 &task_commit_state,
8944 )
8945 });
8946 settle_workspace_entry_create(
8947 task,
8948 commit_state,
8949 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8950 "repository directory create exceeded its bounded deadline",
8951 "repository directory create task failed",
8952 )
8953 .await?;
8954 Ok(WorkspaceEntry {
8955 relative_path: path,
8956 kind: WorkspaceEntryKind::Directory,
8957 })
8958 }
8959
8960 async fn read_git_history(
8961 &self,
8962 workspace_id: WorkspaceId,
8963 path: Option<RepositoryPath>,
8964 before: Option<GitObjectId>,
8965 limit: u16,
8966 ) -> Result<GitHistoryPage, NodeFailure> {
8967 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8968 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
8969 })?;
8970 let root = self.workspace_root(&workspace_id)?;
8971 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
8972 }
8973
8974 async fn read_git_diff(
8975 &self,
8976 workspace_id: WorkspaceId,
8977 request: GitDiffRequest,
8978 ) -> Result<GitDiff, NodeFailure> {
8979 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8980 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
8981 })?;
8982 let root = self.workspace_root(&workspace_id)?;
8983 read_git_diff_bounded(&root, request).await
8984 }
8985
8986 fn native_session_route_root(
8987 &self,
8988 route: &NativeSessionCatalogRoute,
8989 ) -> Result<Option<String>, NodeFailure> {
8990 route
8991 .validate()
8992 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8993 match route.scope {
8994 gate4agent_types::NativeSessionCatalogScope::Workspace => route
8995 .workspace_id
8996 .as_ref()
8997 .map(|workspace_id| self.workspace_root(workspace_id))
8998 .transpose(),
8999 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9000 }
9001 }
9002
9003 fn project_native_session_entries(
9004 &self,
9005 route: &NativeSessionCatalogRoute,
9006 entries: Vec<ScopedNativeSessionCatalogEntry>,
9007 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9008 let current_root = route
9009 .workspace_id
9010 .as_ref()
9011 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9012 let records = self
9013 .session_records
9014 .lock()
9015 .unwrap_or_else(|poisoned| poisoned.into_inner());
9016 let mut seen_records = std::collections::HashSet::new();
9017 entries
9018 .into_iter()
9019 .map(|entry| {
9020 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9021 let matches = records
9022 .records
9023 .values()
9024 .filter(|record| {
9025 record.provider == route.provider
9026 && record.workspace_id == *workspace_id
9027 && current_root.as_deref().is_some_and(|root| {
9028 platform::roots_equal(
9029 root,
9030 windows_path_text(&record.canonical_root),
9031 )
9032 })
9033 && record.provider_session.as_ref().is_some_and(|identity| {
9034 session_registry::same_provider_session(
9035 &record.provider,
9036 identity,
9037 &route.provider,
9038 &entry.provider_session,
9039 )
9040 })
9041 })
9042 .map(|record| record.record_id.clone())
9043 .collect::<Vec<_>>();
9044 (matches.len() == 1
9045 && seen_records.insert(matches[0].clone()))
9046 .then(|| matches[0].clone())
9047 });
9048 let metadata = entry.metadata;
9049 crate::protocol::NativeSessionCatalogEntry {
9050 selection_id: metadata.selection_id,
9051 title: metadata.title,
9052 modified_at_unix_ms: metadata.modified_at_unix_ms,
9053 model: metadata.model,
9054 message_count: metadata.message_count,
9055 completed_turn_count: metadata.completed_turn_count,
9056 external_group: entry.external_group,
9057 record_id,
9058 }
9059 })
9060 .collect()
9061 }
9062
9063 async fn catalog_native_sessions(
9064 &self,
9065 route: NativeSessionCatalogRoute,
9066 limit: u16,
9067 ) -> Result<(
9068 Vec<crate::protocol::NativeSessionCatalogEntry>,
9069 crate::protocol::NativeSessionCatalogSummary,
9070 ), NodeFailure> {
9071 let canonical_root = self.native_session_route_root(&route)?;
9072 let registered_workspace_roots = self.workspace_roots();
9073 let provider = route.provider.clone();
9074 let scope = route.scope;
9075 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9076 failure(
9077 NodeFailureCode::InvalidRequest,
9078 "native session catalog is not configured",
9079 )
9080 })?;
9081 let _permit = Arc::clone(&self.inspection_slots)
9082 .try_acquire_owned()
9083 .map_err(|_| {
9084 failure(
9085 NodeFailureCode::BackendBusy,
9086 "native session catalog capacity is busy",
9087 )
9088 })?;
9089 let page = run_native_session_catalog_operation(
9090 catalog,
9091 "native session catalog",
9092 move |catalog| {
9093 catalog.catalog_initial_for_scope(
9094 &provider,
9095 scope,
9096 canonical_root.as_deref().map(Path::new),
9097 ®istered_workspace_roots,
9098 limit,
9099 )
9100 },
9101 )
9102 .await?;
9103 let summary = crate::protocol::NativeSessionCatalogSummary {
9104 catalog_revision: page.revision,
9105 recent_cutoff_unix_ms: page.cutoff_unix_ms,
9106 recent_total_count: page.recent_total_count,
9107 older_total_count: page.older_total_count,
9108 recent_next_after_selection_id: page.next_after_selection_id,
9109 recent_has_more: page.remaining_count > 0,
9110 };
9111 Ok((self.project_native_session_entries(&route, page.entries), summary))
9112 }
9113
9114 async fn page_native_sessions(
9115 &self,
9116 route: NativeSessionCatalogRoute,
9117 window: crate::protocol::NativeSessionCatalogWindow,
9118 catalog_revision: u64,
9119 recent_cutoff_unix_ms: u64,
9120 after_selection_id: Option<String>,
9121 limit: u16,
9122 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9123 let canonical_root = self.native_session_route_root(&route)?;
9124 let registered_workspace_roots = self.workspace_roots();
9125 let provider = route.provider.clone();
9126 let scope = route.scope;
9127 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9128 failure(
9129 NodeFailureCode::InvalidRequest,
9130 "native session catalog is not configured",
9131 )
9132 })?;
9133 let _permit = Arc::clone(&self.inspection_slots)
9134 .try_acquire_owned()
9135 .map_err(|_| {
9136 failure(
9137 NodeFailureCode::BackendBusy,
9138 "native session catalog capacity is busy",
9139 )
9140 })?;
9141 let page = run_native_session_catalog_operation(
9142 catalog,
9143 "native session catalog page",
9144 move |catalog| {
9145 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9146 &provider,
9147 scope,
9148 canonical_root.as_deref().map(Path::new),
9149 ®istered_workspace_roots,
9150 window,
9151 catalog_revision,
9152 recent_cutoff_unix_ms,
9153 after_selection_id.as_deref(),
9154 limit,
9155 ))
9156 },
9157 )
9158 .await?;
9159 let page = page.map_err(|error| match error {
9160 NativeSessionCatalogError::StaleCatalog => failure(
9161 NodeFailureCode::StaleNativeSessionCatalog,
9162 "native session catalog revision or cursor is stale",
9163 ),
9164 _ => failure(
9165 NodeFailureCode::BackendOperationFailed,
9166 "native session catalog page failed",
9167 ),
9168 })?;
9169 let entries = self.project_native_session_entries(&route, page.entries);
9170 Ok(crate::protocol::NativeSessionCatalogPage {
9171 window: page.window,
9172 revision: page.revision,
9173 entries,
9174 next_after_selection_id: page.next_after_selection_id,
9175 remaining_count: page.remaining_count,
9176 has_more: page.remaining_count > 0,
9177 })
9178 }
9179
9180 async fn preview_native_session(
9181 &self,
9182 selection: NativeSessionSelection,
9183 message_limit: u16,
9184 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9185 selection
9186 .validate()
9187 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9188 let canonical_root = self.native_session_route_root(&selection.route)?;
9189 let registered_workspace_roots = self.workspace_roots();
9190 let provider = selection.route.provider.clone();
9191 let scope = selection.route.scope;
9192 let catalog_revision = selection.catalog_revision;
9193 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9194 let selection_id = selection.selection_id.clone();
9195 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9196 failure(
9197 NodeFailureCode::InvalidRequest,
9198 "native session preview is not configured",
9199 )
9200 })?;
9201 let _permit = Arc::clone(&self.inspection_slots)
9202 .try_acquire_owned()
9203 .map_err(|_| {
9204 failure(
9205 NodeFailureCode::BackendBusy,
9206 "native session preview capacity is busy",
9207 )
9208 })?;
9209 let preview = run_native_session_catalog_operation(
9210 catalog,
9211 "native session preview",
9212 move |catalog| {
9213 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9214 &provider,
9215 scope,
9216 canonical_root.as_deref().map(Path::new),
9217 ®istered_workspace_roots,
9218 catalog_revision,
9219 recent_cutoff_unix_ms,
9220 &selection_id,
9221 message_limit,
9222 ))
9223 },
9224 )
9225 .await?;
9226 preview
9227 .map(Into::into)
9228 .map_err(native_session_preview_failure)
9229 }
9230
9231 async fn index_native_session(
9232 &self,
9233 selection: NativeSessionSelection,
9234 display_name: String,
9235 ) -> Result<ManagedSessionRecord, NodeFailure> {
9236 selection
9237 .validate()
9238 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9239 if selection.route.scope
9240 != gate4agent_types::NativeSessionCatalogScope::Workspace
9241 {
9242 return Err(failure(
9243 NodeFailureCode::WorkspaceRegistrationRequired,
9244 "register the external project as a workspace before indexing or resuming it",
9245 ));
9246 }
9247 let workspace_id = selection
9248 .route
9249 .workspace_id
9250 .clone()
9251 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9252 let canonical_root = self
9253 .native_session_route_root(&selection.route)?
9254 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9255 let registered_workspace_roots = self.workspace_roots();
9256 let provider = selection.route.provider.clone();
9257 let catalog_revision = selection.catalog_revision;
9258 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9259 let selection_id = selection.selection_id.clone();
9260 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9261 failure(
9262 NodeFailureCode::InvalidRequest,
9263 "native session index is not configured",
9264 )
9265 })?;
9266 let _permit = Arc::clone(&self.inspection_slots)
9267 .try_acquire_owned()
9268 .map_err(|_| {
9269 failure(
9270 NodeFailureCode::BackendBusy,
9271 "native session index capacity is busy",
9272 )
9273 })?;
9274 let resolved = run_native_session_catalog_operation(
9275 catalog,
9276 "native session index resolution",
9277 move |catalog| {
9278 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9279 &provider,
9280 gate4agent_types::NativeSessionCatalogScope::Workspace,
9281 Some(Path::new(&canonical_root)),
9282 ®istered_workspace_roots,
9283 catalog_revision,
9284 recent_cutoff_unix_ms,
9285 &selection_id,
9286 ))
9287 },
9288 )
9289 .await?
9290 .map_err(native_session_preview_failure)?;
9291 self.index_provider_session_with_policy(
9292 workspace_id,
9293 selection.route.provider,
9294 resolved.identity,
9295 display_name,
9296 true,
9297 )
9298 }
9299
9300 async fn preview_native_session_by_session_id(
9301 &self,
9302 workspace_id: WorkspaceId,
9303 provider: AgentId,
9304 session_id: String,
9305 message_limit: u16,
9306 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9307 let canonical_root = self.workspace_root(&workspace_id)?;
9308 let registered_workspace_roots = self.workspace_roots();
9309 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9310 failure(
9311 NodeFailureCode::InvalidRequest,
9312 "native session preview is not configured",
9313 )
9314 })?;
9315 let _permit = Arc::clone(&self.inspection_slots)
9316 .try_acquire_owned()
9317 .map_err(|_| {
9318 failure(
9319 NodeFailureCode::BackendBusy,
9320 "native session preview capacity is busy",
9321 )
9322 })?;
9323 run_native_session_catalog_operation(
9324 catalog,
9325 "native session preview",
9326 move |catalog| {
9327 catalog.preview_session_id_for_workspace(
9328 &provider,
9329 Path::new(&canonical_root),
9330 ®istered_workspace_roots,
9331 &session_id,
9332 message_limit,
9333 )
9334 },
9335 )
9336 .await
9337 }
9338
9339 async fn register_workspace(
9340 &self,
9341 workspace_id: WorkspaceId,
9342 root: String,
9343 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9344 validate_workspace_request_root(&root)?;
9345 let canonicalize_workspace_id = workspace_id.clone();
9346 let workspace = tokio::task::spawn_blocking(move || {
9347 WorkspaceConfig::new(canonicalize_workspace_id, root)
9348 })
9349 .await
9350 .map_err(|error| {
9351 failure(
9352 NodeFailureCode::BackendOperationFailed,
9353 &format!("workspace canonicalization task failed: {error}"),
9354 )
9355 })?
9356 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9357 let transaction = self
9358 .state_transaction
9359 .lock()
9360 .unwrap_or_else(|poisoned| poisoned.into_inner());
9361 let mut workspaces = self
9362 .workspaces
9363 .write()
9364 .unwrap_or_else(|poisoned| poisoned.into_inner());
9365 if workspaces.contains_key(&workspace_id) {
9366 return Err(failure(
9367 NodeFailureCode::DuplicateWorkspaceId,
9368 "workspace ID is already registered",
9369 ));
9370 }
9371 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9372 platform::roots_equal(existing_root, workspace.canonical_root())
9373 }) {
9374 return Err(failure(
9375 NodeFailureCode::DuplicateWorkspaceRoot,
9376 &format!("workspace root is already registered as '{existing_id}'"),
9377 ));
9378 }
9379 {
9380 let records = self
9381 .session_records
9382 .lock()
9383 .unwrap_or_else(|poisoned| poisoned.into_inner());
9384 if let Some(record) = records.records.values().find(|record| {
9385 record.workspace_id == workspace_id
9386 && !record
9387 .canonical_root
9388 .as_utf8()
9389 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9390 }) {
9391 return Err(failure(
9392 NodeFailureCode::SessionRecordConflict,
9393 &format!(
9394 "workspace ID is retained by session record '{}' at another root",
9395 record.record_id,
9396 ),
9397 ));
9398 }
9399 }
9400 let snapshot = WorkspaceSnapshot {
9401 workspace_id: workspace_id.clone(),
9402 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9403 sessions: Vec::new(),
9404 worktree_service_mode: Some(WorktreeServiceMode::Manual),
9405 managed_worktree_profiles: Some(WorktreeProfileInventory {
9406 profiles: Vec::new(),
9407 }),
9408 };
9409 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9410 drop(workspaces);
9411 let previous_records = {
9412 let mut records = self
9413 .session_records
9414 .lock()
9415 .unwrap_or_else(|poisoned| poisoned.into_inner());
9416 let previous = records.records.clone();
9417 for record in records.records.values_mut().filter(|record| {
9418 record.workspace_id == workspace_id
9419 && record
9420 .canonical_root
9421 .as_utf8()
9422 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9423 }) {
9424 if record.active_session.is_none() {
9425 record.state = detached_record_state(record);
9426 }
9427 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9428 record.last_error = None;
9429 }
9430 record.updated_at_unix_ms = unix_time_ms();
9431 }
9432 previous
9433 };
9434 if let Err(error) = self.persist_state_locked() {
9435 self.workspaces
9436 .write()
9437 .unwrap_or_else(|poisoned| poisoned.into_inner())
9438 .remove(&workspace_id);
9439 self.session_records
9440 .lock()
9441 .unwrap_or_else(|poisoned| poisoned.into_inner())
9442 .records = previous_records;
9443 return Err(persistence_failure(error));
9444 }
9445 let restored = self
9446 .session_records
9447 .lock()
9448 .unwrap_or_else(|poisoned| poisoned.into_inner())
9449 .records
9450 .values()
9451 .filter(|record| record.workspace_id == workspace_id)
9452 .cloned()
9453 .collect::<Vec<_>>();
9454 drop(transaction);
9455 self.publish(NodeEvent::WorkspaceAdded {
9456 workspace: snapshot.clone(),
9457 });
9458 for record in restored {
9459 self.publish_record(record);
9460 }
9461 Ok(snapshot)
9462 }
9463
9464 async fn create_standalone_workspace(
9465 &self,
9466 workspace_id: WorkspaceId,
9467 root: String,
9468 initial_branch: Option<String>,
9469 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9470 validate_workspace_request_root(&root)?;
9471 validate_git_revision("initial branch", initial_branch.as_deref())?;
9472 let preflight_workspace_id = workspace_id.clone();
9473 let preflight_root = root.clone();
9474 let canonical_candidate = tokio::task::spawn_blocking(move || {
9475 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9476 })
9477 .await
9478 .map_err(|_| failure(
9479 NodeFailureCode::BackendOperationFailed,
9480 "standalone workspace root preflight task failed",
9481 ))?
9482 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9483 {
9484 let workspaces = self
9485 .workspaces
9486 .read()
9487 .unwrap_or_else(|poisoned| poisoned.into_inner());
9488 if workspaces.contains_key(&workspace_id) {
9489 return Err(failure(
9490 NodeFailureCode::DuplicateWorkspaceId,
9491 "workspace ID is already registered",
9492 ));
9493 }
9494 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9495 platform::roots_equal(existing_root, &canonical_candidate)
9496 }) {
9497 return Err(failure(
9498 NodeFailureCode::DuplicateWorkspaceRoot,
9499 &format!("workspace root is already registered as '{existing_id}'"),
9500 ));
9501 }
9502 }
9503 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9504 .await
9505 .map_err(standalone_workspace_failure)?;
9506 prepared
9507 .verify_for_registration()
9508 .await
9509 .map_err(standalone_workspace_failure)?;
9510 match self
9511 .register_workspace(workspace_id, prepared.root().to_owned())
9512 .await
9513 {
9514 Ok(workspace) => Ok(workspace),
9515 Err(registration_error) => {
9516 if prepared.compensate_registration_failure().await {
9517 Err(registration_error)
9518 } else {
9519 Err(failure(
9520 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9521 "standalone workspace initialization succeeded but registration failed; recovery is required",
9522 ))
9523 }
9524 }
9525 }
9526 }
9527
9528 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9529 let bindings = self
9530 .session_bindings
9531 .lock()
9532 .unwrap_or_else(|poisoned| poisoned.into_inner());
9533 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9534 return Err(failure(
9535 NodeFailureCode::WorkspaceBusy,
9536 "workspace retains a bound session; remove every session before unregistering it",
9537 ));
9538 }
9539 drop(bindings);
9540 let transaction = self
9541 .state_transaction
9542 .lock()
9543 .unwrap_or_else(|poisoned| poisoned.into_inner());
9544 let mut workspaces = self
9545 .workspaces
9546 .write()
9547 .unwrap_or_else(|poisoned| poisoned.into_inner());
9548 if !workspaces.contains_key(workspace_id) {
9549 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9550 }
9551 if workspaces.len() == 1 {
9552 return Err(failure(
9553 NodeFailureCode::LastWorkspace,
9554 "node must retain at least one workspace",
9555 ));
9556 }
9557 let removed_root = workspaces
9558 .remove(workspace_id)
9559 .expect("workspace presence was validated");
9560 drop(workspaces);
9561 let previous_records = {
9562 let mut records = self
9563 .session_records
9564 .lock()
9565 .unwrap_or_else(|poisoned| poisoned.into_inner());
9566 let previous = records.records.clone();
9567 for record in records
9568 .records
9569 .values_mut()
9570 .filter(|record| &record.workspace_id == workspace_id)
9571 {
9572 record.active_session = None;
9573 record.state = ManagedSessionState::Unavailable;
9574 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9575 record.updated_at_unix_ms = unix_time_ms();
9576 }
9577 previous
9578 };
9579 if let Err(error) = self.persist_state_locked() {
9580 self.workspaces
9581 .write()
9582 .unwrap_or_else(|poisoned| poisoned.into_inner())
9583 .insert(workspace_id.clone(), removed_root);
9584 self.session_records
9585 .lock()
9586 .unwrap_or_else(|poisoned| poisoned.into_inner())
9587 .records = previous_records;
9588 return Err(persistence_failure(error));
9589 }
9590 let unavailable = self
9591 .session_records
9592 .lock()
9593 .unwrap_or_else(|poisoned| poisoned.into_inner())
9594 .records
9595 .values()
9596 .filter(|record| &record.workspace_id == workspace_id)
9597 .cloned()
9598 .collect::<Vec<_>>();
9599 drop(transaction);
9600 self.publish(NodeEvent::WorkspaceRemoved {
9601 workspace_id: workspace_id.clone(),
9602 });
9603 for record in unavailable {
9604 self.publish_record(record);
9605 }
9606 Ok(())
9607 }
9608
9609 async fn create_worktree(
9610 &self,
9611 source_workspace_id: WorkspaceId,
9612 workspace_id: WorkspaceId,
9613 target_root: String,
9614 branch: String,
9615 base: Option<String>,
9616 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9617 self.require_worktree_service(&source_workspace_id)?;
9618 validate_workspace_request_root(&target_root)?;
9619 validate_git_revision("worktree base", base.as_deref())?;
9620 let source_root = self.workspace_root(&source_workspace_id)?;
9621 {
9622 let workspaces = self
9623 .workspaces
9624 .read()
9625 .unwrap_or_else(|poisoned| poisoned.into_inner());
9626 if workspaces.contains_key(&workspace_id) {
9627 return Err(failure(
9628 NodeFailureCode::DuplicateWorkspaceId,
9629 "workspace ID is already registered",
9630 ));
9631 }
9632 if let Some((existing_id, _)) = workspaces
9633 .iter()
9634 .find(|(_, root)| worktree_paths_equal(root, &target_root))
9635 {
9636 return Err(failure(
9637 NodeFailureCode::DuplicateWorkspaceRoot,
9638 &format!("workspace root is already registered as '{existing_id}'"),
9639 ));
9640 }
9641 }
9642 let mut worktree = create_git_worktree(
9643 &source_root,
9644 &target_root,
9645 &branch,
9646 base.as_deref(),
9647 )
9648 .await
9649 .map_err(git_worktree_failure)?;
9650 let workspace = match self
9651 .register_workspace(workspace_id, worktree.path.clone())
9652 .await
9653 {
9654 Ok(workspace) => workspace,
9655 Err(registration_error) => {
9656 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9657 return Err(failure(
9658 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9659 "worktree registration failed and compensation requires recovery",
9660 ));
9661 }
9662 return Err(failure(
9663 registration_error.code,
9664 ®istration_error.message,
9665 ));
9666 }
9667 };
9668 worktree.workspace_id = Some(workspace.workspace_id.clone());
9669 Ok((protocol_worktree(worktree), workspace))
9670 }
9671
9672 async fn remove_worktree(
9673 &self,
9674 source_workspace_id: WorkspaceId,
9675 target_root: String,
9676 ) -> Result<Option<WorkspaceId>, NodeFailure> {
9677 self.require_worktree_service(&source_workspace_id)?;
9678 validate_workspace_request_root(&target_root)?;
9679 let source_root = self.workspace_root(&source_workspace_id)?;
9680 let target_root = normalize_worktree_removal_target(&target_root)
9681 .map_err(git_worktree_failure)?;
9682 let target = list_git_worktrees(&source_root)
9683 .await
9684 .map_err(git_worktree_failure)?
9685 .into_iter()
9686 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9687 .ok_or_else(|| {
9688 failure(
9689 NodeFailureCode::WorktreeProtected,
9690 "refusing to remove a path that is not in Git's worktree listing",
9691 )
9692 })?;
9693 let registered_workspace_id = {
9694 let workspaces = self
9695 .workspaces
9696 .read()
9697 .unwrap_or_else(|poisoned| poisoned.into_inner());
9698 let matched = workspaces
9699 .iter()
9700 .find(|(_, root)| worktree_paths_equal(root, &target.path))
9701 .map(|(workspace_id, _)| workspace_id.clone());
9702 if matched.is_some() && workspaces.len() == 1 {
9703 return Err(failure(
9704 NodeFailureCode::LastWorkspace,
9705 "node must retain at least one workspace",
9706 ));
9707 }
9708 matched
9709 };
9710 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9711 let bindings = self
9712 .session_bindings
9713 .lock()
9714 .unwrap_or_else(|poisoned| poisoned.into_inner());
9715 if bindings
9716 .values()
9717 .any(|binding| &binding.workspace_id == workspace_id)
9718 {
9719 return Err(failure(
9720 NodeFailureCode::WorkspaceBusy,
9721 "worktree workspace retains a bound session; remove every session first",
9722 ));
9723 }
9724 }
9725 remove_git_worktree(&source_root, &target.path)
9726 .await
9727 .map_err(git_worktree_failure)?;
9728 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9729 self.unregister_workspace(workspace_id)
9730 .map_err(|error| failure(error.code, &error.message))?;
9731 }
9732 Ok(registered_workspace_id)
9733 }
9734
9735 async fn cleanup_managed_worktree(
9736 &self,
9737 lease_id: &ManagedWorktreeLeaseId,
9738 explicit: bool,
9739 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9740 let lease = self.managed_worktrees.lock()
9741 .unwrap_or_else(|poisoned| poisoned.into_inner())
9742 .get(lease_id)
9743 .cloned()
9744 .ok_or_else(|| failure(
9745 NodeFailureCode::UnknownManagedWorktreeLease,
9746 "managed worktree lease does not exist",
9747 ))?;
9748 if lease.state == ManagedWorktreeLeaseState::Removed {
9749 return Ok(lease.snapshot());
9750 }
9751 if lease.has_holders() {
9752 return Err(failure(
9753 NodeFailureCode::ManagedWorktreeBusy,
9754 "managed worktree lease still has a session or durable-record holder",
9755 ));
9756 }
9757 if self
9758 .materializations
9759 .lock()
9760 .unwrap_or_else(|poisoned| poisoned.into_inner())
9761 .values()
9762 .any(|record| record.managed_lease_id() == Some(lease_id))
9763 {
9764 return Err(failure(
9765 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9766 "managed worktree cleanup is blocked by session environment ownership",
9767 ));
9768 }
9769 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9770 let snapshot = {
9771 let mut registry = self.managed_worktrees.lock()
9772 .unwrap_or_else(|poisoned| poisoned.into_inner());
9773 let current = registry.get_mut(lease_id)
9774 .expect("managed worktree lease remains present");
9775 current.state = ManagedWorktreeLeaseState::Retained;
9776 current.cleanup_failure = None;
9777 current.updated_at_unix_ms = unix_time_ms();
9778 current.snapshot()
9779 };
9780 if self.persist_state().is_err() {
9781 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9782 }
9783 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9784 return Ok(snapshot);
9785 }
9786 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9787 if profile.revision() != &lease.profile_revision {
9788 self.mark_managed_recovery_required(
9789 lease_id,
9790 ManagedWorktreeCleanupFailure::OwnershipConflict,
9791 );
9792 return Err(failure(
9793 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9794 "managed worktree profile revision changed",
9795 ));
9796 }
9797 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9798 self.mark_managed_recovery_required(
9799 lease_id,
9800 ManagedWorktreeCleanupFailure::OwnershipConflict,
9801 );
9802 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9803 })?;
9804 let source_root = self.workspace_root(&lease.source_workspace_id)?;
9805 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9806 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9807 managed_git_worktree_failure(error)
9808 })?;
9809 let target = listed.iter()
9810 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9811 if let Some(target) = target {
9812 if target.is_main || target.is_bare || !exact_owned_worktree(
9813 &lease,
9814 &target.path,
9815 target.branch.as_deref(),
9816 &target.head,
9817 ) {
9818 self.mark_managed_recovery_required(
9819 lease_id,
9820 ManagedWorktreeCleanupFailure::OwnershipConflict,
9821 );
9822 return Err(failure(
9823 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9824 "Git worktree identity no longer matches the managed lease",
9825 ));
9826 }
9827 if target.prunable {
9828 self.mark_managed_cleanup_blocked(
9829 lease_id,
9830 ManagedWorktreeCleanupFailure::Prunable,
9831 );
9832 return Err(failure(
9833 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9834 "managed worktree is prunable and requires recovery",
9835 ));
9836 }
9837 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9838 let failure_kind = match error.kind {
9839 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9840 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9841 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9842 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9843 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9844 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9845 };
9846 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9847 self.mark_managed_recovery_required(lease_id, failure_kind);
9848 } else {
9849 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9850 }
9851 return Err(managed_git_worktree_failure(error));
9852 }
9853 } else {
9854 match std::fs::symlink_metadata(&lease.target_root) {
9855 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9856 Ok(_) => {
9857 self.mark_managed_recovery_required(
9858 lease_id,
9859 ManagedWorktreeCleanupFailure::OwnershipConflict,
9860 );
9861 return Err(failure(
9862 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9863 "managed target exists but is not owned by Git",
9864 ));
9865 }
9866 Err(_) => {
9867 self.mark_managed_cleanup_blocked(
9868 lease_id,
9869 ManagedWorktreeCleanupFailure::Backend,
9870 );
9871 return Err(failure(
9872 NodeFailureCode::BackendOperationFailed,
9873 "managed target identity could not be inspected",
9874 ));
9875 }
9876 }
9877 }
9878 let registered = self.workspaces.read()
9879 .unwrap_or_else(|poisoned| poisoned.into_inner())
9880 .contains_key(&lease.workspace_id);
9881 if registered {
9882 self.unregister_workspace(&lease.workspace_id)?;
9883 }
9884 let removed = {
9885 self.managed_worktrees.lock()
9886 .unwrap_or_else(|poisoned| poisoned.into_inner())
9887 .tombstone(lease_id, unix_time_ms())
9888 .expect("managed worktree lease remains present during cleanup")
9889 };
9890 self.persist_state().map_err(persistence_failure)?;
9891 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9892 Ok(removed.snapshot())
9893 }
9894
9895 fn mark_managed_cleanup_blocked(
9896 &self,
9897 lease_id: &ManagedWorktreeLeaseId,
9898 failure_kind: ManagedWorktreeCleanupFailure,
9899 ) {
9900 let snapshot = {
9901 let mut registry = self.managed_worktrees.lock()
9902 .unwrap_or_else(|poisoned| poisoned.into_inner());
9903 let Some(lease) = registry.get_mut(lease_id) else { return };
9904 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9905 lease.cleanup_failure = Some(failure_kind);
9906 lease.updated_at_unix_ms = unix_time_ms();
9907 lease.snapshot()
9908 };
9909 let _ = self.persist_state();
9910 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9911 }
9912
9913 fn require_worktree_service(
9914 &self,
9915 source_workspace_id: &WorkspaceId,
9916 ) -> Result<(), NodeFailure> {
9917 if self.managed_worktrees.lock()
9918 .unwrap_or_else(|poisoned| poisoned.into_inner())
9919 .lease_for_workspace(source_workspace_id)
9920 .is_some()
9921 {
9922 return Err(failure(
9923 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9924 "managed worktree targets are internal-only workspaces",
9925 ));
9926 }
9927 match self
9928 .worktree_service_modes
9929 .get(source_workspace_id)
9930 .copied()
9931 .unwrap_or(WorktreeServiceMode::Manual)
9932 {
9933 WorktreeServiceMode::Manual => Ok(()),
9934 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
9935 NodeFailureCode::UnsupportedSpawnCapability,
9936 "Git worktree service mode is unavailable in this node build",
9937 )),
9938 }
9939 }
9940
9941 fn managed_reservation_conflict(
9942 &self,
9943 workspace_id: Option<&WorkspaceId>,
9944 root: Option<&str>,
9945 ) -> bool {
9946 self.managed_worktrees.lock()
9947 .unwrap_or_else(|poisoned| poisoned.into_inner())
9948 .active_records()
9949 .any(|lease| {
9950 workspace_id.is_some_and(|workspace_id| {
9951 workspace_id == &lease.source_workspace_id
9952 || workspace_id == &lease.workspace_id
9953 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
9954 })
9955 }
9956
9957 fn reject_managed_reservation(
9958 &self,
9959 workspace_id: Option<&WorkspaceId>,
9960 root: Option<&str>,
9961 ) -> Result<(), NodeFailure> {
9962 if self.managed_reservation_conflict(workspace_id, root) {
9963 Err(failure(
9964 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9965 "managed worktree ownership may only be mutated through its lease cleanup",
9966 ))
9967 } else {
9968 Ok(())
9969 }
9970 }
9971
9972 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
9973 self.validate_address(address)?;
9974 self.workspace_root(&address.workspace_id)
9975 }
9976
9977 fn require_session_runtime_policy(
10011 &self,
10012 address: &SessionAddress,
10013 requirement: ProviderRuntimeRequirement,
10014 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10015 let runtime_policy = {
10016 let bindings = self
10017 .session_bindings
10018 .lock()
10019 .unwrap_or_else(|poisoned| poisoned.into_inner());
10020 let binding = bindings
10021 .get(&address.session.instance_id)
10022 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10023 if binding.workspace_id != address.workspace_id {
10024 return Err(failure(
10025 NodeFailureCode::SessionWorkspaceMismatch,
10026 "session belongs to another workspace",
10027 ));
10028 }
10029 if binding.generation != address.session.generation {
10030 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10031 }
10032 binding.runtime_policy
10033 };
10034 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10035 let snapshot = self.internal_session_snapshot(address)?;
10036 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10037 && snapshot.provider.lead_activity != ProviderActivity::Idle
10038 {
10039 return Err(failure(
10040 NodeFailureCode::TurnInFlight,
10041 "session already has a turn in flight; wait for it to complete before sending another prompt",
10042 ));
10043 }
10044 }
10045 require_policy(runtime_policy, false, false, requirement)
10046 .map(|()| runtime_policy)
10047 .map_err(|_| failure(
10048 NodeFailureCode::UnsupportedCapability,
10049 "session runtime policy does not admit this semantic operation",
10050 ))
10051 }
10052
10053 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10054 let mut bindings = self
10055 .session_bindings
10056 .lock()
10057 .unwrap_or_else(|poisoned| poisoned.into_inner());
10058 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10059 binding.workspace_id == address.workspace_id
10060 && binding.generation == address.session.generation
10061 }) {
10062 let removed = bindings.remove(&address.session.instance_id);
10063 self.clear_terminal_frame_watermark(address);
10064 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10065 return removed;
10066 }
10067 None
10068 }
10069
10070 fn cleanup_session_owned_materialization(
10071 &self,
10072 address: &SessionAddress,
10073 binding: &SessionBinding,
10074 ) -> Result<(), NodeFailure> {
10075 let Some(id) = binding.materialization_id.as_ref() else {
10076 return Ok(());
10077 };
10078 let owner = self
10079 .materializations
10080 .lock()
10081 .unwrap_or_else(|poisoned| poisoned.into_inner())
10082 .get(id)
10083 .map(|record| record.owner().clone());
10084 match owner {
10085 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10086 Some(MaterializationOwner::Session {
10087 incarnation_id,
10088 instance_id,
10089 generation,
10090 }) if incarnation_id == self.incarnation_id
10091 && instance_id == address.session.instance_id
10092 && generation == address.session.generation =>
10093 {
10094 self.cleanup_materialization(id)
10095 }
10096 Some(MaterializationOwner::Session { .. }) => Err(failure(
10097 NodeFailureCode::BackendOperationFailed,
10098 "session environment ownership requires recovery",
10099 )),
10100 }
10101 }
10102
10103 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10104 let mut watermarks = self
10105 .terminal_frame_watermarks
10106 .lock()
10107 .unwrap_or_else(|poisoned| poisoned.into_inner());
10108 if watermarks
10109 .get(&address.session.instance_id)
10110 .is_some_and(|(current, _)| current == address)
10111 {
10112 watermarks.remove(&address.session.instance_id);
10113 }
10114 }
10115
10116 #[cfg(test)]
10117 fn address_for(
10118 &self,
10119 instance_id: AgentInstanceId,
10120 generation: SessionGeneration,
10121 ) -> Option<SessionAddress> {
10122 let binding = self.session_bindings
10123 .lock()
10124 .unwrap_or_else(|poisoned| poisoned.into_inner())
10125 .get(&instance_id)
10126 .cloned()?;
10127 (binding.generation == generation).then_some(SessionAddress {
10128 workspace_id: binding.workspace_id,
10129 session: SessionKey { instance_id, generation },
10130 })
10131 }
10132
10133 fn arm_resume(
10134 &self,
10135 address: &SessionAddress,
10136 command_id: CommandId,
10137 runtime_policy: ProviderRuntimePolicy,
10138 ) -> Result<(), NodeFailure> {
10139 let mut bindings = self
10140 .session_bindings
10141 .lock()
10142 .unwrap_or_else(|poisoned| poisoned.into_inner());
10143 let binding = bindings
10144 .get_mut(&address.session.instance_id)
10145 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10146 if binding.workspace_id != address.workspace_id {
10147 return Err(failure(
10148 NodeFailureCode::SessionWorkspaceMismatch,
10149 "session belongs to another workspace",
10150 ));
10151 }
10152 if binding.generation != address.session.generation {
10153 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10154 }
10155 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10156 Ok(())
10157 }
10158
10159 fn clear_armed_resume(&self, address: &SessionAddress) {
10160 let mut bindings = self
10161 .session_bindings
10162 .lock()
10163 .unwrap_or_else(|poisoned| poisoned.into_inner());
10164 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10165 if binding.workspace_id == address.workspace_id
10166 && binding.generation == address.session.generation
10167 {
10168 binding.pending_resume = None;
10169 }
10170 }
10171 }
10172
10173 fn publish_control(&self, event: ControlEvent) {
10174 let revokes_harness_mcp = matches!(
10175 &event.event,
10176 ControlEventKind::Exited { .. }
10177 | ControlEventKind::Failed { .. }
10178 | ControlEventKind::Removed
10179 );
10180 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10181 let mut bindings = self
10182 .session_bindings
10183 .lock()
10184 .unwrap_or_else(|poisoned| poisoned.into_inner());
10185 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10186 return;
10187 };
10188 let previous_address = SessionAddress {
10189 workspace_id: binding.workspace_id.clone(),
10190 session: SessionKey {
10191 instance_id: event.instance_id,
10192 generation: binding.generation,
10193 },
10194 };
10195 let mut resume_command_rejected = false;
10196 if binding.generation == event.generation {
10197 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10198 let rejected_resume = matches!(
10199 &event.event,
10200 ControlEventKind::CommandRejected { .. }
10201 ) && event.command_id == Some(pending_command_id);
10202 let resume_failed = matches!(
10203 &event.event,
10204 ControlEventKind::ResumeDenied { .. }
10205 | ControlEventKind::ResumeFailed { .. }
10206 );
10207 if pending_generation == binding.generation
10208 && (rejected_resume || resume_failed)
10209 {
10210 resume_command_rejected = rejected_resume;
10211 binding.pending_resume = None;
10212 }
10213 }
10214 } else {
10215 let expected = binding.generation.0.checked_add(1);
10216 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10217 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10218 generation == binding.generation
10219 })
10220 || expected != Some(event.generation.0)
10221 || !authorized
10222 {
10223 return;
10224 }
10225 let (_, _, runtime_policy) = binding
10226 .pending_resume
10227 .expect("authorized resume retains its pending runtime policy");
10228 binding.generation = event.generation;
10229 binding.runtime_policy = runtime_policy;
10230 binding.pending_resume = None;
10231 }
10232 (
10233 SessionAddress {
10234 workspace_id: binding.workspace_id.clone(),
10235 session: SessionKey {
10236 instance_id: event.instance_id,
10237 generation: event.generation,
10238 },
10239 },
10240 (previous_address.session.generation != event.generation)
10241 .then_some(previous_address),
10242 binding.record_id.clone(),
10243 binding.runtime_policy.provider_session_identity,
10244 resume_command_rejected,
10245 )
10246 };
10247 if let Some(previous_address) = previous_address.as_ref() {
10248 self.clear_terminal_frame_watermark(previous_address);
10249 }
10250 if let Some(record_id) = record_id {
10251 self.reconcile_managed_record(
10252 &record_id,
10253 &address,
10254 &event,
10255 identity_admitted,
10256 resume_command_rejected,
10257 );
10258 }
10259 match &event.event {
10267 ControlEventKind::Running { process_id } => {
10268 tracing::info!(
10269 node_id = %self.node_id,
10270 workspace_id = %address.workspace_id,
10271 session = ?address.session,
10272 pid = ?process_id,
10273 "session process is running",
10274 );
10275 }
10276 ControlEventKind::Exited { exit_code, forced } => {
10277 tracing::info!(
10278 node_id = %self.node_id,
10279 workspace_id = %address.workspace_id,
10280 session = ?address.session,
10281 exit_code = ?exit_code,
10282 forced = forced,
10283 "session process exited",
10284 );
10285 }
10286 ControlEventKind::Failed { message } => {
10287 tracing::warn!(
10288 node_id = %self.node_id,
10289 workspace_id = %address.workspace_id,
10290 session = ?address.session,
10291 cause = %message,
10292 "session failed",
10293 );
10294 }
10295 ControlEventKind::CommandRejected { message } => {
10296 tracing::warn!(
10297 node_id = %self.node_id,
10298 workspace_id = %address.workspace_id,
10299 session = ?address.session,
10300 cause = %message,
10301 "session command rejected",
10302 );
10303 }
10304 ControlEventKind::Removed => {
10305 tracing::info!(
10306 node_id = %self.node_id,
10307 workspace_id = %address.workspace_id,
10308 session = ?address.session,
10309 "session removed",
10310 );
10311 }
10312 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10313 tracing::info!(
10314 node_id = %self.node_id,
10315 workspace_id = %address.workspace_id,
10316 session = ?address.session,
10317 provider_session = ?provider_session,
10318 "session resume authorized",
10319 );
10320 }
10321 ControlEventKind::Resumed { session: provider_session, process_id } => {
10322 tracing::info!(
10323 node_id = %self.node_id,
10324 workspace_id = %address.workspace_id,
10325 session = ?address.session,
10326 provider_session = ?provider_session,
10327 pid = ?process_id,
10328 "session resumed",
10329 );
10330 }
10331 ControlEventKind::ResumeDenied { reason } => {
10332 tracing::warn!(
10333 node_id = %self.node_id,
10334 workspace_id = %address.workspace_id,
10335 session = ?address.session,
10336 cause = %reason,
10337 "session resume denied",
10338 );
10339 }
10340 ControlEventKind::ResumeFailed { message } => {
10341 tracing::warn!(
10342 node_id = %self.node_id,
10343 workspace_id = %address.workspace_id,
10344 session = ?address.session,
10345 cause = %message,
10346 "session resume failed",
10347 );
10348 }
10349 _ => {}
10350 }
10351 let stream_chunk = agent_stream_chunk(&event);
10352 self.publish(NodeEvent::Control {
10353 address: address.clone(),
10354 event,
10355 });
10356 if let Some(chunk) = stream_chunk {
10357 self.publish_agent_stream_chunk(address.clone(), chunk);
10358 }
10359 if revokes_harness_mcp {
10360 SessionLifecycleHook::on_session_ended(self, &address);
10361 }
10362 }
10363
10364 fn reconcile_managed_record(
10365 &self,
10366 record_id: &SessionRecordId,
10367 address: &SessionAddress,
10368 event: &ControlEvent,
10369 identity_admitted: bool,
10370 resume_command_rejected: bool,
10371 ) {
10372 let _transaction = self
10373 .state_transaction
10374 .lock()
10375 .unwrap_or_else(|poisoned| poisoned.into_inner());
10376 let observed_identity = match &event.event {
10377 ControlEventKind::ProviderEvent {
10378 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10379 ..
10380 } if identity_admitted => Some(identity.clone()),
10381 _ => None,
10382 };
10383 let identity_was_observed = observed_identity.is_some();
10384 let replacement_id = observed_identity
10385 .as_ref()
10386 .and_then(|_| self.allocate_record_id().ok());
10387 let mut upserts = Vec::new();
10388 let mut removals = Vec::new();
10389 let mut rebind = None;
10390 let original_records;
10391 let original_materializations = self
10392 .materializations
10393 .lock()
10394 .unwrap_or_else(|poisoned| poisoned.into_inner())
10395 .clone();
10396 {
10397 let mut records = self
10398 .session_records
10399 .lock()
10400 .unwrap_or_else(|poisoned| poisoned.into_inner());
10401 original_records = records.records.clone();
10402 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10403 return;
10404 };
10405 if let Some(identity) = observed_identity {
10406 let existing_id = records
10407 .records
10408 .values()
10409 .find(|record| {
10410 record.record_id != *record_id
10411 && record.provider_session.as_ref().is_some_and(|record_identity| {
10412 session_registry::same_provider_session(
10413 &record.provider,
10414 record_identity,
10415 ¤t_snapshot.provider,
10416 &identity,
10417 )
10418 })
10419 })
10420 .map(|record| record.record_id.clone());
10421 if let Some(existing_id) = existing_id {
10422 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10423 record.mode == current_snapshot.mode
10424 && record.workspace_id == current_snapshot.workspace_id
10425 && record.environment_profile == current_snapshot.environment_profile
10426 && record.bundle == current_snapshot.bundle
10427 && record.context_id == current_snapshot.context_id
10428 && record.context == current_snapshot.context
10429 && record
10430 .canonical_root
10431 .as_utf8()
10432 .zip(current_snapshot.canonical_root.as_utf8())
10433 .is_some_and(|(left, right)| platform::roots_equal(left, right))
10434 });
10435 if !scope_matches {
10436 if let Some(current) = records.records.get_mut(record_id) {
10437 current.state = ManagedSessionState::Unavailable;
10438 current.last_error =
10439 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10440 current.updated_at_unix_ms = unix_time_ms();
10441 upserts.push(current.clone());
10442 }
10443 } else {
10444 let existing_is_active = records
10445 .records
10446 .get(&existing_id)
10447 .is_some_and(|record| record.active_session.is_some());
10448 if existing_is_active {
10449 if let Some(current) = records.records.get_mut(record_id) {
10450 current.state = ManagedSessionState::Unavailable;
10451 current.last_error =
10452 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10453 current.updated_at_unix_ms = unix_time_ms();
10454 upserts.push(current.clone());
10455 }
10456 } else {
10457 if current_snapshot.provider_session.is_some() {
10458 if let Some(current) = records.records.get_mut(record_id) {
10459 current.active_session = None;
10460 current.state = ManagedSessionState::Dormant;
10461 current.updated_at_unix_ms = unix_time_ms();
10462 upserts.push(current.clone());
10463 }
10464 } else {
10465 records.records.remove(record_id);
10466 removals.push(record_id.clone());
10467 }
10468 if let Some(existing) = records.records.get_mut(&existing_id) {
10469 existing.provider_session = Some(identity.clone());
10470 existing.active_session = Some(address.clone());
10471 existing.state = ManagedSessionState::Live;
10472 existing.updated_at_unix_ms = unix_time_ms();
10473 existing.last_error = None;
10474 upserts.push(existing.clone());
10475 }
10476 rebind = Some(existing_id);
10477 }
10478 }
10479 } else if current_snapshot
10480 .provider_session
10481 .as_ref()
10482 .is_some_and(|current| {
10483 !session_registry::same_provider_session(
10484 ¤t_snapshot.provider,
10485 current,
10486 ¤t_snapshot.provider,
10487 &identity,
10488 )
10489 })
10490 {
10491 if let Some(current) = records.records.get_mut(record_id) {
10492 current.active_session = None;
10493 if current.bundle.is_some() || current.context.is_some() {
10494 let had_bundle = current.bundle.is_some();
10495 current.provider_session = None;
10496 current.bundle = None;
10497 current.context_id = None;
10498 current.context = None;
10499 current.state = ManagedSessionState::Unavailable;
10500 current.last_error = Some(if had_bundle {
10501 "bundle-unavailable".to_owned()
10502 } else {
10503 "context-unavailable".to_owned()
10504 });
10505 } else {
10506 current.state = ManagedSessionState::Dormant;
10507 }
10508 current.updated_at_unix_ms = unix_time_ms();
10509 upserts.push(current.clone());
10510 }
10511 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10512 if let Some(current) = records.records.get_mut(record_id) {
10513 current.state = ManagedSessionState::Unavailable;
10514 current.last_error =
10515 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10516 current.updated_at_unix_ms = unix_time_ms();
10517 upserts.push(current.clone());
10518 }
10519 } else if let Some(new_record_id) = replacement_id {
10520 let now = unix_time_ms();
10521 let replacement = ManagedSessionRecord {
10522 record_id: new_record_id.clone(),
10523 display_name: format!(
10524 "{} #{}",
10525 current_snapshot.provider.as_str(),
10526 records
10527 .records
10528 .values()
10529 .filter(|record| {
10530 record.provider == current_snapshot.provider
10531 })
10532 .count()
10533 .saturating_add(1),
10534 ),
10535 provider: current_snapshot.provider.clone(),
10536 mode: current_snapshot.mode,
10537 state: ManagedSessionState::Live,
10538 workspace_id: current_snapshot.workspace_id,
10539 canonical_root: current_snapshot.canonical_root,
10540 provider_session: Some(identity),
10541 active_session: Some(address.clone()),
10542 environment_profile: current_snapshot.environment_profile,
10543 bundle: current_snapshot.bundle,
10544 context_id: current_snapshot.context_id,
10545 context: current_snapshot.context,
10546 exported_context: None,
10552 task_binding: current_snapshot.task_binding,
10553 created_at_unix_ms: now,
10554 updated_at_unix_ms: now,
10555 last_error: None,
10556 };
10557 records
10558 .records
10559 .insert(new_record_id.clone(), replacement.clone());
10560 upserts.push(replacement);
10561 rebind = Some(new_record_id);
10562 } else if let Some(current) = records.records.get_mut(record_id) {
10563 current.state = ManagedSessionState::Unavailable;
10564 current.last_error =
10565 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10566 upserts.push(current.clone());
10567 }
10568 } else if let Some(current) = records.records.get_mut(record_id) {
10569 current.provider_session = Some(identity);
10570 current.active_session = Some(address.clone());
10571 current.state = ManagedSessionState::Live;
10572 current.updated_at_unix_ms = unix_time_ms();
10573 current.last_error = None;
10574 upserts.push(current.clone());
10575 }
10576 } else if let Some(current) = records.records.get_mut(record_id) {
10577 if resume_command_rejected {
10578 current.active_session = None;
10579 current.state = detached_record_state(current);
10580 current.updated_at_unix_ms = unix_time_ms();
10581 current.last_error =
10582 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10583 upserts.push(current.clone());
10584 } else {
10585 match &event.event {
10586 ControlEventKind::Running { .. }
10587 | ControlEventKind::ResumeAuthorized { .. }
10588 | ControlEventKind::Resumed { .. } => {
10589 current.active_session = Some(address.clone());
10590 current.state = active_record_state(
10591 identity_admitted,
10592 current.provider_session.is_some(),
10593 );
10594 current.updated_at_unix_ms = unix_time_ms();
10595 current.last_error = None;
10596 upserts.push(current.clone());
10597 }
10598 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10599 current.active_session = None;
10600 current.state = detached_record_state(current);
10601 current.updated_at_unix_ms = unix_time_ms();
10602 upserts.push(current.clone());
10603 }
10604 ControlEventKind::Failed { message }
10605 | ControlEventKind::ResumeFailed { message } => {
10606 current.active_session = None;
10607 current.state = detached_record_state(current);
10608 current.updated_at_unix_ms = unix_time_ms();
10609 current.last_error = Some(
10610 session_registry::sanitized_record_error_summary(message),
10611 );
10612 upserts.push(current.clone());
10613 }
10614 ControlEventKind::ResumeDenied { reason } => {
10615 current.active_session = None;
10616 current.state = detached_record_state(current);
10617 current.updated_at_unix_ms = unix_time_ms();
10618 current.last_error = Some(
10619 session_registry::sanitized_record_error_summary(reason),
10620 );
10621 upserts.push(current.clone());
10622 }
10623 _ => {}
10624 }
10625 }
10626 }
10627 }
10628 let materialization_owner_record = identity_was_observed.then(|| {
10629 rebind.clone().unwrap_or_else(|| record_id.clone())
10630 }).and_then(|candidate| {
10631 self.session_records
10632 .lock()
10633 .unwrap_or_else(|poisoned| poisoned.into_inner())
10634 .records
10635 .get(&candidate)
10636 .filter(|record| {
10637 record.provider_session.is_some()
10638 && record.active_session.as_ref() == Some(address)
10639 })
10640 .map(|_| candidate)
10641 });
10642 if let Some(owner_record_id) = materialization_owner_record {
10643 let materialization_id = self
10644 .session_bindings
10645 .lock()
10646 .unwrap_or_else(|poisoned| poisoned.into_inner())
10647 .get(&address.session.instance_id)
10648 .and_then(|binding| binding.materialization_id.clone());
10649 if let Some(materialization_id) = materialization_id {
10650 let mut transfer_failed = false;
10651 {
10652 let mut materializations = self
10653 .materializations
10654 .lock()
10655 .unwrap_or_else(|poisoned| poisoned.into_inner());
10656 if let Some(ownership) = materializations.get_mut(&materialization_id) {
10657 match ownership.owner() {
10658 MaterializationOwner::Session {
10659 incarnation_id,
10660 instance_id,
10661 generation,
10662 } if *incarnation_id == self.incarnation_id
10663 && *instance_id == address.session.instance_id
10664 && *generation == address.session.generation =>
10665 {
10666 transfer_failed = ownership
10667 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10668 .is_err();
10669 }
10670 MaterializationOwner::Record { record_id }
10671 if record_id == &owner_record_id => {}
10672 MaterializationOwner::Record { record_id } => {
10673 let expected = record_id.clone();
10674 transfer_failed = ownership
10675 .transfer_record_owner(
10676 &expected,
10677 owner_record_id.clone(),
10678 unix_time_ms(),
10679 )
10680 .is_err();
10681 }
10682 _ => transfer_failed = true,
10683 }
10684 if transfer_failed {
10685 let _ = ownership.mark_recovery_required(unix_time_ms());
10686 }
10687 } else {
10688 transfer_failed = true;
10689 }
10690 }
10691 if transfer_failed {
10692 if let Some(record) = self
10693 .session_records
10694 .lock()
10695 .unwrap_or_else(|poisoned| poisoned.into_inner())
10696 .records
10697 .get_mut(&owner_record_id)
10698 {
10699 record.state = ManagedSessionState::Unavailable;
10700 record.last_error = Some("environment-profile-unavailable".to_owned());
10701 record.updated_at_unix_ms = unix_time_ms();
10702 upserts.push(record.clone());
10703 }
10704 }
10705 }
10706 }
10707 if let Some(new_record_id) = rebind.clone() {
10708 if let Some(binding) = self
10709 .session_bindings
10710 .lock()
10711 .unwrap_or_else(|poisoned| poisoned.into_inner())
10712 .get_mut(&address.session.instance_id)
10713 {
10714 binding.record_id = Some(new_record_id);
10715 }
10716 }
10717 if upserts.is_empty() && removals.is_empty() {
10718 return;
10719 }
10720 if self.persist_state_locked().is_err() {
10721 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10722 let mut records = self
10723 .session_records
10724 .lock()
10725 .unwrap_or_else(|poisoned| poisoned.into_inner());
10726 records.records = original_records;
10727 *self
10728 .materializations
10729 .lock()
10730 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10731 if let Some(current) = records.records.get_mut(record_id) {
10732 current.state = ManagedSessionState::Unavailable;
10733 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10734 current.updated_at_unix_ms = unix_time_ms();
10735 upserts = vec![current.clone()];
10736 } else {
10737 upserts.clear();
10738 }
10739 removals.clear();
10740 drop(records);
10741 if let Some(binding) = self
10742 .session_bindings
10743 .lock()
10744 .unwrap_or_else(|poisoned| poisoned.into_inner())
10745 .get_mut(&address.session.instance_id)
10746 {
10747 binding.record_id = Some(record_id.clone());
10748 }
10749 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10750 }
10751 for record in upserts {
10752 self.publish_record(record);
10753 }
10754 for record_id in removals {
10755 self.publish(NodeEvent::SessionRecordRemoved { record_id });
10756 }
10757 }
10758
10759 fn snapshot(&self) -> NodeSnapshot {
10760 let control = self.handle.snapshot();
10761 let bindings = self
10762 .session_bindings
10763 .lock()
10764 .unwrap_or_else(|poisoned| poisoned.into_inner())
10765 .clone();
10766 let workspace_roots = self
10767 .workspaces
10768 .read()
10769 .unwrap_or_else(|poisoned| poisoned.into_inner())
10770 .clone();
10771 let environment_profiles = self
10772 .environment_profiles
10773 .read()
10774 .unwrap_or_else(|poisoned| poisoned.into_inner());
10775 let mut workspaces = workspace_roots
10776 .iter()
10777 .map(|(workspace_id, canonical_root)| {
10778 (
10779 workspace_id.clone(),
10780 WorkspaceSnapshot {
10781 workspace_id: workspace_id.clone(),
10782 canonical_root: opaque_windows_path(canonical_root.clone()),
10783 sessions: Vec::new(),
10784 worktree_service_mode: Some(
10785 self.worktree_service_modes
10786 .get(workspace_id)
10787 .copied()
10788 .unwrap_or(WorktreeServiceMode::Manual),
10789 ),
10790 managed_worktree_profiles: Some(WorktreeProfileInventory {
10791 profiles: self
10792 .managed_worktree_profiles
10793 .get(workspace_id)
10794 .into_iter()
10795 .flat_map(BTreeMap::values)
10796 .map(|profile| ManagedWorktreeProfileSummary {
10797 id: profile.profile_id().clone(),
10798 revision: profile.revision().clone(),
10799 retention: profile.retention(),
10800 })
10801 .collect(),
10802 }),
10803 },
10804 )
10805 })
10806 .collect::<BTreeMap<_, _>>();
10807 let mut agent_progress = Vec::new();
10808 for session in &control.sessions {
10809 let Some(binding) = bindings.get(&session.instance_id) else {
10810 continue;
10811 };
10812 if binding.generation != session.generation {
10813 continue;
10814 }
10815 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10816 let address = SessionAddress {
10817 workspace_id: binding.workspace_id.clone(),
10818 session: SessionKey {
10819 instance_id: session.instance_id,
10820 generation: session.generation,
10821 },
10822 };
10823 if let Some(progress) =
10824 agent_progress_from_provider_snapshot(address, &session.provider)
10825 {
10826 agent_progress.push(progress);
10827 }
10828 workspace.sessions.push(session.clone());
10829 }
10830 }
10831 NodeSnapshot {
10832 node_id: self.node_id.clone(),
10833 enabled_providers: self.enabled_providers.clone(),
10834 provider_runtime_statuses: {
10835 let updates = self
10836 .provider_runtime_status_updates
10837 .read()
10838 .unwrap_or_else(|poisoned| poisoned.into_inner());
10839 ProviderRuntimeStatuses::new(
10840 self.provider_runtime_statuses
10841 .iter()
10842 .filter(|status| !updates.contains_key(status.provider()))
10843 .cloned()
10844 .chain(updates.values().cloned()),
10845 )
10846 .expect("refreshed provider runtime inventory remains bounded and unique")
10847 },
10848 workspaces: workspaces.into_values().collect(),
10849 session_records: self
10850 .session_records
10851 .lock()
10852 .unwrap_or_else(|poisoned| poisoned.into_inner())
10853 .records
10854 .values()
10855 .cloned()
10856 .collect(),
10857 managed_worktrees: self.managed_worktrees
10858 .lock()
10859 .unwrap_or_else(|poisoned| poisoned.into_inner())
10860 .snapshots(),
10861 launch_inventory: Some(LaunchInventory {
10862 spawn_profiles: Some(
10863 self.spawn_profiles
10864 .iter()
10865 .filter_map(|profile| {
10866 resolved_spawn_profile_summary(profile, &environment_profiles)
10867 })
10868 .collect(),
10869 ),
10870 bundles: Some(
10871 self.bundle_catalog
10872 .read()
10873 .unwrap_or_else(|poisoned| poisoned.into_inner())
10874 .iter()
10875 .map(NodeBundle::receipt)
10876 .collect(),
10877 ),
10878 network_allowlists: {
10881 let catalog = self
10882 .network_allowlist_catalog
10883 .read()
10884 .unwrap_or_else(|poisoned| poisoned.into_inner());
10885 if catalog.is_empty() {
10886 None
10887 } else {
10888 Some(catalog.ids().cloned().collect())
10889 }
10890 },
10891 }),
10892 agent_progress,
10893 }
10894 }
10895
10896 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
10897 assert!(
10898 !matches!(
10899 &event,
10900 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
10901 ),
10902 "terminal frame and agent stream events must use their own dedicated live channel",
10903 );
10904 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10905 let sequence = history
10906 .last_sequence
10907 .checked_add(1)
10908 .expect("node event sequence exhausted");
10909 if history.events.len() == NODE_EVENT_HISTORY_MAX {
10910 if let Some(evicted) = history.events.pop_front() {
10911 history.replay_floor_sequence = evicted
10912 .sequence
10913 .checked_add(1)
10914 .expect("node replay floor sequence exhausted");
10915 history.removed_record_providers.remove(&evicted.sequence);
10916 }
10917 }
10918 match &event {
10919 NodeEvent::HarnessMcpReadCall { .. } => {
10920 panic!("transient harness MCP calls must not enter durable event history")
10921 }
10922 NodeEvent::SessionRecordUpserted { record } => {
10923 history
10924 .record_providers
10925 .insert(record.record_id.clone(), record.provider.clone());
10926 }
10927 NodeEvent::SessionRecordRemoved { record_id } => {
10928 if let Some(provider) = history.record_providers.remove(record_id) {
10929 history.removed_record_providers.insert(sequence, provider);
10930 }
10931 }
10932 NodeEvent::ManagedWorktreeUpserted { .. }
10933 | NodeEvent::ManagedWorktreeRemoved { .. }
10934 | NodeEvent::Control { .. }
10935 | NodeEvent::ControllerChanged { .. }
10936 | NodeEvent::SessionRecordHistorySummarized { .. }
10937 | NodeEvent::WorkspaceAdded { .. }
10938 | NodeEvent::WorkspaceRemoved { .. }
10939 | NodeEvent::ResyncRequired { .. }
10940 | NodeEvent::AgentStream { .. }
10941 | NodeEvent::TerminalFrame { .. } => {}
10942 }
10943 let envelope = NodeEventEnvelope { sequence, event };
10944 history.events.push_back(envelope.clone());
10945 history.last_sequence = sequence;
10946 let _ = self.event_tx.send(envelope.clone());
10947 drop(history);
10948 envelope
10949 }
10950
10951 fn publish_transient(&self, event: NodeEvent) -> bool {
10962 assert!(event.requires_harness_mcp_proxy_capability());
10963 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
10964 let envelope = NodeEventEnvelope { sequence: 0, event };
10965 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
10966 Ok(_) => true,
10967 Err(broadcast::error::SendError(undelivered)) => {
10968 if let Some(NodeEventEnvelope {
10969 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
10970 ..
10971 }) = undelivered.first() {
10972 tracing::warn!(
10978 call_id = call_id.as_str(),
10979 content_type = ?request.content_type,
10980 cause = "no-subscribers",
10981 "harness MCP proxy event could not be published",
10982 );
10983 }
10984 false
10985 }
10986 }
10987 }
10988
10989 fn publish_terminal_frames(&self) {
10990 let snapshot = self.handle.snapshot();
10991 let bindings = self
10992 .session_bindings
10993 .lock()
10994 .unwrap_or_else(|poisoned| poisoned.into_inner());
10995 let candidates = snapshot
10996 .sessions
10997 .iter()
10998 .filter_map(|session| {
10999 let binding = bindings.get(&session.instance_id)?;
11000 if binding.generation != session.generation {
11001 return None;
11002 }
11003 let frame = session.terminal_frame.clone()?;
11004 Some((
11005 SessionAddress {
11006 workspace_id: binding.workspace_id.clone(),
11007 session: SessionKey {
11008 instance_id: session.instance_id,
11009 generation: session.generation,
11010 },
11011 },
11012 frame,
11013 ))
11014 })
11015 .collect();
11016 drop(bindings);
11017 self.publish_terminal_frame_candidates(candidates);
11018 }
11019
11020 fn publish_terminal_frame_candidates(
11021 &self,
11022 candidates: Vec<(SessionAddress, TerminalFrame)>,
11023 ) {
11024 let mut watermarks = self
11025 .terminal_frame_watermarks
11026 .lock()
11027 .unwrap_or_else(|poisoned| poisoned.into_inner());
11028 let mut events = Vec::with_capacity(candidates.len());
11029 for (address, frame) in candidates {
11030 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11031 let advanced = watermarks
11032 .get(&address.session.instance_id)
11033 .map_or(true, |(current_address, sequence)| {
11034 current_address != &address || frame.sequence > *sequence
11035 });
11036 if !advanced {
11037 continue;
11038 }
11039 if is_first_frame {
11040 if self.controller_state().is_none() {
11054 tracing::warn!(
11055 node_id = %self.node_id,
11056 workspace_id = %address.workspace_id,
11057 session = ?address.session,
11058 terminal_size = ?frame.size,
11059 "session producing output with no controller attached",
11060 );
11061 } else {
11062 tracing::info!(
11063 node_id = %self.node_id,
11064 workspace_id = %address.workspace_id,
11065 session = ?address.session,
11066 terminal_size = ?frame.size,
11067 "session produced its first output",
11068 );
11069 }
11070 }
11071 watermarks.insert(
11072 address.session.instance_id,
11073 (address.clone(), frame.sequence),
11074 );
11075 if let Some((at, reported)) = self
11076 .pty_input_marks
11077 .lock()
11078 .unwrap_or_else(|poisoned| poisoned.into_inner())
11079 .get_mut(&address.session.instance_id)
11080 {
11081 if !*reported && at.elapsed() < Duration::from_secs(5) {
11083 *reported = true;
11084 tracing::debug!(target: "pty_lat", instance_id = address.session.instance_id.0, node_us = at.elapsed().as_micros() as u64, "pty input to frame published");
11085 }
11086 }
11087 events.push(NodeEvent::TerminalFrame { address, frame });
11088 }
11089 drop(watermarks);
11090 if events.is_empty() {
11091 return;
11092 }
11093 let mut history = self
11094 .history
11095 .lock()
11096 .unwrap_or_else(|poisoned| poisoned.into_inner());
11097 let mut envelopes = Vec::with_capacity(events.len());
11098 for event in events {
11099 let sequence = history
11100 .last_sequence
11101 .checked_add(1)
11102 .expect("node event sequence exhausted");
11103 history.last_sequence = sequence;
11104 envelopes.push(NodeEventEnvelope { sequence, event });
11105 }
11106 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11107 drop(history);
11108 }
11109
11110 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11119 let mut history = self
11120 .history
11121 .lock()
11122 .unwrap_or_else(|poisoned| poisoned.into_inner());
11123 let sequence = history
11124 .last_sequence
11125 .checked_add(1)
11126 .expect("node event sequence exhausted");
11127 history.last_sequence = sequence;
11128 drop(history);
11129 tracing::debug!(
11137 source_sequence = chunk.source_sequence,
11138 kind = agent_stream_chunk_kind_label(&chunk.kind),
11139 envelope_sequence = sequence,
11140 "publishing agent stream chunk",
11141 );
11142 let envelope = NodeEventEnvelope {
11143 sequence,
11144 event: NodeEvent::AgentStream { address, chunk },
11145 };
11146 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11147 }
11148
11149 fn current_sequence(&self) -> u64 {
11150 self.history
11151 .lock()
11152 .unwrap_or_else(|poisoned| poisoned.into_inner())
11153 .last_sequence
11154 }
11155
11156 fn controller_state(&self) -> Option<ControllerState> {
11157 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11158 let lease = controller.as_ref().copied()?;
11159 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11160 if remaining.is_zero() {
11161 *controller = None;
11162 return None;
11163 }
11164 Some(ControllerState {
11165 connection_id: lease.connection_id,
11166 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11167 })
11168 }
11169
11170 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11171 if role != ClientRole::Operator {
11172 tracing::warn!(
11173 node_id = %self.node_id,
11174 connection_id,
11175 "controller lease rejected: observer connections cannot acquire control",
11176 );
11177 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11178 }
11179 let lease_ms = if lease_ms == 0 {
11180 DEFAULT_CONTROLLER_LEASE_MS
11181 } else {
11182 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11183 };
11184 let now = Instant::now();
11185 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11186 if let Some(active) = controller.as_ref() {
11187 if active.connection_id != connection_id && active.expires_at > now {
11188 tracing::warn!(
11189 node_id = %self.node_id,
11190 connection_id,
11191 holder_connection_id = active.connection_id,
11192 "controller lease rejected: another operator holds it",
11193 );
11194 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11195 }
11196 }
11197 let is_new_attach = match controller.as_ref() {
11204 Some(active) => active.connection_id != connection_id,
11205 None => true,
11206 };
11207 *controller = Some(ControllerLease {
11208 connection_id,
11209 expires_at: now + Duration::from_millis(lease_ms),
11210 });
11211 drop(controller);
11212 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11213 if is_new_attach {
11214 tracing::info!(
11215 node_id = %self.node_id,
11216 connection_id,
11217 lease_ms,
11218 "controller lease acquired",
11219 );
11220 } else {
11221 tracing::debug!(
11222 node_id = %self.node_id,
11223 connection_id,
11224 lease_ms,
11225 "controller lease renewed",
11226 );
11227 }
11228 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11229 Ok(state)
11230 }
11231
11232 fn release_controller(&self, connection_id: u64) -> bool {
11233 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11234 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11235 if released {
11236 *controller = None;
11237 }
11238 drop(controller);
11239 if released {
11240 tracing::info!(
11241 node_id = %self.node_id,
11242 connection_id,
11243 "controller lease released",
11244 );
11245 self.publish(NodeEvent::ControllerChanged { controller: None });
11246 }
11247 released
11248 }
11249
11250 fn mark_spawn_provisional(&self, address: SessionAddress) {
11256 let id = address.session.instance_id;
11257 self.provisional_spawns
11258 .lock()
11259 .unwrap_or_else(|poisoned| poisoned.into_inner())
11260 .insert(id, (address, Instant::now()));
11261 tracing::info!(
11262 node_id = %self.node_id,
11263 instance_id = id.0,
11264 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11265 "draft spawn; waiting for HQ view",
11266 );
11267 }
11268
11269 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11270 let removed = self
11271 .provisional_spawns
11272 .lock()
11273 .unwrap_or_else(|poisoned| poisoned.into_inner())
11274 .remove(&instance_id);
11275 if removed.is_some() {
11276 tracing::info!(
11277 node_id = %self.node_id,
11278 instance_id = instance_id.0,
11279 "draft spawn claimed by HQ view",
11280 );
11281 }
11282 }
11283
11284 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11285 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11286 let mut map = self
11287 .provisional_spawns
11288 .lock()
11289 .unwrap_or_else(|poisoned| poisoned.into_inner());
11290 let due: Vec<AgentInstanceId> = map
11291 .iter()
11292 .filter(|(_, (_, at))| at.elapsed() >= grace)
11293 .map(|(id, _)| *id)
11294 .collect();
11295 due.into_iter()
11296 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11297 .collect()
11298 }
11299
11300 async fn reap_provisional_spawn(&self, address: SessionAddress) {
11301 let instance_id = address.session.instance_id;
11302 tracing::warn!(
11303 node_id = %self.node_id,
11304 instance_id = instance_id.0,
11305 workspace = %address.workspace_id,
11306 reason = "attach_never_arrived",
11307 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11308 "draft spawn never viewed; stopping PTY",
11309 );
11310 let _ = self.dispatch(ControlCommand::Stop {
11311 instance_id,
11312 force: true,
11313 });
11314 let _ = self.remove_session(&address).await;
11315 }
11316
11317 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11318 if role != ClientRole::Operator {
11319 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11320 }
11321 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11322 let now = Instant::now();
11323 match controller.as_ref().copied() {
11324 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11325 Some(active) if active.expires_at <= now => {
11326 *controller = None;
11327 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11328 }
11329 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11330 }
11331 }
11332
11333 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11334 if !self
11335 .workspaces
11336 .read()
11337 .unwrap_or_else(|poisoned| poisoned.into_inner())
11338 .contains_key(&address.workspace_id)
11339 {
11340 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11341 }
11342 let binding = self
11343 .session_bindings
11344 .lock()
11345 .unwrap_or_else(|poisoned| poisoned.into_inner())
11346 .get(&address.session.instance_id)
11347 .cloned()
11348 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11349 if binding.workspace_id != address.workspace_id {
11350 return Err(failure(
11351 NodeFailureCode::SessionWorkspaceMismatch,
11352 "session belongs to another workspace",
11353 ));
11354 }
11355 if binding.generation != address.session.generation {
11356 return Err(failure(
11357 NodeFailureCode::StaleGeneration,
11358 &format!(
11359 "stale session generation: expected {}, received {}",
11360 binding.generation.0,
11361 address.session.generation.0,
11362 ),
11363 ));
11364 }
11365 let snapshot = self.handle.snapshot();
11366 let Some(current) = snapshot
11367 .sessions
11368 .iter()
11369 .find(|item| item.instance_id == address.session.instance_id)
11370 else {
11371 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11372 };
11373 if current.generation != address.session.generation {
11374 return Err(failure(
11375 NodeFailureCode::StaleGeneration,
11376 &format!(
11377 "stale session generation: expected {}, received {}",
11378 current.generation.0,
11379 address.session.generation.0,
11380 ),
11381 ));
11382 }
11383 Ok(current.agent_id.clone())
11384 }
11385
11386 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11387 self.dispatch_envelope(self.prepare_command(command))
11388 }
11389
11390 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11391 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11392 CommandEnvelope {
11393 id: CommandId(id),
11394 command,
11395 }
11396 }
11397
11398 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11399 let command_id = envelope.id;
11400 self.handle
11401 .dispatch(envelope)
11402 .map_err(|error| match error {
11403 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11404 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11405 })?;
11406 Ok(command_id)
11407 }
11408
11409 fn record_rejected_command(
11418 &self,
11419 command_id: CommandId,
11420 message: String,
11421 unsupported_transport: bool,
11422 ) {
11423 let now = Instant::now();
11424 let mut reasons = self.rejected_command_reasons.lock()
11425 .unwrap_or_else(|poisoned| poisoned.into_inner());
11426 reasons.retain(|_, reason| reason.expires_at > now);
11427 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11428 if let Some(&oldest) = reasons.keys().next() {
11429 reasons.remove(&oldest);
11430 }
11431 }
11432 reasons.insert(
11433 command_id,
11434 RejectedCommandReason {
11435 message,
11436 unsupported_transport,
11437 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11438 },
11439 );
11440 }
11441
11442 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11449 let now = Instant::now();
11450 let mut reasons = self.rejected_command_reasons.lock()
11451 .unwrap_or_else(|poisoned| poisoned.into_inner());
11452 match reasons.remove(&command_id) {
11453 Some(reason) if reason.expires_at > now => Some(reason),
11454 _ => None,
11455 }
11456 }
11457
11458 async fn dispatch_bounded(
11459 &self,
11460 command: ControlCommand,
11461 timeout_duration: Duration,
11462 ) -> Result<CommandId, NodeFailure> {
11463 let deadline = Instant::now() + timeout_duration;
11464 loop {
11465 match self.dispatch(command.clone()) {
11466 Ok(command_id) => return Ok(command_id),
11467 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11468 sleep(Duration::from_millis(2)).await;
11469 }
11470 Err(error) => return Err(error),
11471 }
11472 }
11473 }
11474
11475 fn internal_session_snapshot(
11476 &self,
11477 address: &SessionAddress,
11478 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11479 self.validate_address(address)?;
11480 self.handle
11481 .snapshot()
11482 .sessions
11483 .clone()
11484 .into_iter()
11485 .find(|session| {
11486 session.instance_id == address.session.instance_id
11487 && session.generation == address.session.generation
11488 })
11489 .ok_or_else(|| {
11490 failure(
11491 NodeFailureCode::UnknownSession,
11492 "session snapshot is unavailable",
11493 )
11494 })
11495 }
11496
11497 fn require_acp_session(
11505 &self,
11506 address: &SessionAddress,
11507 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11508 let snapshot = self.internal_session_snapshot(address)?;
11509 if snapshot.transport != TransportKind::Acp {
11510 return Err(failure(
11511 NodeFailureCode::UnsupportedTransport,
11512 "this verb requires a session spawned over the ACP transport",
11513 ));
11514 }
11515 Ok(snapshot)
11516 }
11517
11518 async fn dispatch_history_bounded(
11519 &self,
11520 address: &SessionAddress,
11521 command: ControlCommand,
11522 operation: HistoryOperation,
11523 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11524 self.validate_address(address)?;
11525 let started_after = self.current_sequence();
11526 let command_id = self
11527 .dispatch_bounded(
11528 command,
11529 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11530 )
11531 .await?;
11532 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11533 let mut scan_after = started_after;
11534 let mut accepted_after = None;
11535 loop {
11536 let events = self
11537 .history
11538 .lock()
11539 .unwrap_or_else(|poisoned| poisoned.into_inner())
11540 .events
11541 .iter()
11542 .filter(|envelope| envelope.sequence > scan_after)
11543 .cloned()
11544 .collect::<Vec<_>>();
11545 for envelope in events {
11546 scan_after = scan_after.max(envelope.sequence);
11547 let NodeEvent::Control {
11548 address: event_address,
11549 event,
11550 } = envelope.event
11551 else {
11552 continue;
11553 };
11554 if event_address != *address {
11555 continue;
11556 }
11557 if event.command_id == Some(command_id) {
11558 match &event.event {
11559 ControlEventKind::CommandRejected { .. } => {
11560 return Err(failure(
11561 NodeFailureCode::InvalidRequest,
11562 "history request was rejected",
11563 ));
11564 }
11565 ControlEventKind::HistoryRequested {
11566 operation: requested,
11567 ..
11568 } if requested == &operation => {
11569 accepted_after = Some(envelope.sequence);
11570 continue;
11571 }
11572 _ => {}
11573 }
11574 }
11575 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11576 match (&operation, &event.event) {
11577 (
11578 HistoryOperation::Discover { .. },
11579 ControlEventKind::HistoryDiscovered { .. },
11580 )
11581 | (
11582 HistoryOperation::Load { .. },
11583 ControlEventKind::HistoryLoaded { .. },
11584 ) => return self.internal_session_snapshot(address),
11585 (_, ControlEventKind::HistoryFailed { .. }) => {
11586 return Err(failure(
11587 NodeFailureCode::BackendOperationFailed,
11588 "history operation failed",
11589 ));
11590 }
11591 _ => {}
11592 }
11593 }
11594 }
11595 if Instant::now() >= deadline {
11596 return Err(failure(
11597 NodeFailureCode::BackendBusy,
11598 if accepted_after.is_some() {
11599 "history operation did not settle before the bounded deadline"
11600 } else {
11601 "history operation was not accepted before the bounded deadline"
11602 },
11603 ));
11604 }
11605 sleep(Duration::from_millis(2)).await;
11606 }
11607 }
11608
11609 async fn discover_history(
11610 &self,
11611 address: &SessionAddress,
11612 limit: u16,
11613 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11614 let query = HistoryQuery {
11615 working_directory: Some(self.workspace_root(&address.workspace_id)?),
11616 limit,
11617 };
11618 query
11619 .validate()
11620 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11621 let snapshot = self
11622 .dispatch_history_bounded(
11623 address,
11624 ControlCommand::DiscoverHistory {
11625 instance_id: address.session.instance_id,
11626 query: query.clone(),
11627 },
11628 HistoryOperation::Discover { query },
11629 )
11630 .await?;
11631 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11632 return Err(failure(
11633 NodeFailureCode::BackendOperationFailed,
11634 "history discovery settled into an invalid state",
11635 ));
11636 }
11637 Ok(snapshot.history.candidates)
11638 }
11639
11640 async fn load_history(
11641 &self,
11642 address: &SessionAddress,
11643 candidate_id: String,
11644 ) -> Result<HistorySessionRecord, NodeFailure> {
11645 validate_candidate_id(&candidate_id)
11646 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11647 let snapshot = self
11648 .dispatch_history_bounded(
11649 address,
11650 ControlCommand::LoadHistory {
11651 instance_id: address.session.instance_id,
11652 candidate_id: candidate_id.clone(),
11653 },
11654 HistoryOperation::Load {
11655 candidate_id: candidate_id.clone(),
11656 },
11657 )
11658 .await?;
11659 if snapshot.history.pending.is_some()
11660 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11661 {
11662 return Err(failure(
11663 NodeFailureCode::BackendOperationFailed,
11664 "history load settled into an invalid state",
11665 ));
11666 }
11667 snapshot.history.loaded.ok_or_else(|| {
11668 failure(
11669 NodeFailureCode::BackendOperationFailed,
11670 "history load produced no bounded session",
11671 )
11672 })
11673 }
11674
11675 async fn export_context_pack_for_session_record(
11682 &self,
11683 record_id: &SessionRecordId,
11684 session: &SessionAddress,
11685 allow_clean_detachment: bool,
11686 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11687 let outcome = self
11693 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11694 .await;
11695 if let Err(failure) = &outcome {
11696 tracing::warn!(
11697 record = %record_id,
11698 code = ?failure.code,
11699 reason = %failure.message,
11700 "session-record context-pack export refused",
11701 );
11702 }
11703 outcome
11704 }
11705
11706 async fn export_context_pack_for_session_record_inner(
11707 &self,
11708 record_id: &SessionRecordId,
11709 session: &SessionAddress,
11710 allow_clean_detachment: bool,
11711 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11712 let step = |name: &'static str| move |failure: NodeFailure| {
11717 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11718 failure
11719 };
11720 let record = self.record(record_id).map_err(step("record-lookup"))?;
11721 let identity = match record.provider_session.clone() {
11737 Some(identity) => {
11738 if identity.key != ProviderSessionKey::SessionId {
11739 return Err(step("provider-identity-key")(failure(
11740 NodeFailureCode::BackendOperationFailed,
11741 "managed session provider identity cannot bind history exactly",
11742 )));
11743 }
11744 Some(identity)
11745 }
11746 None if record.state == ManagedSessionState::IdentityPending => {
11747 return Err(step("provider-session-identity")(failure(
11748 NodeFailureCode::BackendOperationFailed,
11749 "managed session has not yet observed its provider session identity",
11750 )));
11751 }
11752 None => None,
11753 };
11754 self.revalidate_session_record_context_export(
11755 &record,
11756 identity.as_ref(),
11757 session,
11758 allow_clean_detachment,
11759 ).map_err(step("revalidate-before-discovery"))?;
11760 let candidates = self
11761 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11762 .await
11763 .map_err(step("discover-history"))?;
11764 let candidate_id = select_history_candidate(
11769 &candidates,
11770 identity.as_ref().map(|identity| identity.id.as_str()),
11771 ).map_err(step("unique-candidate"))?;
11772 let loaded = self.load_history(session, candidate_id.clone()).await
11773 .map_err(step("load-history"))?;
11774 if let Some(identity) = identity.as_ref() {
11775 if loaded.session_id != identity.id {
11776 return Err(failure(
11777 NodeFailureCode::SessionRecordConflict,
11778 "loaded history identity does not match the managed session record",
11779 ));
11780 }
11781 }
11782 self.revalidate_session_record_context_export(
11783 &record,
11784 identity.as_ref(),
11785 session,
11786 allow_clean_detachment,
11787 )?;
11788 self.revalidate_loaded_history(session, &candidate_id, &loaded)
11789 .map_err(step("revalidate-loaded-history"))?;
11790 let pack = self.materialize_context_pack(session).await
11791 .map_err(step("materialize-context-pack"))?;
11792 self.commit_context_pack_for_session_record(
11793 &record,
11794 identity.as_ref(),
11795 session,
11796 &candidate_id,
11797 &loaded,
11798 pack,
11799 allow_clean_detachment,
11800 )
11801 }
11802
11803 async fn export_context_pack(
11804 &self,
11805 address: &SessionAddress,
11806 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11807 let pack = self.materialize_context_pack(address).await?;
11808 self.commit_context_pack(pack)
11809 }
11810
11811 async fn materialize_context_pack(
11812 &self,
11813 address: &SessionAddress,
11814 ) -> Result<NodeContextPack, NodeFailure> {
11815 let snapshot = self.internal_session_snapshot(address)?;
11816 if !context_pack_source_status_is_usable(&snapshot.status) {
11817 return Err(failure(
11818 NodeFailureCode::InvalidRequest,
11819 "context export requires a running or successfully exited source session",
11820 ));
11821 }
11822 if snapshot.history.pending.is_some()
11823 || snapshot.history.loaded_candidate_id.is_none()
11824 {
11825 return Err(failure(
11826 NodeFailureCode::InvalidRequest,
11827 "context export requires settled loaded history",
11828 ));
11829 }
11830 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11831 failure(
11832 NodeFailureCode::InvalidRequest,
11833 "context export requires settled loaded history",
11834 )
11835 })?;
11836 let source_provider = snapshot.agent_id.clone();
11837 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11838 let repository = self
11839 .context_pack_repository(&address.workspace_id)
11840 .await?;
11841 let current = self.internal_session_snapshot(address)?;
11842 if !context_pack_source_status_is_usable(¤t.status)
11843 || current.agent_id != source_provider
11844 || current.history.pending.is_some()
11845 || current.history.loaded_candidate_id != loaded_candidate_id
11846 || current.history.loaded.as_ref() != Some(&loaded)
11847 {
11848 return Err(failure(
11849 NodeFailureCode::ContextPackMaterializationFailed,
11850 "context source changed during bounded repository capture",
11851 ));
11852 }
11853 NodeContextPack::export_with_repository(
11854 ContextPackLineageReceipt {
11855 source_node_id: self.node_id.clone(),
11856 source_session: address.clone(),
11857 source_provider,
11858 },
11859 &loaded,
11860 Some(repository),
11861 )
11862 .map_err(|_| {
11863 failure(
11864 NodeFailureCode::ContextPackMaterializationFailed,
11865 "bounded context pack export failed",
11866 )
11867 })
11868 }
11869
11870 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
11876 let mut store = self
11877 .context_pack_store
11878 .lock()
11879 .unwrap_or_else(|poisoned| poisoned.into_inner());
11880 let Some(store) = store.as_mut() else {
11881 return Ok(());
11882 };
11883 store.commit(pack).map_err(|_| {
11884 failure(
11885 NodeFailureCode::ContextPackMaterializationFailed,
11886 "durable context pack commit failed",
11887 )
11888 })
11889 }
11890
11891 fn commit_context_pack(
11892 &self,
11893 pack: NodeContextPack,
11894 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11895 self.commit_context_pack_durably(&pack)?;
11896 self.context_catalog
11897 .write()
11898 .unwrap_or_else(|poisoned| poisoned.into_inner())
11899 .insert(pack)
11900 .map_err(|_| {
11901 failure(
11902 NodeFailureCode::ContextPackMaterializationFailed,
11903 "context pack catalog is full",
11904 )
11905 })
11906 }
11907
11908 fn commit_context_pack_for_session_record(
11909 &self,
11910 expected: &ManagedSessionRecord,
11911 identity: Option<&ProviderSessionIdentity>,
11912 session: &SessionAddress,
11913 candidate_id: &str,
11914 loaded: &HistorySessionRecord,
11915 pack: NodeContextPack,
11916 allow_clean_detachment: bool,
11917 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11918 let current_root = self.workspace_root(&expected.workspace_id)?;
11919 let _transaction = self
11920 .state_transaction
11921 .lock()
11922 .unwrap_or_else(|poisoned| poisoned.into_inner());
11923 let records = self
11924 .session_records
11925 .lock()
11926 .unwrap_or_else(|poisoned| poisoned.into_inner());
11927 let current = records.records.get(&expected.record_id).ok_or_else(|| {
11928 failure(
11929 NodeFailureCode::SessionRecordConflict,
11930 "managed session record disappeared during context export",
11931 )
11932 })?;
11933 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
11934 return Err(failure(
11935 NodeFailureCode::SessionRecordConflict,
11936 "managed session record changed during context export",
11937 ));
11938 }
11939 let bindings = self
11940 .session_bindings
11941 .lock()
11942 .unwrap_or_else(|poisoned| poisoned.into_inner());
11943 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
11944 failure(
11945 NodeFailureCode::SessionRecordConflict,
11946 "managed session runtime binding disappeared during context export",
11947 )
11948 })?;
11949 let runtime = self
11950 .handle
11951 .snapshot()
11952 .sessions
11953 .iter()
11954 .find(|snapshot| {
11955 snapshot.instance_id == session.session.instance_id
11956 && snapshot.generation == session.session.generation
11957 })
11958 .cloned()
11959 .ok_or_else(|| {
11960 failure(
11961 NodeFailureCode::SessionRecordConflict,
11962 "managed session runtime disappeared during context export",
11963 )
11964 })?;
11965 let receipt = pack.receipt();
11966 if !session_record_context_export_binding_is_exact(
11967 expected,
11968 current,
11969 identity,
11970 session,
11971 binding,
11972 &runtime.agent_id,
11973 ¤t_root,
11974 allow_clean_detachment,
11975 ) || !session_record_context_export_source_is_usable(
11976 current,
11977 &runtime.status,
11978 allow_clean_detachment,
11979 ) || runtime.history.pending.is_some()
11980 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
11981 || runtime.history.loaded.as_ref() != Some(loaded)
11982 || receipt.lineage.source_node_id != self.node_id
11983 || receipt.lineage.source_session != *session
11984 || receipt.lineage.source_provider != current.provider
11985 || receipt.source_message_count != loaded.message_count
11986 {
11987 return Err(failure(
11988 NodeFailureCode::SessionRecordConflict,
11989 "managed session source changed before context catalog commit",
11990 ));
11991 }
11992 self.commit_context_pack_durably(&pack)?;
11993 self.context_catalog
11994 .write()
11995 .unwrap_or_else(|poisoned| poisoned.into_inner())
11996 .insert(pack)
11997 .map_err(|_| {
11998 failure(
11999 NodeFailureCode::ContextPackMaterializationFailed,
12000 "context pack catalog is full",
12001 )
12002 })
12003 }
12004
12005 async fn context_pack_repository(
12006 &self,
12007 workspace_id: &WorkspaceId,
12008 ) -> Result<ContextPackRepository, NodeFailure> {
12009 let first = self
12010 .context_pack_repository_observation(workspace_id)
12011 .await?;
12012 let second = self
12013 .context_pack_repository_observation(workspace_id)
12014 .await?;
12015 stable_context_pack_repository(first, second)
12016 }
12017
12018 async fn context_pack_repository_observation(
12019 &self,
12020 workspace_id: &WorkspaceId,
12021 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12022 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12023 let head = if git.is_repository {
12024 let root = self.workspace_root(workspace_id)?;
12025 match resolve_base_commit_with_timeout(
12026 &root,
12027 "HEAD",
12028 GIT_COMMAND_TIMEOUT_MS,
12029 )
12030 .await
12031 {
12032 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12033 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12034 ContextPackRepositoryHead::Unborn
12035 }
12036 Err(_) => {
12037 return Err(failure(
12038 NodeFailureCode::ContextPackMaterializationFailed,
12039 "context pack HEAD observation failed",
12040 ));
12041 }
12042 }
12043 } else {
12044 ContextPackRepositoryHead::NotRepository
12045 };
12046 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12047 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12048 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12049 failure(
12050 NodeFailureCode::InvalidRepositoryPath,
12051 "context pack selected file path is invalid",
12052 )
12053 })?;
12054 match self.read_workspace_file(workspace_id.clone(), path).await {
12055 Ok(WorkspaceFileRead {
12056 content: WorkspaceFileContent::Utf8 { text, byte_len },
12057 ..
12058 }) => files.push(ContextPackRepositoryFileSource::utf8(
12059 selected_path,
12060 text,
12061 byte_len,
12062 )),
12063 Ok(WorkspaceFileRead {
12064 content: WorkspaceFileContent::NonUtf8 { byte_len },
12065 ..
12066 }) => files.push(ContextPackRepositoryFileSource::skipped(
12067 selected_path,
12068 ContextPackSelectedFileSkipReason::NonUtf8,
12069 Some(byte_len),
12070 )),
12071 Ok(WorkspaceFileRead {
12072 content: WorkspaceFileContent::TooLarge { .. },
12073 ..
12074 }) => files.push(ContextPackRepositoryFileSource::skipped(
12075 selected_path,
12076 ContextPackSelectedFileSkipReason::TooLarge,
12077 None,
12078 )),
12079 Err(error) => {
12080 let reason = if matches!(
12081 error.code,
12082 NodeFailureCode::InvalidRepositoryPath
12083 | NodeFailureCode::RepositoryFileNotRegular
12084 | NodeFailureCode::RepositoryPathUnsafe
12085 ) {
12086 ContextPackSelectedFileSkipReason::Unsafe
12087 } else {
12088 ContextPackSelectedFileSkipReason::Unavailable
12089 };
12090 files.push(ContextPackRepositoryFileSource::skipped(
12091 selected_path,
12092 reason,
12093 None,
12094 ));
12095 }
12096 }
12097 }
12098 Ok(ContextPackRepositoryObservation { head, git, files })
12099 }
12100
12101 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12102 let referenced_by_binding = self
12103 .session_bindings
12104 .lock()
12105 .unwrap_or_else(|poisoned| poisoned.into_inner())
12106 .values()
12107 .any(|binding| {
12108 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12109 });
12110 let referenced_by_record = self
12111 .session_records
12112 .lock()
12113 .unwrap_or_else(|poisoned| poisoned.into_inner())
12114 .records
12115 .values()
12116 .any(|record| record.context_id.as_ref() == Some(context_id));
12117 let referenced_by_materialization = self
12118 .materializations
12119 .lock()
12120 .unwrap_or_else(|poisoned| poisoned.into_inner())
12121 .values()
12122 .any(|ownership| {
12123 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12124 });
12125 if referenced_by_binding
12126 || referenced_by_record
12127 || referenced_by_materialization
12128 {
12129 return Err(failure(
12130 NodeFailureCode::ContextPackBusy,
12131 "context pack is still referenced",
12132 ));
12133 }
12134 let mut catalog = self.context_catalog
12135 .write()
12136 .unwrap_or_else(|poisoned| poisoned.into_inner());
12137 if catalog.get(context_id).is_none() {
12138 return Err(failure(
12139 NodeFailureCode::UnknownContextPack,
12140 "context pack is unavailable",
12141 ));
12142 }
12143 self.spawn_idempotency
12144 .lock()
12145 .unwrap_or_else(|poisoned| poisoned.into_inner())
12146 .entries
12147 .retain(|_, entry| !entry.value.references_context(context_id));
12148 let removed = catalog.remove(context_id);
12149 debug_assert!(removed.is_some(), "validated context pack remains present");
12150 Ok(())
12151 }
12152
12153 async fn dispatch_input_bounded(
12154 &self,
12155 address: &SessionAddress,
12156 action: InputAction,
12157 ) -> Result<(), NodeFailure> {
12158 let started_after = self.current_sequence();
12159 self.pty_input_marks
12160 .lock()
12161 .unwrap_or_else(|poisoned| poisoned.into_inner())
12162 .insert(address.session.instance_id, (Instant::now(), false));
12163 self.pty_input_wake.notify_one();
12164 let command_id = self
12165 .dispatch_bounded(
12166 ControlCommand::SendInput {
12167 instance_id: address.session.instance_id,
12168 action,
12169 },
12170 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12171 )
12172 .await?;
12173 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12177 let mut scan_after = started_after;
12178 let mut accepted_after = None;
12179 loop {
12180 let events = self
12181 .history
12182 .lock()
12183 .unwrap_or_else(|poisoned| poisoned.into_inner())
12184 .events
12185 .iter()
12186 .filter(|envelope| envelope.sequence > scan_after)
12187 .cloned()
12188 .collect::<Vec<_>>();
12189 for envelope in events {
12190 scan_after = scan_after.max(envelope.sequence);
12191 let NodeEvent::Control {
12192 address: event_address,
12193 event,
12194 } = envelope.event
12195 else {
12196 continue;
12197 };
12198 if event_address != *address {
12199 continue;
12200 }
12201 if event.command_id == Some(command_id) {
12202 match &event.event {
12203 ControlEventKind::CommandRejected { message } => {
12204 return Err(failure(NodeFailureCode::InvalidRequest, message));
12205 }
12206 ControlEventKind::InputRequested { .. } => {
12207 accepted_after = Some(envelope.sequence);
12208 continue;
12209 }
12210 _ => {}
12211 }
12212 }
12213 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12214 match &event.event {
12215 ControlEventKind::InputCompleted { .. } => return Ok(()),
12216 ControlEventKind::InputFailed { message, .. } => {
12217 return Err(failure(
12218 NodeFailureCode::BackendOperationFailed,
12219 message,
12220 ));
12221 }
12222 _ => {}
12223 }
12224 }
12225 }
12226 if Instant::now() >= deadline {
12227 return Err(failure(
12228 NodeFailureCode::BackendBusy,
12229 if accepted_after.is_some() {
12230 "input was accepted but did not settle before the bounded deadline"
12231 } else {
12232 "input was not accepted before the bounded deadline"
12233 },
12234 ));
12235 }
12236 sleep(Duration::from_millis(2)).await;
12237 }
12238 }
12239
12240 async fn dispatch_resize_bounded(
12241 &self,
12242 address: &SessionAddress,
12243 size: gate4agent_types::TerminalSize,
12244 ) -> Result<(), NodeFailure> {
12245 let started_after = self.current_sequence();
12246 let command_id = self
12247 .dispatch_bounded(
12248 ControlCommand::Resize {
12249 instance_id: address.session.instance_id,
12250 size,
12251 },
12252 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12253 )
12254 .await?;
12255 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12256 let mut scan_after = started_after;
12257 let mut accepted_after = None;
12258 loop {
12259 let events = self
12260 .history
12261 .lock()
12262 .unwrap_or_else(|poisoned| poisoned.into_inner())
12263 .events
12264 .iter()
12265 .filter(|envelope| envelope.sequence > scan_after)
12266 .cloned()
12267 .collect::<Vec<_>>();
12268 for envelope in events {
12269 scan_after = scan_after.max(envelope.sequence);
12270 let NodeEvent::Control {
12271 address: event_address,
12272 event,
12273 } = envelope.event
12274 else {
12275 continue;
12276 };
12277 if event_address != *address {
12278 continue;
12279 }
12280 if event.command_id == Some(command_id) {
12281 match &event.event {
12282 ControlEventKind::CommandRejected { message } => {
12283 return Err(failure(NodeFailureCode::InvalidRequest, message));
12284 }
12285 ControlEventKind::ResizeRequested {
12286 size: requested_size,
12287 ..
12288 } if *requested_size == size => {
12289 accepted_after = Some(envelope.sequence);
12290 continue;
12291 }
12292 _ => {}
12293 }
12294 }
12295 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12296 match &event.event {
12297 ControlEventKind::Resized { size: settled_size }
12298 if *settled_size == size =>
12299 {
12300 return Ok(());
12301 }
12302 ControlEventKind::ResizeFailed { message } => {
12303 return Err(failure(
12304 NodeFailureCode::BackendOperationFailed,
12305 message,
12306 ));
12307 }
12308 _ => {}
12309 }
12310 }
12311 }
12312 if Instant::now() >= deadline {
12313 return Err(failure(
12314 NodeFailureCode::BackendBusy,
12315 if accepted_after.is_some() {
12316 "resize was accepted but did not settle before the bounded deadline"
12317 } else {
12318 "resize was not accepted before the bounded deadline"
12319 },
12320 ));
12321 }
12322 sleep(Duration::from_millis(2)).await;
12323 }
12324 }
12325
12326 async fn spawn_session(
12327 &self,
12328 workspace_id: WorkspaceId,
12329 provider: AgentId,
12330 mode: SessionMode,
12331 terminal_size: gate4agent_types::TerminalSize,
12332 initial_prompt: Option<String>,
12333 ) -> Result<SessionAddress, NodeFailure> {
12334 if self.managed_worktrees.lock()
12335 .unwrap_or_else(|poisoned| poisoned.into_inner())
12336 .lease_for_workspace(&workspace_id)
12337 .is_some()
12338 {
12339 return Err(failure(
12340 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12341 "managed worktree targets may only be spawned through their lease request",
12342 ));
12343 }
12344 self
12345 .spawn_session_with_deadline(
12346 workspace_id,
12347 provider,
12348 mode,
12349 terminal_size,
12350 initial_prompt,
12351 None,
12352 None,
12353 None,
12354 None,
12355 None,
12356 SpawnRecordPolicy::ProviderIdentityOnly,
12357 None,
12358 &[],
12359 None,
12360 ApprovalLevel::default(),
12366 None,
12367 None,
12368 )
12369 .await
12370 .map(|(session, _runtime_policy)| session)
12371 }
12372
12373 async fn spawn_session_with_deadline(
12374 &self,
12375 workspace_id: WorkspaceId,
12376 provider: AgentId,
12377 mode: SessionMode,
12378 terminal_size: gate4agent_types::TerminalSize,
12379 initial_prompt: Option<String>,
12380 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12381 bundle: Option<ResolvedBundleReceipt>,
12382 context: Option<ResolvedContextPackReceipt>,
12383 managed_authority: Option<ManagedWorktreeLeaseId>,
12384 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12385 record_policy: SpawnRecordPolicy,
12386 deadline: Option<Instant>,
12387 required_capabilities: &[ProviderRuntimeCapability],
12388 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12389 approval_level: ApprovalLevel,
12390 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12391 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12397 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12398 let reserved_lease = self.managed_worktrees.lock()
12399 .unwrap_or_else(|poisoned| poisoned.into_inner())
12400 .lease_for_workspace(&workspace_id);
12401 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12402 (Some(expected), Some(provided)) if expected == provided => {}
12403 (None, None) => {}
12404 _ => {
12405 return Err(failure(
12406 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12407 "spawn lacks exact managed worktree lease authority",
12408 ));
12409 }
12410 }
12411 self.ensure_workspace_environment_profile_compatible(
12415 &workspace_id,
12416 environment_profile.as_ref(),
12417 )?;
12418 let runtime_requirement = match (mode, initial_prompt.is_some()) {
12419 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12420 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12421 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12422 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12423 };
12424 let transport = match mode {
12430 SessionMode::Pty => TransportKind::Pty,
12431 SessionMode::Inline => TransportKind::Pipe,
12432 SessionMode::Acp => TransportKind::Acp,
12433 };
12434 let pipe_transport = self.provider_supports_pipe_transport(&provider);
12435 let acp_transport = self.provider_supports_acp_transport(&provider);
12436 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12437 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12438 NodeFailureCode::UnsupportedSpawnCapability,
12439 "pre-admitted provider runtime no longer satisfies spawn requirements",
12440 ))?;
12441 runtime_policy
12442 } else if let Some(deadline) = deadline {
12443 let remaining = spawn_deadline_remaining(deadline)?;
12444 timeout(
12445 remaining,
12446 self.admit_provider_runtime(&provider, runtime_requirement),
12447 )
12448 .await
12449 .map_err(|_| {
12450 failure(
12451 NodeFailureCode::SpawnDeadlineExceeded,
12452 "provider admission exceeded the spawn deadline",
12453 )
12454 })??
12455 } else {
12456 self.admit_provider_runtime(&provider, runtime_requirement).await?
12457 };
12458 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12459 let runtime_policy =
12471 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12472 if required_capabilities
12473 .iter()
12474 .any(|capability| !runtime_policy.admits(*capability))
12475 {
12476 return Err(failure(
12477 NodeFailureCode::UnsupportedSpawnCapability,
12478 "provider runtime does not admit a required spawn capability",
12479 ));
12480 }
12481 if let Some(deadline) = deadline {
12482 spawn_deadline_remaining(deadline)?;
12483 }
12484 self.ensure_binding_capacity()?;
12485 let working_directory = self.workspace_root(&workspace_id)?;
12486 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12487 let session = SessionKey {
12488 instance_id,
12489 generation: SessionGeneration(1),
12490 };
12491 let address = SessionAddress {
12492 workspace_id: workspace_id.clone(),
12493 session,
12494 };
12495 tracing::info!(
12503 node_id = %self.node_id,
12504 workspace_id = %workspace_id,
12505 provider = %provider,
12506 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12507 session = ?address.session,
12508 "session spawn request accepted",
12509 );
12510 let mut prepared_spawn = SpawnPreparer::prepare(
12511 self,
12512 SpawnPrepareRequest {
12513 instance_id,
12514 address: &address,
12515 provider: &provider,
12516 mode,
12517 environment_profile: environment_profile.as_ref(),
12518 bundle: bundle.as_ref(),
12519 context: context.as_ref(),
12520 managed_authority: managed_authority.as_ref(),
12521 harness_mcp,
12522 approval_level,
12523 network_allowlist: network_allowlist.as_ref(),
12524 browser_profile_id,
12525 },
12526 )?;
12527 let materialization_id = prepared_spawn.materialization_id.take();
12528 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12529 let record_id = self.bind_spawn_session_with_materialization(
12530 &address,
12531 provider.clone(),
12532 mode,
12533 runtime_policy,
12534 record_policy,
12535 environment_profile,
12536 bundle,
12537 context,
12538 materialization_id,
12539 )?;
12540 prepared_spawn.retain_session_guards();
12541 let agent_id = provider;
12545 let register_command_id = match self.dispatch_bounded(
12546 ControlCommand::Register {
12547 instance_id,
12548 agent_id,
12549 transport,
12550 },
12551 dispatch_timeout,
12552 )
12553 .await {
12554 Ok(command_id) => command_id,
12555 Err(error) => {
12556 if let Some(binding) = self.remove_binding(&address) {
12557 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12558 }
12559 if let Some(record_id) = record_id.as_ref() {
12560 self.discard_record(record_id)?;
12561 }
12562 return Err(spawn_dispatch_error(error, deadline));
12563 }
12564 };
12565 let start_timeout = match spawn_dispatch_timeout(deadline) {
12566 Ok(timeout) => timeout,
12567 Err(error) => {
12568 let recovery = self
12569 .rollback_spawn(
12570 &address,
12571 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12572 )
12573 .await;
12574 return match recovery {
12575 Ok(()) => {
12576 if let Some(record_id) = record_id.as_ref() {
12577 self.discard_record(record_id)?;
12578 }
12579 Err(error)
12580 }
12581 Err(recovery_error) => {
12582 if let Some(record_id) = record_id.as_ref() {
12583 self.mark_record_error(
12584 record_id,
12585 "spawn deadline elapsed and registration recovery failed",
12586 )?;
12587 }
12588 Err(recovery_error)
12589 }
12590 };
12591 }
12592 };
12593 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12594 if let Err(recovery_error) = self
12595 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12596 .await
12597 {
12598 return Err(recovery_error);
12599 }
12600 if let Some(record_id) = record_id.as_ref() {
12601 self.discard_record(record_id)?;
12602 }
12603 return Err(start_error);
12604 }
12605 let start_result = self
12606 .dispatch_bounded(
12607 ControlCommand::Start {
12608 instance_id,
12609 runtime_policy,
12610 request: StartRequest {
12611 working_directory,
12612 terminal_size,
12613 initial_prompt,
12614 session_options: None,
12615 approval_level,
12616 },
12617 },
12618 start_timeout,
12619 )
12620 .await;
12621 let start_command_id = match start_result {
12622 Ok(command_id) => command_id,
12623 Err(start_error) => {
12624 let start_error = spawn_dispatch_error(start_error, deadline);
12625 let recovery = self
12626 .rollback_spawn(
12627 &address,
12628 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12629 )
12630 .await;
12631 return match recovery {
12632 Ok(()) => {
12633 if let Some(record_id) = record_id.as_ref() {
12634 self.discard_record(record_id)?;
12635 }
12636 Err(start_error)
12637 }
12638 Err(recovery_error) => {
12639 let diagnostic = format!(
12640 "start failed and registration recovery failed: {}",
12641 recovery_error.message,
12642 );
12643 if let Some(record_id) = record_id.as_ref() {
12644 self.mark_record_error(record_id, &diagnostic)?;
12645 }
12646 Err(failure(recovery_error.code, &diagnostic))
12647 }
12648 };
12649 }
12650 };
12651
12652 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12653 loop {
12654 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12663 .or_else(|| self.take_rejected_command_reason(start_command_id))
12664 {
12665 self.rollback_spawn(
12666 &address,
12667 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12668 ).await.map_err(|error| {
12669 failure(
12670 error.code,
12671 &format!(
12672 "spawn was rejected ({}) and registration recovery failed: {}",
12673 reason.message, error.message
12674 ),
12675 )
12676 })?;
12677 if let Some(record_id) = record_id.as_ref() {
12678 self.discard_record(record_id)?;
12679 }
12680 let code = if reason.unsupported_transport {
12681 NodeFailureCode::UnsupportedTransport
12682 } else {
12683 NodeFailureCode::BackendOperationFailed
12684 };
12685 return Err(failure(code, &reason.message));
12686 }
12687 if let Some(current) = self
12688 .handle
12689 .snapshot()
12690 .sessions
12691 .iter()
12692 .find(|current| current.instance_id == instance_id)
12693 {
12694 if current.generation != session.generation {
12695 let divergence = failure(
12696 NodeFailureCode::StaleGeneration,
12697 "spawn generation diverged",
12698 );
12699 return match self
12700 .rollback_spawn(
12701 &address,
12702 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12703 )
12704 .await
12705 {
12706 Ok(()) => {
12707 if let Some(record_id) = record_id.as_ref() {
12708 self.discard_record(record_id)?;
12709 }
12710 Err(divergence)
12711 }
12712 Err(recovery_error) => {
12713 if let Some(record_id) = record_id.as_ref() {
12714 self.mark_record_error(
12715 record_id,
12716 "spawn generation diverged and recovery failed",
12717 )?;
12718 }
12719 Err(recovery_error)
12720 }
12721 };
12722 }
12723 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12724 prepared_spawn.retain_launch_guards();
12725 return Ok((address, runtime_policy));
12726 }
12727 }
12728 if Instant::now() >= commit_deadline {
12729 self.rollback_spawn(
12730 &address,
12731 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12732 ).await.map_err(|error| {
12733 failure(
12734 error.code,
12735 &format!(
12736 "start did not commit and registration recovery failed: {}",
12737 error.message
12738 ),
12739 )
12740 })?;
12741 if let Some(record_id) = record_id.as_ref() {
12742 self.discard_record(record_id)?;
12743 }
12744 return Err(failure(
12745 if deadline.is_some() {
12746 NodeFailureCode::SpawnDeadlineExceeded
12747 } else {
12748 NodeFailureCode::BackendBusy
12749 },
12750 "start did not commit before the bounded deadline; registration was removed",
12751 ));
12752 }
12753 sleep(Duration::from_millis(2)).await;
12754 }
12755 }
12756
12757 fn effective_spawn_runtime_policy(
12758 &self,
12759 runtime_policy: ProviderRuntimePolicy,
12760 ) -> ProviderRuntimePolicy {
12761 #[cfg(feature = "fixture")]
12762 let runtime_policy = if self.fixture_semantic_hook_policy {
12763 ProviderRuntimePolicy::new(true, true, true, true, false, true)
12764 .expect("monitoring Hook fixture policy is internally valid")
12765 } else {
12766 runtime_policy
12767 };
12768 runtime_policy
12769 }
12770
12771 fn verify_harness_mcp_before_start(
12772 &self,
12773 prepared: Option<&PreparedHarnessMcpSpawn>,
12774 ) -> Result<(), NodeFailure> {
12775 prepared
12776 .map(PreparedHarnessMcpSpawn::verify_helper_program)
12777 .transpose()
12778 .map(|_| ())
12779 .map_err(harness_mcp_failure)
12780 }
12781
12782 async fn rollback_spawn(
12783 &self,
12784 address: &SessionAddress,
12785 commit_timeout: Duration,
12786 ) -> Result<(), NodeFailure> {
12787 self.dispatch_bounded(
12788 ControlCommand::Remove {
12789 instance_id: address.session.instance_id,
12790 },
12791 commit_timeout,
12792 )
12793 .await?;
12794 self.wait_until_removed(address, commit_timeout).await?;
12795 if let Some(binding) = self.remove_binding(address) {
12796 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
12797 }
12798 Ok(())
12799 }
12800
12801 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12802 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12803 self.dispatch_bounded(
12804 ControlCommand::Remove {
12805 instance_id: address.session.instance_id,
12806 },
12807 commit_timeout,
12808 )
12809 .await?;
12810 self.wait_until_removed(address, commit_timeout).await?;
12811 let removed = self.remove_binding(address);
12812 if let Some(binding) = removed.as_ref() {
12813 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
12814 }
12815 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12816 let snapshot = self.managed_worktrees.lock()
12817 .unwrap_or_else(|poisoned| poisoned.into_inner())
12818 .release_session(
12819 &lease_id,
12820 self.incarnation_id,
12821 address.session.instance_id,
12822 address.session.generation,
12823 unix_time_ms(),
12824 );
12825 if self.persist_state().is_err() {
12826 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12827 }
12828 if let Some(snapshot) = snapshot {
12829 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12830 }
12831 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12832 }
12833 Ok(())
12834 }
12835
12836 async fn wait_until_removed(
12882 &self,
12883 address: &SessionAddress,
12884 commit_timeout: Duration,
12885 ) -> Result<(), NodeFailure> {
12886 let deadline = Instant::now() + commit_timeout;
12887 let mut next_dispatch = Instant::now();
12888 loop {
12889 let snapshot = self.handle.snapshot();
12890 let status = snapshot
12891 .sessions
12892 .iter()
12893 .find(|session| session.instance_id == address.session.instance_id)
12894 .map(|session| session.status.clone());
12895 let Some(status) = status else {
12896 return Ok(());
12897 };
12898 let now = Instant::now();
12899 if now >= deadline {
12900 return Err(failure(
12901 NodeFailureCode::BackendBusy,
12902 "remove did not commit before the bounded deadline; session binding was retained",
12903 ));
12904 }
12905 if status.allows_remove() && now >= next_dispatch {
12906 let _ = self.dispatch(ControlCommand::Remove {
12907 instance_id: address.session.instance_id,
12908 });
12909 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
12910 }
12911 sleep(Duration::from_millis(2)).await;
12912 }
12913 }
12914
12915 fn resync(&self, after_sequence: u64) -> NodeResponse {
12916 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12917 let event_sequence = history.last_sequence;
12918 let oldest_available_sequence = history.replay_floor_sequence;
12919 let events = history
12920 .events
12921 .iter()
12922 .filter(|event| event.sequence > after_sequence)
12923 .cloned()
12924 .collect();
12925 drop(history);
12926 NodeResponse::Resync {
12927 event_sequence,
12928 oldest_available_sequence,
12929 snapshot: self.snapshot(),
12930 events,
12931 }
12932 }
12933}
12934
12935fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
12940 match kind {
12941 AgentStreamChunkKindV1::Text { .. } => "text",
12942 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
12943 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
12944 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
12945 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
12946 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
12947 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
12948 }
12949}
12950
12951async fn accept_connections(
12965 endpoint: &str,
12966 call_home: Option<std::net::SocketAddr>,
12967 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
12968 shared: Arc<NodeShared>,
12969) -> Result<(), NodeServerError> {
12970 let mut connections = JoinSet::new();
12971 let result = match call_home {
12972 None => accept_connections_inner(endpoint, shared, &mut connections).await,
12973 Some(relay) => {
12974 let listener =
12975 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
12976 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
12977 tokio::pin!(listener);
12978 tokio::pin!(dialler);
12979 tokio::select! {
12980 result = &mut listener => result,
12981 result = &mut dialler => result,
12982 }
12983 }
12984 };
12985 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
12986 while !connections.is_empty() {
12987 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
12988 if remaining.is_zero()
12989 || timeout(remaining, connections.join_next()).await.is_err()
12990 {
12991 break;
12992 }
12993 }
12994 connections.abort_all();
12995 while connections.join_next().await.is_some() {}
12996 result
12997}
12998
12999const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13003
13004async fn call_home_loop(
13018 relay: std::net::SocketAddr,
13019 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13020 shared: Arc<NodeShared>,
13021) -> Result<(), NodeServerError> {
13022 let mut failures = 0_usize;
13023 loop {
13024 if shared.shutdown.load(Ordering::Acquire) {
13025 return Ok(());
13026 }
13027 let permit = tokio::select! {
13028 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13029 permit.map_err(|_| NodeServerError::PreauthClosed)?
13030 }
13031 _ = shared.shutdown_notify.notified() => return Ok(()),
13032 };
13033 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13034 Ok(()) => {
13035 failures = 0;
13039 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed");
13040 }
13041 Err(error) => {
13042 failures = failures.saturating_add(1);
13043 tracing::warn!(
13044 node_id = %shared.node_id,
13045 %relay,
13046 cause = %error,
13047 attempt = failures,
13048 "call-home connection failed",
13049 );
13050 let backoff = CALL_HOME_BACKOFFS_MS
13051 [failures.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)];
13052 tokio::select! {
13053 _ = sleep(Duration::from_millis(backoff)) => {}
13054 _ = shared.shutdown_notify.notified() => return Ok(()),
13055 }
13056 }
13057 }
13058 }
13059}
13060
13061async fn dial_relay_and_serve(
13063 relay: std::net::SocketAddr,
13064 identity: Option<&hatchery_enroll::NodeIdentity>,
13065 shared: Arc<NodeShared>,
13066 preauth_permit: OwnedSemaphorePermit,
13067) -> Result<(), NodeServerError> {
13068 let mut stream = tokio::net::TcpStream::connect(relay)
13069 .await
13070 .map_err(NodeServerError::CallHomeTransport)?;
13071 let _ = stream.set_nodelay(true);
13075 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13076 .await
13077 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13078 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13079 let mut session_token = None;
13080 if let Some(identity) = identity {
13081 let mut magic = [0_u8; 4];
13085 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13086 .await
13087 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13088 .map_err(NodeServerError::CallHomeTransport)?;
13089 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13090 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13091 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13092 .await
13093 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13094 .map_err(NodeServerError::CallHomeTransport)?;
13095 let (answer, token) = identity
13096 .answer_challenge(shared.node_id.as_str(), &frame)
13097 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13098 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13099 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13100 session_token = Some(token);
13101 }
13102 }
13103 serve_connection(stream, shared, session_token, preauth_permit).await
13104}
13105
13106async fn accept_connections_inner(
13107 endpoint: &str,
13108 shared: Arc<NodeShared>,
13109 connections: &mut JoinSet<Result<(), NodeServerError>>,
13110) -> Result<(), NodeServerError> {
13111 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13112 loop {
13113 while connections.try_join_next().is_some() {}
13114 if shared.shutdown.load(Ordering::Acquire) {
13115 return Ok(());
13116 }
13117 let preauth_permit = tokio::select! {
13118 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13119 permit.map_err(|_| NodeServerError::PreauthClosed)?
13120 }
13121 _ = shared.shutdown_notify.notified() => return Ok(()),
13122 };
13123 let server = tokio::select! {
13124 result = listener.accept() => result?,
13125 _ = shared.shutdown_notify.notified() => return Ok(()),
13126 };
13127 let connection_shared = Arc::clone(&shared);
13128 connections.spawn(async move {
13129 serve_connection(server, connection_shared, None, preauth_permit).await
13130 });
13131 if shared.shutdown.load(Ordering::Acquire) {
13132 return Ok(());
13133 }
13134 }
13135}
13136
13137async fn serve_connection<S>(
13145 mut pipe: S,
13146 shared: Arc<NodeShared>,
13147 session_token: Option<String>,
13148 preauth_permit: OwnedSemaphorePermit,
13149) -> Result<(), NodeServerError>
13150where
13151 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13152{
13153 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13155 let hello_frame = timeout(
13156 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13157 read_json_frame_limited_body_timeout(
13158 &mut pipe,
13159 MAX_NODE_HELLO_FRAME_BYTES,
13160 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13161 ),
13162 )
13163 .await
13164 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13165 let ClientFrame::Hello(hello) = hello_frame else {
13166 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13167 };
13168 if hello.build_stamp != BUILD_STAMP {
13169 return Err(NodeServerError::Handshake(format!(
13170 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13171 hello.build_stamp,
13172 )));
13173 }
13174 let compatibility = match hello.compatibility.as_ref() {
13175 Some(offer) => {
13176 let mut selected = node_compatibility_support(&shared)?
13177 .negotiate(offer)
13178 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13179 project_negotiated_provider_ids(&mut selected);
13180 Some(selected)
13181 }
13182 None => None,
13183 };
13184 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13185 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13186 (Some(offer), Some(selected)) => negotiated_auth_proof(
13187 auth_token,
13188 AuthDirection::Server,
13189 hello.role,
13190 &hello.client_nonce,
13191 &server_nonce,
13192 offer,
13193 selected,
13194 ),
13195 (None, None) => auth_proof(
13196 auth_token,
13197 AuthDirection::Server,
13198 hello.role,
13199 &hello.client_nonce,
13200 &server_nonce,
13201 ),
13202 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13203 }
13204 .map_err(NodeServerError::Authentication)?;
13205 write_json_frame_limited(
13206 &mut pipe,
13207 &ServerFrame::Challenge(ServerChallenge {
13208 build_stamp: BUILD_STAMP.to_owned(),
13209 server_nonce,
13210 server_proof,
13211 compatibility: compatibility.clone(),
13212 }),
13213 MAX_NODE_HELLO_FRAME_BYTES,
13214 )
13215 .await?;
13216 let authentication = timeout(
13217 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13218 read_json_frame_limited_body_timeout(
13219 &mut pipe,
13220 MAX_NODE_HELLO_FRAME_BYTES,
13221 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13222 ),
13223 )
13224 .await
13225 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13226 let ClientFrame::Authenticate(authentication) = authentication else {
13227 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13228 };
13229 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13230 (Some(offer), Some(selected)) => negotiated_auth_proof(
13231 auth_token,
13232 AuthDirection::Client,
13233 hello.role,
13234 &hello.client_nonce,
13235 &server_nonce,
13236 offer,
13237 selected,
13238 ),
13239 (None, None) => auth_proof(
13240 auth_token,
13241 AuthDirection::Client,
13242 hello.role,
13243 &hello.client_nonce,
13244 &server_nonce,
13245 ),
13246 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13247 }
13248 .map_err(NodeServerError::Authentication)?;
13249 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13250 return Err(NodeServerError::Handshake("access denied".to_owned()));
13251 }
13252 let selected_capabilities = compatibility
13253 .as_ref()
13254 .map(|selected| selected.capabilities.clone())
13255 .unwrap_or_default();
13256 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13257 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13258 });
13259 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13260 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13261 });
13262 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13263 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13264 });
13265 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13266 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13267 });
13268 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13269 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13270 });
13271 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13272 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13273 }) && selected_capabilities.iter().any(|capability| {
13274 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13275 });
13276 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13277 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13278 });
13279 let include_session_bundles = selected_capabilities.iter().any(|capability| {
13280 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13281 });
13282 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13283 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13284 });
13285 let include_agent_progress = selected_capabilities.iter().any(|capability| {
13286 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13287 });
13288 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13289 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13290 });
13291 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13292 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13293 });
13294 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13295 .try_acquire_owned()
13296 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13297 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13298 let _release_guard = ControllerReleaseGuard {
13299 shared: Arc::clone(&shared),
13300 connection_id,
13301 };
13302 drop(preauth_permit);
13303 let _authenticated_permit = authenticated_permit;
13304 let mut event_rx = shared.event_tx.subscribe();
13305 let mut terminal_event_rx = terminal_event_subscription(
13306 &shared,
13307 include_terminal_frame_events,
13308 );
13309 let mut agent_stream_event_rx = agent_stream_event_subscription(
13310 &shared,
13311 include_agent_stream_events,
13312 );
13313 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13314 &shared,
13315 include_harness_mcp_proxy,
13316 );
13317 write_json_frame(
13318 &mut pipe,
13319 &ServerFrame::Hello(NodeHello {
13320 build_stamp: BUILD_STAMP.to_owned(),
13321 incarnation_id: shared.incarnation_id,
13322 connection_id,
13323 role: hello.role,
13324 event_sequence: shared.current_sequence(),
13325 controller: shared.controller_state(),
13326 snapshot: snapshot_for_wire(
13327 &shared,
13328 include_provider_runtime_status,
13329 include_open_provider_ids,
13330 include_spawn_profiles,
13331 include_managed_worktrees,
13332 include_child_environment_profiles,
13333 include_session_bundles,
13334 include_history_context_packs,
13335 include_agent_progress,
13336 include_session_task_correlation,
13337 ),
13338 compatibility,
13339 }),
13340 )
13341 .await?;
13342 let (mut reader, mut writer) = tokio::io::split(pipe);
13343 let (frame_tx, mut frame_rx) = mpsc::channel(16);
13344 let reader_task = tokio::spawn(async move {
13345 loop {
13346 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13347 &mut reader,
13348 MAX_NODE_CLIENT_FRAME_BYTES,
13349 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13350 )
13351 .await;
13352 let terminal = frame.is_err();
13353 if frame_tx.send(frame).await.is_err() || terminal {
13354 break;
13355 }
13356 }
13357 });
13358 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13359 let mut pending_events = Vec::new();
13360 let mut resync_required = false;
13361 let mut discard_events_through = 0_u64;
13362 loop {
13363 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13368 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13369 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13370 for _ in 0..durable_drain_budget {
13371 match event_rx.try_recv() {
13372 Ok(event) => queue_connection_event(
13373 &mut pending_events,
13374 event,
13375 discard_events_through,
13376 ),
13377 Err(broadcast::error::TryRecvError::Empty) => break,
13378 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13379 resync_required = true;
13380 break;
13381 }
13382 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13383 }
13384 }
13385 if let Some(receiver) = terminal_event_rx.as_mut() {
13386 match receiver.try_recv() {
13387 Ok(events) => queue_connection_event_batch(
13388 &mut pending_events,
13389 &events,
13390 discard_events_through,
13391 ),
13392 Err(broadcast::error::TryRecvError::Empty) => {}
13393 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13394 resync_required = true;
13395 }
13396 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13397 }
13398 }
13399 if let Some(receiver) = agent_stream_event_rx.as_mut() {
13400 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13410 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13411 for _ in 0..agent_stream_drain_budget {
13412 match receiver.try_recv() {
13413 Ok(events) => queue_connection_event_batch(
13414 &mut pending_events,
13415 &events,
13416 discard_events_through,
13417 ),
13418 Err(broadcast::error::TryRecvError::Empty) => break,
13419 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13420 tracing::warn!(
13421 connection_id,
13422 dropped,
13423 "agent stream event subscription lagged; forcing a resync",
13424 );
13425 resync_required = true;
13426 break;
13427 }
13428 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13429 }
13430 }
13431 }
13432 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13433 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13439 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13440 for _ in 0..harness_mcp_proxy_drain_budget {
13441 match receiver.try_recv() {
13442 Ok(events) => queue_connection_event_batch(
13443 &mut pending_events,
13444 &events,
13445 discard_events_through,
13446 ),
13447 Err(broadcast::error::TryRecvError::Empty) => break,
13448 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13449 tracing::warn!(
13450 connection_id,
13451 dropped,
13452 "harness MCP proxy event subscription lagged; forcing a resync",
13453 );
13454 resync_required = true;
13455 break;
13456 }
13457 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13458 }
13459 }
13460 }
13461 if resync_required {
13462 let event = resync_required_event(&shared);
13463 apply_connection_resync_watermark(
13464 &mut pending_events,
13465 &mut discard_events_through,
13466 event.sequence,
13467 );
13468 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13469 resync_required = false;
13470 continue;
13471 }
13472 if !pending_events.is_empty() {
13473 pending_events.sort_unstable_by_key(|event| event.sequence);
13474 let burst_len = connection_event_burst_len(pending_events.len());
13475 shared
13476 .connection_events_sent
13477 .fetch_add(burst_len as u64, Ordering::Relaxed);
13478 for event in pending_events.drain(..burst_len) {
13479 let event = if include_managed_worktrees {
13480 Some(event)
13481 } else {
13482 project_event_without_managed_worktrees(event)
13483 };
13484 let event = event.and_then(|event| if include_open_provider_ids {
13485 Some(event)
13486 } else {
13487 project_event_legacy_provider_ids(&shared, event)
13488 });
13489 let event = event.map(|event| if include_child_environment_profiles {
13490 event
13491 } else {
13492 project_event_without_child_environment_profile(event)
13493 });
13494 let event = event.map(|event| if include_session_bundles {
13495 event
13496 } else {
13497 project_event_without_session_bundle(event)
13498 });
13499 let event = event.map(|event| {
13500 project_event_history_for_wire(event, include_history_context_packs)
13501 });
13502 let event = event.map(|event| if include_history_context_packs {
13503 event
13504 } else {
13505 project_event_without_context_pack(event)
13506 });
13507 let event = event.map(|event| if include_session_task_correlation {
13508 event
13509 } else {
13510 project_event_without_session_task_binding(event)
13511 });
13512 let exact_harness_controller = hello.role == ClientRole::Operator
13513 && shared.controller_state().is_some_and(|controller| {
13514 controller.connection_id == connection_id
13515 });
13516 let event = event.and_then(|event| {
13517 if include_harness_mcp_proxy && exact_harness_controller {
13518 Some(event)
13519 } else {
13520 project_event_without_harness_mcp_proxy(event)
13521 }
13522 });
13523 if let Some(event) = event {
13524 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13525 }
13526 }
13527 }
13528 tokio::select! {
13529 biased;
13530 frame = frame_rx.recv() => {
13531 let frame = match frame {
13532 Some(frame) => frame,
13533 None => break,
13534 };
13535 let frame = match frame {
13536 Ok(frame) => frame,
13537 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13538 Err(error) => return Err(error.into()),
13539 };
13540 let ClientFrame::Request(request) = frame else {
13541 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13542 };
13543 #[cfg(feature = "fixture")]
13544 let probe_spawn_managed_worktree_v2 = matches!(
13545 &request.request,
13546 NodeRequest::SpawnManagedWorktreeV2 { .. }
13547 );
13548 let requires_open_provider_ids =
13549 request_requires_open_provider_ids(&shared, &request.request);
13550 let requires_child_environment_profile =
13551 request_requires_child_environment_profile(&shared, &request.request);
13552 let requires_session_bundle =
13553 request_requires_session_bundle(&shared, &request.request);
13554 let requires_history_context_pack =
13555 request_requires_history_context_pack(&shared, &request.request);
13556 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13557 ResponseEnvelope {
13558 request_id: request.request_id,
13559 result: Err(failure(
13560 NodeFailureCode::UnsupportedCapability,
13561 "open provider IDs were not negotiated",
13562 )),
13563 }
13564 } else if requires_child_environment_profile
13565 && !include_child_environment_profiles
13566 {
13567 ResponseEnvelope {
13568 request_id: request.request_id,
13569 result: Err(failure(
13570 NodeFailureCode::UnsupportedCapability,
13571 "child environment profiles were not negotiated",
13572 )),
13573 }
13574 } else if requires_session_bundle && !include_session_bundles {
13575 ResponseEnvelope {
13576 request_id: request.request_id,
13577 result: Err(failure(
13578 NodeFailureCode::UnsupportedCapability,
13579 "session bundles were not negotiated",
13580 )),
13581 }
13582 } else if requires_history_context_pack && !include_history_context_packs {
13583 ResponseEnvelope {
13584 request_id: request.request_id,
13585 result: Err(failure(
13586 NodeFailureCode::UnsupportedCapability,
13587 "history context packs were not negotiated",
13588 )),
13589 }
13590 } else if request_uses_unnegotiated_capability(
13591 &request.request,
13592 &selected_capabilities,
13593 )
13594 {
13595 ResponseEnvelope {
13596 request_id: request.request_id,
13597 result: Err(failure(
13598 NodeFailureCode::UnsupportedCapability,
13599 "request capability was not negotiated",
13600 )),
13601 }
13602 } else {
13603 process_request(&shared, connection_id, hello.role, request).await
13604 };
13605 #[cfg(feature = "fixture")]
13606 if probe_spawn_managed_worktree_v2 {
13607 shared
13608 .fixture_spawn_managed_worktree_v2_failure_probe
13609 .record(reply.result.as_ref().err().map(|failure| failure.code));
13610 }
13611 if !include_provider_runtime_status {
13612 clear_response_provider_runtime_status(&mut reply);
13613 }
13614 if !include_managed_worktrees {
13615 project_response_without_managed_worktrees(&mut reply);
13616 }
13617 if !include_open_provider_ids {
13618 project_response_legacy_provider_ids(&shared, &mut reply);
13619 }
13620 if !include_child_environment_profiles {
13621 project_response_without_child_environment_profile(&mut reply);
13622 }
13623 if !include_session_bundles {
13624 project_response_without_session_bundle(&mut reply);
13625 }
13626 project_response_history_for_wire(
13627 &mut reply,
13628 include_history_context_packs,
13629 );
13630 if !include_history_context_packs {
13631 project_response_without_context_pack(&mut reply);
13632 }
13633 if !include_agent_progress {
13634 project_response_without_agent_progress(&mut reply);
13635 }
13636 if !include_session_task_correlation {
13637 project_response_without_session_task_binding(&mut reply);
13638 }
13639 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13640 }
13641 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13642 event = event_rx.recv() => {
13643 match event {
13644 Ok(event) => queue_connection_event(
13645 &mut pending_events,
13646 event,
13647 discard_events_through,
13648 ),
13649 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13650 Err(broadcast::error::RecvError::Closed) => break,
13651 }
13652 }
13653 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13654 match events {
13655 Ok(events) => queue_connection_event_batch(
13656 &mut pending_events,
13657 &events,
13658 discard_events_through,
13659 ),
13660 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13661 Err(broadcast::error::RecvError::Closed) => break,
13662 }
13663 }
13664 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13665 match events {
13666 Ok(events) => queue_connection_event_batch(
13667 &mut pending_events,
13668 &events,
13669 discard_events_through,
13670 ),
13671 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13672 Err(broadcast::error::RecvError::Closed) => break,
13673 }
13674 }
13675 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13676 match events {
13677 Ok(events) => queue_connection_event_batch(
13678 &mut pending_events,
13679 &events,
13680 discard_events_through,
13681 ),
13682 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13683 Err(broadcast::error::RecvError::Closed) => break,
13684 }
13685 }
13686 _ = shared.shutdown_notify.notified() => break,
13687 }
13688 }
13689 Ok(())
13690}
13691
13692async fn receive_terminal_event_batch(
13693 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13694) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13695 match receiver {
13696 Some(receiver) => receiver.recv().await,
13697 None => std::future::pending().await,
13698 }
13699}
13700
13701fn terminal_event_subscription(
13702 shared: &NodeShared,
13703 enabled: bool,
13704) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13705 enabled.then(|| shared.terminal_event_tx.subscribe())
13706}
13707
13708fn agent_stream_event_subscription(
13712 shared: &NodeShared,
13713 enabled: bool,
13714) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13715 enabled.then(|| shared.agent_stream_event_tx.subscribe())
13716}
13717
13718fn harness_mcp_proxy_event_subscription(
13723 shared: &NodeShared,
13724 enabled: bool,
13725) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13726 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13727}
13728
13729fn queue_connection_event(
13730 pending: &mut Vec<NodeEventEnvelope>,
13731 event: NodeEventEnvelope,
13732 discard_through: u64,
13733) {
13734 if event.sequence <= discard_through
13735 && !event.event.requires_harness_mcp_proxy_capability()
13736 {
13737 return;
13738 }
13739 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13740 if let Some(index) = pending.iter().position(|current| {
13741 matches!(
13742 ¤t.event,
13743 NodeEvent::TerminalFrame {
13744 address: current_address,
13745 ..
13746 } if current_address == address
13747 )
13748 }) {
13749 pending.remove(index);
13750 }
13751 }
13752 pending.push(event);
13753}
13754
13755fn project_event_without_harness_mcp_proxy(
13756 envelope: NodeEventEnvelope,
13757) -> Option<NodeEventEnvelope> {
13758 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13759}
13760
13761fn queue_connection_event_batch(
13762 pending: &mut Vec<NodeEventEnvelope>,
13763 events: &[NodeEventEnvelope],
13764 discard_through: u64,
13765) {
13766 for event in events {
13767 queue_connection_event(pending, event.clone(), discard_through);
13768 }
13769}
13770
13771fn apply_connection_resync_watermark(
13772 pending: &mut Vec<NodeEventEnvelope>,
13773 discard_through: &mut u64,
13774 marker_sequence: u64,
13775) {
13776 *discard_through = (*discard_through).max(marker_sequence);
13777 pending.retain(|event| event.sequence > *discard_through);
13778}
13779
13780fn connection_event_burst_len(pending_len: usize) -> usize {
13781 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13782}
13783
13784fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13785 let history = shared
13786 .history
13787 .lock()
13788 .unwrap_or_else(|poisoned| poisoned.into_inner());
13789 let sequence = history.last_sequence;
13790 let oldest_available_sequence = history.replay_floor_sequence;
13791 NodeEventEnvelope {
13792 sequence,
13793 event: NodeEvent::ResyncRequired {
13794 oldest_available_sequence,
13795 },
13796 }
13797}
13798
13799fn node_compatibility_support(
13800 shared: &NodeShared,
13801) -> Result<NodeCompatibilitySupport, NodeServerError> {
13802 let mut support = node_compatibility_support_for_manifest(
13803 &shared.provider_contracts,
13804 &shared.provider_adapter_contracts,
13805 )?;
13806 if shared
13807 .delivery_store
13808 .lock()
13809 .unwrap_or_else(|poisoned| poisoned.into_inner())
13810 .is_some()
13811 {
13812 support.capabilities.push(
13813 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13814 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13815 );
13816 }
13817 if shared.harness_mcp_registry.is_some() {
13818 support.capabilities.push(
13819 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
13820 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13821 );
13822 }
13823 Ok(support)
13824}
13825
13826fn node_compatibility_support_for_manifest(
13827 provider_contracts: &[ProviderContractSupport],
13828 provider_adapter_contracts: &[ProviderAdapterContractSupport],
13829) -> Result<NodeCompatibilitySupport, NodeServerError> {
13830 Ok(NodeCompatibilitySupport {
13831 build_stamp: BUILD_STAMP.to_owned(),
13832 capabilities: baseline_capabilities()?,
13833 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
13834 path_semantics: platform::path_semantics(),
13835 local_transport: platform::local_transport(),
13836 state_schema: StateSchemaSupport {
13837 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
13838 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13839 },
13840 provider_contracts: provider_contracts.to_vec(),
13841 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
13842 })
13843}
13844
13845fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
13846 let capabilities = [
13847 NODE_COMPATIBILITY_METADATA_CAPABILITY,
13848 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
13849 NODE_REPOSITORY_PATH_CAPABILITY,
13850 NODE_WORKSPACE_FILE_READ_CAPABILITY,
13851 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
13852 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
13853 NODE_GIT_READ_CAPABILITY,
13854 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
13855 NODE_PROVIDER_ID_OPEN_CAPABILITY,
13856 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
13857 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
13858 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
13859 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
13860 NODE_ACP_CONTROL_CAPABILITY,
13861 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
13862 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
13863 NODE_WORKTREE_SELECTION_CAPABILITY,
13864 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
13865 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
13866 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
13867 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
13868 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
13869 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
13870 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
13871 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
13872 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
13873 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
13874 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
13875 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
13876 ]
13877 .into_iter()
13878 .map(|capability| {
13879 CapabilityId::new(capability)
13880 .map_err(|error| NodeServerError::Handshake(error.to_string()))
13881 })
13882 .collect::<Result<Vec<_>, _>>()?;
13883 #[cfg(windows)]
13884 let capabilities = {
13885 let mut capabilities = capabilities;
13886 capabilities.push(
13887 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
13888 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13889 );
13890 capabilities
13891 };
13892 Ok(capabilities)
13893}
13894
13895fn request_uses_unnegotiated_capability(
13896 request: &NodeRequest,
13897 selected_capabilities: &[CapabilityId],
13898) -> bool {
13899 let selected = |required: &str| {
13900 selected_capabilities
13901 .iter()
13902 .any(|capability| capability.as_str() == required)
13903 };
13904 request
13905 .required_capability()
13906 .is_some_and(|required| !selected(required))
13907 || (request.requires_worktree_selection_capability()
13908 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
13909 || (request.requires_spawn_spec_defaults_overrides_capability()
13910 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
13911 || (request.requires_spawn_profile_revision_capability()
13912 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
13913 || (request.requires_child_environment_profile_capability()
13914 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
13915 || (request.requires_session_bundle_materialization_capability()
13916 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
13917 || (request.requires_history_context_pack_capability()
13918 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
13919}
13920
13921fn snapshot_for_wire(
13922 shared: &NodeShared,
13923 include_provider_runtime_status: bool,
13924 include_open_provider_ids: bool,
13925 include_spawn_profiles: bool,
13926 include_managed_worktrees: bool,
13927 include_child_environment_profiles: bool,
13928 include_session_bundles: bool,
13929 include_history_context_packs: bool,
13930 include_agent_progress: bool,
13931 include_session_task_correlation: bool,
13932) -> NodeSnapshot {
13933 let mut snapshot = shared.snapshot();
13934 if !include_provider_runtime_status {
13935 snapshot.provider_runtime_statuses.clear();
13936 }
13937 if !include_open_provider_ids {
13938 project_snapshot_legacy_provider_ids(&mut snapshot);
13939 }
13940 if !include_managed_worktrees {
13941 snapshot.managed_worktrees.clear();
13942 for workspace in &mut snapshot.workspaces {
13943 workspace.managed_worktree_profiles = None;
13944 }
13945 }
13946 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
13947 if !include_spawn_profiles {
13948 inventory.spawn_profiles = None;
13949 }
13950 if !include_session_bundles {
13951 inventory.bundles = None;
13952 }
13953 if inventory.spawn_profiles.is_none()
13954 && inventory.bundles.is_none()
13955 && inventory.network_allowlists.is_none()
13956 {
13957 snapshot.launch_inventory = None;
13958 }
13959 }
13960 if !include_child_environment_profiles {
13961 clear_snapshot_child_environment_profiles(&mut snapshot);
13962 }
13963 if !include_session_bundles {
13964 clear_snapshot_session_bundles(&mut snapshot);
13965 }
13966 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
13967 if !include_history_context_packs {
13968 clear_snapshot_context_packs(&mut snapshot);
13969 }
13970 if !include_agent_progress {
13971 snapshot.agent_progress.clear();
13972 }
13973 if !include_session_task_correlation {
13974 clear_snapshot_session_task_bindings(&mut snapshot);
13975 }
13976 snapshot
13977}
13978
13979fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
13980 for record in &mut snapshot.session_records {
13981 record.task_binding = None;
13982 }
13983}
13984
13985fn project_event_without_session_task_binding(
13986 mut envelope: NodeEventEnvelope,
13987) -> NodeEventEnvelope {
13988 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
13989 record.task_binding = None;
13990 }
13991 envelope
13992}
13993
13994fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
13995 let Ok(response) = reply.result.as_mut() else { return };
13996 match response {
13997 NodeResponse::Snapshot { snapshot, .. } => {
13998 clear_snapshot_session_task_bindings(snapshot);
13999 }
14000 NodeResponse::Resync { snapshot, events, .. } => {
14001 clear_snapshot_session_task_bindings(snapshot);
14002 for event in events {
14003 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14004 record.task_binding = None;
14005 }
14006 }
14007 }
14008 NodeResponse::SessionRecordUpdated { record }
14009 | NodeResponse::ProviderSessionIndexed { record }
14010 | NodeResponse::NativeSessionIndexed { record, .. }
14011 | NodeResponse::SessionRecordResumed { record, .. } => {
14012 record.task_binding = None;
14013 }
14014 _ => {}
14015 }
14016}
14017
14018fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14019 let Ok(response) = reply.result.as_mut() else { return };
14020 match response {
14021 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14022 snapshot.agent_progress.clear();
14023 }
14024 _ => {}
14025 }
14026}
14027
14028fn project_event_without_managed_worktrees(
14029 mut envelope: NodeEventEnvelope,
14030) -> Option<NodeEventEnvelope> {
14031 if matches!(
14032 envelope.event,
14033 NodeEvent::ManagedWorktreeUpserted { .. }
14034 | NodeEvent::ManagedWorktreeRemoved { .. }
14035 ) {
14036 return None;
14037 }
14038 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14039 workspace.managed_worktree_profiles = None;
14040 }
14041 Some(envelope)
14042}
14043
14044fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14045 if matches!(
14046 reply.result,
14047 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14048 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14049 ) {
14050 reply.result = Err(failure(
14051 NodeFailureCode::UnsupportedCapability,
14052 "managed worktree capability was not negotiated",
14053 ));
14054 return;
14055 }
14056 let Ok(response) = reply.result.as_mut() else { return };
14057 match response {
14058 NodeResponse::Snapshot { snapshot, .. } => {
14059 snapshot.managed_worktrees.clear();
14060 for workspace in &mut snapshot.workspaces {
14061 workspace.managed_worktree_profiles = None;
14062 }
14063 }
14064 NodeResponse::Resync { snapshot, events, .. } => {
14065 snapshot.managed_worktrees.clear();
14066 for workspace in &mut snapshot.workspaces {
14067 workspace.managed_worktree_profiles = None;
14068 }
14069 *events = std::mem::take(events)
14070 .into_iter()
14071 .filter_map(project_event_without_managed_worktrees)
14072 .collect();
14073 }
14074 NodeResponse::WorkspaceRegistered { workspace }
14075 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14076 | NodeResponse::WorktreeCreated { workspace, .. } => {
14077 workspace.managed_worktree_profiles = None;
14078 }
14079 NodeResponse::WorkspaceInspected { inspection } => {
14080 inspection.git.managed_worktree = None;
14081 }
14082 _ => {}
14083 }
14084}
14085
14086fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14087 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14088 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14089 });
14090 if includes_open_provider_ids {
14091 return;
14092 }
14093 compatibility
14094 .provider_contracts
14095 .retain(|contract| provider_id_is_legacy(&contract.provider));
14096 compatibility
14097 .provider_adapter_contracts
14098 .retain(|contract| provider_id_is_legacy(&contract.provider));
14099}
14100
14101fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14102 if let Some(spec) = match request {
14103 NodeRequest::SpawnSpec { spec }
14104 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14105 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14106 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14107 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14108 _ => None,
14109 } {
14110 return shared
14111 .resolve_spawn_spec(spec)
14112 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14113 }
14114 request_requires_open_provider_ids_with(
14115 request,
14116 |session| shared.validate_address(session).ok(),
14117 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14118 )
14119}
14120
14121fn request_requires_child_environment_profile(
14122 shared: &NodeShared,
14123 request: &NodeRequest,
14124) -> bool {
14125 if let Some(spec) = match request {
14126 NodeRequest::SpawnSpec { spec }
14127 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14128 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14129 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14130 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14131 _ => None,
14132 } {
14133 return shared
14134 .resolve_spawn_spec(spec)
14135 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14136 }
14137 match request {
14138 NodeRequest::Resume { session, .. }
14139 | NodeRequest::Prompt { session, .. }
14140 | NodeRequest::Paste { session, .. }
14141 | NodeRequest::Input { session, .. }
14142 | NodeRequest::TerminalBytes { session, .. }
14143 | NodeRequest::TerminalControl { session, .. }
14144 | NodeRequest::Resize { session, .. }
14145 | NodeRequest::Interrupt { session }
14146 | NodeRequest::Stop { session, .. }
14147 | NodeRequest::Remove { session }
14148 | NodeRequest::ResolveInteraction { session, .. }
14149 | NodeRequest::SetSessionMode { session, .. }
14150 | NodeRequest::SetSessionConfigOption { session, .. }
14151 | NodeRequest::SetSessionModel { session, .. }
14152 | NodeRequest::DiscoverHistory { session, .. }
14153 | NodeRequest::LoadHistory { session, .. }
14154 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14155 | NodeRequest::ExportContextPack { session } => shared
14156 .session_bindings
14157 .lock()
14158 .unwrap_or_else(|poisoned| poisoned.into_inner())
14159 .get(&session.session.instance_id)
14160 .filter(|binding| {
14161 binding.workspace_id == session.workspace_id
14162 && binding.generation == session.session.generation
14163 })
14164 .map_or(true, |binding| binding.environment_profile.is_some()),
14165 NodeRequest::RenameSessionRecord { record_id, .. }
14166 | NodeRequest::SetSessionTask { record_id, .. }
14167 | NodeRequest::ResumeSessionRecord { record_id, .. }
14168 | NodeRequest::ForgetSessionRecord { record_id } => shared
14169 .record(record_id)
14170 .map_or(true, |record| record.environment_profile.is_some()),
14171 NodeRequest::ArmHarnessMcpReservation { .. }
14172 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14173 | NodeRequest::ActivateHarnessMcpReservation { .. }
14174 | NodeRequest::AbortHarnessMcpReservation { .. }
14175 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14176 | NodeRequest::RejectHarnessMcpCall { .. }
14177 | NodeRequest::Snapshot
14178 | NodeRequest::Resync { .. }
14179 | NodeRequest::BeginDeliveryStage { .. }
14180 | NodeRequest::PutDeliveryBlobChunk { .. }
14181 | NodeRequest::CommitDeliveryStage { .. }
14182 | NodeRequest::AbortDeliveryStage { .. }
14183 | NodeRequest::BrowseHostDirectories { .. }
14184 | NodeRequest::InspectWorkspace { .. }
14185 | NodeRequest::ReadWorkspaceFile { .. }
14186 | NodeRequest::WriteWorkspaceFile { .. }
14187 | NodeRequest::CreateWorkspaceFile { .. }
14188 | NodeRequest::CreateWorkspaceDirectory { .. }
14189 | NodeRequest::ReadGitHistory { .. }
14190 | NodeRequest::ReadGitDiff { .. }
14191 | NodeRequest::AcquireController { .. }
14192 | NodeRequest::ReleaseController
14193 | NodeRequest::Spawn { .. }
14194 | NodeRequest::SpawnSpec { .. }
14195 | NodeRequest::RegisterWorkspace { .. }
14196 | NodeRequest::CreateStandaloneWorkspace { .. }
14197 | NodeRequest::UnregisterWorkspace { .. }
14198 | NodeRequest::CreateWorktree { .. }
14199 | NodeRequest::RemoveWorktree { .. }
14200 | NodeRequest::SpawnManagedWorktree { .. }
14201 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14202 | NodeRequest::CleanupManagedWorktree { .. }
14203 | NodeRequest::IndexProviderSession { .. }
14204 | NodeRequest::IndexNativeSession { .. }
14205 | NodeRequest::CatalogNativeSessions { .. }
14206 | NodeRequest::PageNativeSessions { .. }
14207 | NodeRequest::PreviewNativeSession { .. }
14208 | NodeRequest::PreviewSessionRecord { .. }
14209 | NodeRequest::ForgetContextPack { .. }
14210 | NodeRequest::ResolveDurableContextPack { .. }
14211 | NodeRequest::ReadContextPack { .. }
14212 | NodeRequest::Shutdown => false,
14213 }
14214}
14215
14216fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14217 if let Some(spec) = match request {
14218 NodeRequest::SpawnSpec { spec }
14219 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14220 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14221 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14222 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14223 _ => None,
14224 } {
14225 return shared
14226 .resolve_spawn_spec(spec)
14227 .is_ok_and(|resolved| resolved.bundle_id.is_some());
14228 }
14229 match request {
14230 NodeRequest::Resume { session, .. }
14231 | NodeRequest::Prompt { session, .. }
14232 | NodeRequest::Paste { session, .. }
14233 | NodeRequest::Input { session, .. }
14234 | NodeRequest::TerminalBytes { session, .. }
14235 | NodeRequest::TerminalControl { session, .. }
14236 | NodeRequest::Resize { session, .. }
14237 | NodeRequest::Interrupt { session }
14238 | NodeRequest::Stop { session, .. }
14239 | NodeRequest::Remove { session }
14240 | NodeRequest::ResolveInteraction { session, .. }
14241 | NodeRequest::SetSessionMode { session, .. }
14242 | NodeRequest::SetSessionConfigOption { session, .. }
14243 | NodeRequest::SetSessionModel { session, .. }
14244 | NodeRequest::DiscoverHistory { session, .. }
14245 | NodeRequest::LoadHistory { session, .. }
14246 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14247 | NodeRequest::ExportContextPack { session } => shared
14248 .session_bindings
14249 .lock()
14250 .unwrap_or_else(|poisoned| poisoned.into_inner())
14251 .get(&session.session.instance_id)
14252 .filter(|binding| {
14253 binding.workspace_id == session.workspace_id
14254 && binding.generation == session.session.generation
14255 })
14256 .map_or(true, |binding| binding.bundle.is_some()),
14257 NodeRequest::RenameSessionRecord { record_id, .. }
14258 | NodeRequest::SetSessionTask { record_id, .. }
14259 | NodeRequest::ResumeSessionRecord { record_id, .. }
14260 | NodeRequest::ForgetSessionRecord { record_id } => shared
14261 .record(record_id)
14262 .map_or(true, |record| record.bundle.is_some()),
14263 NodeRequest::ArmHarnessMcpReservation { .. }
14264 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14265 | NodeRequest::ActivateHarnessMcpReservation { .. }
14266 | NodeRequest::AbortHarnessMcpReservation { .. }
14267 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14268 | NodeRequest::RejectHarnessMcpCall { .. }
14269 | NodeRequest::Snapshot
14270 | NodeRequest::Resync { .. }
14271 | NodeRequest::BeginDeliveryStage { .. }
14272 | NodeRequest::PutDeliveryBlobChunk { .. }
14273 | NodeRequest::CommitDeliveryStage { .. }
14274 | NodeRequest::AbortDeliveryStage { .. }
14275 | NodeRequest::BrowseHostDirectories { .. }
14276 | NodeRequest::InspectWorkspace { .. }
14277 | NodeRequest::ReadWorkspaceFile { .. }
14278 | NodeRequest::WriteWorkspaceFile { .. }
14279 | NodeRequest::CreateWorkspaceFile { .. }
14280 | NodeRequest::CreateWorkspaceDirectory { .. }
14281 | NodeRequest::ReadGitHistory { .. }
14282 | NodeRequest::ReadGitDiff { .. }
14283 | NodeRequest::AcquireController { .. }
14284 | NodeRequest::ReleaseController
14285 | NodeRequest::Spawn { .. }
14286 | NodeRequest::SpawnSpec { .. }
14287 | NodeRequest::RegisterWorkspace { .. }
14288 | NodeRequest::CreateStandaloneWorkspace { .. }
14289 | NodeRequest::UnregisterWorkspace { .. }
14290 | NodeRequest::CreateWorktree { .. }
14291 | NodeRequest::RemoveWorktree { .. }
14292 | NodeRequest::SpawnManagedWorktree { .. }
14293 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14294 | NodeRequest::CleanupManagedWorktree { .. }
14295 | NodeRequest::IndexProviderSession { .. }
14296 | NodeRequest::IndexNativeSession { .. }
14297 | NodeRequest::CatalogNativeSessions { .. }
14298 | NodeRequest::PageNativeSessions { .. }
14299 | NodeRequest::PreviewNativeSession { .. }
14300 | NodeRequest::PreviewSessionRecord { .. }
14301 | NodeRequest::ForgetContextPack { .. }
14302 | NodeRequest::ResolveDurableContextPack { .. }
14303 | NodeRequest::ReadContextPack { .. }
14304 | NodeRequest::Shutdown => false,
14305 }
14306}
14307
14308fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14309 if request.requires_history_context_pack_capability() {
14310 return true;
14311 }
14312 if let Some(spec) = match request {
14313 NodeRequest::SpawnSpec { spec }
14314 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14315 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14316 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14317 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14318 _ => None,
14319 } {
14320 return shared
14321 .resolve_spawn_spec(spec)
14322 .map_or(true, |resolved| resolved.context_id.is_some());
14323 }
14324 match request {
14325 NodeRequest::Resume { session, .. }
14326 | NodeRequest::Prompt { session, .. }
14327 | NodeRequest::Paste { session, .. }
14328 | NodeRequest::Input { session, .. }
14329 | NodeRequest::TerminalBytes { session, .. }
14330 | NodeRequest::TerminalControl { session, .. }
14331 | NodeRequest::Resize { session, .. }
14332 | NodeRequest::Interrupt { session }
14333 | NodeRequest::Stop { session, .. }
14334 | NodeRequest::Remove { session }
14335 | NodeRequest::ResolveInteraction { session, .. }
14336 | NodeRequest::SetSessionMode { session, .. }
14337 | NodeRequest::SetSessionConfigOption { session, .. }
14338 | NodeRequest::SetSessionModel { session, .. } => shared
14339 .session_bindings
14340 .lock()
14341 .unwrap_or_else(|poisoned| poisoned.into_inner())
14342 .get(&session.session.instance_id)
14343 .filter(|binding| {
14344 binding.workspace_id == session.workspace_id
14345 && binding.generation == session.session.generation
14346 })
14347 .map_or(true, |binding| binding.context.is_some()),
14348 NodeRequest::RenameSessionRecord { record_id, .. }
14349 | NodeRequest::SetSessionTask { record_id, .. }
14350 | NodeRequest::ResumeSessionRecord { record_id, .. }
14351 | NodeRequest::ForgetSessionRecord { record_id } => shared
14352 .record(record_id)
14353 .map_or(true, |record| record.context.is_some()),
14354 NodeRequest::ArmHarnessMcpReservation { .. }
14355 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14356 | NodeRequest::ActivateHarnessMcpReservation { .. }
14357 | NodeRequest::AbortHarnessMcpReservation { .. }
14358 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14359 | NodeRequest::RejectHarnessMcpCall { .. }
14360 | NodeRequest::Snapshot
14361 | NodeRequest::Resync { .. }
14362 | NodeRequest::BeginDeliveryStage { .. }
14363 | NodeRequest::PutDeliveryBlobChunk { .. }
14364 | NodeRequest::CommitDeliveryStage { .. }
14365 | NodeRequest::AbortDeliveryStage { .. }
14366 | NodeRequest::BrowseHostDirectories { .. }
14367 | NodeRequest::InspectWorkspace { .. }
14368 | NodeRequest::ReadWorkspaceFile { .. }
14369 | NodeRequest::WriteWorkspaceFile { .. }
14370 | NodeRequest::CreateWorkspaceFile { .. }
14371 | NodeRequest::CreateWorkspaceDirectory { .. }
14372 | NodeRequest::ReadGitHistory { .. }
14373 | NodeRequest::ReadGitDiff { .. }
14374 | NodeRequest::AcquireController { .. }
14375 | NodeRequest::ReleaseController
14376 | NodeRequest::Spawn { .. }
14377 | NodeRequest::SpawnSpec { .. }
14378 | NodeRequest::RegisterWorkspace { .. }
14379 | NodeRequest::CreateStandaloneWorkspace { .. }
14380 | NodeRequest::UnregisterWorkspace { .. }
14381 | NodeRequest::CreateWorktree { .. }
14382 | NodeRequest::RemoveWorktree { .. }
14383 | NodeRequest::SpawnManagedWorktree { .. }
14384 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14385 | NodeRequest::CleanupManagedWorktree { .. }
14386 | NodeRequest::IndexProviderSession { .. }
14387 | NodeRequest::IndexNativeSession { .. }
14388 | NodeRequest::CatalogNativeSessions { .. }
14389 | NodeRequest::PageNativeSessions { .. }
14390 | NodeRequest::PreviewNativeSession { .. }
14391 | NodeRequest::PreviewSessionRecord { .. }
14392 | NodeRequest::DiscoverHistory { .. }
14393 | NodeRequest::LoadHistory { .. }
14394 | NodeRequest::ExportContextPackForSessionRecord { .. }
14395 | NodeRequest::ExportContextPack { .. }
14396 | NodeRequest::ForgetContextPack { .. }
14397 | NodeRequest::ResolveDurableContextPack { .. }
14398 | NodeRequest::ReadContextPack { .. }
14399 | NodeRequest::Shutdown => false,
14400 }
14401}
14402
14403fn request_requires_open_provider_ids_with(
14404 request: &NodeRequest,
14405 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14406 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14407) -> bool {
14408 match request {
14409 NodeRequest::Spawn { provider, .. } => !provider_id_is_legacy(provider),
14410 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14411 NodeRequest::CatalogNativeSessions { route, .. }
14412 | NodeRequest::PageNativeSessions { route, .. } => {
14413 !provider_id_is_legacy(&route.provider)
14414 }
14415 NodeRequest::PreviewNativeSession { selection, .. }
14416 | NodeRequest::IndexNativeSession { selection, .. } => {
14417 !provider_id_is_legacy(&selection.route.provider)
14418 }
14419 NodeRequest::SpawnSpec { .. }
14420 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14421 NodeRequest::SpawnManagedWorktree { .. }
14422 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14423 NodeRequest::Resume { session, .. }
14424 | NodeRequest::Prompt { session, .. }
14425 | NodeRequest::Paste { session, .. }
14426 | NodeRequest::Input { session, .. }
14427 | NodeRequest::TerminalBytes { session, .. }
14428 | NodeRequest::TerminalControl { session, .. }
14429 | NodeRequest::Resize { session, .. }
14430 | NodeRequest::Interrupt { session }
14431 | NodeRequest::Stop { session, .. }
14432 | NodeRequest::Remove { session }
14433 | NodeRequest::ResolveInteraction { session, .. }
14434 | NodeRequest::SetSessionMode { session, .. }
14435 | NodeRequest::SetSessionConfigOption { session, .. }
14436 | NodeRequest::SetSessionModel { session, .. }
14437 | NodeRequest::DiscoverHistory { session, .. }
14438 | NodeRequest::LoadHistory { session, .. }
14439 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14440 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14441 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14442 NodeRequest::RenameSessionRecord { record_id, .. }
14443 | NodeRequest::SetSessionTask { record_id, .. }
14444 | NodeRequest::ResumeSessionRecord { record_id, .. }
14445 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14446 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14447 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14448 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14449 NodeRequest::ArmHarnessMcpReservation { .. }
14450 | NodeRequest::ActivateHarnessMcpReservation { .. }
14451 | NodeRequest::AbortHarnessMcpReservation { .. }
14452 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14453 | NodeRequest::RejectHarnessMcpCall { .. }
14454 | NodeRequest::Snapshot
14455 | NodeRequest::Resync { .. }
14456 | NodeRequest::BeginDeliveryStage { .. }
14457 | NodeRequest::PutDeliveryBlobChunk { .. }
14458 | NodeRequest::CommitDeliveryStage { .. }
14459 | NodeRequest::AbortDeliveryStage { .. }
14460 | NodeRequest::BrowseHostDirectories { .. }
14461 | NodeRequest::InspectWorkspace { .. }
14462 | NodeRequest::ReadWorkspaceFile { .. }
14463 | NodeRequest::WriteWorkspaceFile { .. }
14464 | NodeRequest::CreateWorkspaceFile { .. }
14465 | NodeRequest::CreateWorkspaceDirectory { .. }
14466 | NodeRequest::ReadGitHistory { .. }
14467 | NodeRequest::ReadGitDiff { .. }
14468 | NodeRequest::AcquireController { .. }
14469 | NodeRequest::ReleaseController
14470 | NodeRequest::RegisterWorkspace { .. }
14471 | NodeRequest::CreateStandaloneWorkspace { .. }
14472 | NodeRequest::UnregisterWorkspace { .. }
14473 | NodeRequest::CreateWorktree { .. }
14474 | NodeRequest::RemoveWorktree { .. }
14475 | NodeRequest::CleanupManagedWorktree { .. }
14476 | NodeRequest::Shutdown => false,
14477 NodeRequest::ForgetContextPack { .. } => true,
14478 NodeRequest::ResolveDurableContextPack { .. }
14484 | NodeRequest::ReadContextPack { .. } => true,
14485 }
14486}
14487
14488fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14489 snapshot.enabled_providers.retain(provider_id_is_legacy);
14490 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14491 snapshot
14492 .provider_runtime_statuses
14493 .as_slice()
14494 .iter()
14495 .filter(|status| provider_id_is_legacy(status.provider()))
14496 .cloned(),
14497 )
14498 .expect("a filtered provider runtime projection remains valid");
14499 snapshot
14500 .session_records
14501 .retain(|record| provider_id_is_legacy(&record.provider));
14502 for workspace in &mut snapshot.workspaces {
14503 workspace
14504 .sessions
14505 .retain(|session| provider_id_is_legacy(&session.agent_id));
14506 }
14507}
14508
14509fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14510 for record in &mut snapshot.session_records {
14511 record.environment_profile = None;
14512 }
14513 if let Some(profiles) = snapshot
14514 .launch_inventory
14515 .as_mut()
14516 .and_then(|inventory| inventory.spawn_profiles.as_mut())
14517 {
14518 for profile in profiles {
14519 profile.environment_profile = None;
14520 }
14521 }
14522}
14523
14524fn project_event_without_child_environment_profile(
14525 mut envelope: NodeEventEnvelope,
14526) -> NodeEventEnvelope {
14527 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14528 record.environment_profile = None;
14529 }
14530 envelope
14531}
14532
14533fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14534 let contains_environment_profile = match reply.result.as_ref() {
14535 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14536 receipt.environment_profile.is_some()
14537 }
14538 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14539 receipt.spawn.environment_profile.is_some()
14540 }
14541 _ => false,
14542 };
14543 if contains_environment_profile {
14544 reply.result = Err(failure(
14545 NodeFailureCode::UnsupportedCapability,
14546 "child environment profiles were not negotiated",
14547 ));
14548 return;
14549 }
14550 let Ok(response) = reply.result.as_mut() else {
14551 return;
14552 };
14553 match response {
14554 NodeResponse::Snapshot { snapshot, .. } => {
14555 clear_snapshot_child_environment_profiles(snapshot);
14556 }
14557 NodeResponse::Resync { snapshot, events, .. } => {
14558 clear_snapshot_child_environment_profiles(snapshot);
14559 for event in events {
14560 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14561 record.environment_profile = None;
14562 }
14563 }
14564 }
14565 NodeResponse::SessionRecordUpdated { record }
14566 | NodeResponse::ProviderSessionIndexed { record }
14567 | NodeResponse::NativeSessionIndexed { record, .. }
14568 | NodeResponse::SessionRecordResumed { record, .. } => {
14569 record.environment_profile = None;
14570 }
14571 NodeResponse::Armed { .. }
14572 | NodeResponse::Spawned { .. }
14573 | NodeResponse::Activated { .. }
14574 | NodeResponse::Aborted { .. }
14575 | NodeResponse::ReplyChunkAccepted { .. }
14576 | NodeResponse::CallRejected { .. }
14577 | NodeResponse::DeliveryStageBegun { .. }
14578 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14579 | NodeResponse::DeliveryCommitted { .. }
14580 | NodeResponse::DeliveryStageAborted { .. }
14581 | NodeResponse::WorkspaceInspected { .. }
14582 | NodeResponse::HostDirectoriesBrowsed { .. }
14583 | NodeResponse::WorkspaceFileRead { .. }
14584 | NodeResponse::WorkspaceFileWritten { .. }
14585 | NodeResponse::WorkspaceFileCreated { .. }
14586 | NodeResponse::WorkspaceDirectoryCreated { .. }
14587 | NodeResponse::GitHistoryRead { .. }
14588 | NodeResponse::GitDiffRead { .. }
14589 | NodeResponse::Controller { .. }
14590 | NodeResponse::SpawnAccepted { .. }
14591 | NodeResponse::SpawnSpecAccepted { .. }
14592 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14593 | NodeResponse::ManagedWorktreeCleanup { .. }
14594 | NodeResponse::NativeSessionsCataloged { .. }
14595 | NodeResponse::NativeSessionsPaged { .. }
14596 | NodeResponse::NativeSessionPreviewed { .. }
14597 | NodeResponse::SessionRecordPreviewed { .. }
14598 | NodeResponse::HistoryDiscovered { .. }
14599 | NodeResponse::HistoryLoaded { .. }
14600 | NodeResponse::ContextPackForSessionRecordExported { .. }
14601 | NodeResponse::ContextPackExported { .. }
14602 | NodeResponse::ContextPackForgotten { .. }
14603 | NodeResponse::DurableContextPackResolved { .. }
14604 | NodeResponse::ContextPackBytesRead { .. }
14605 | NodeResponse::SessionRecordForgotten { .. }
14606 | NodeResponse::WorkspaceRegistered { .. }
14607 | NodeResponse::StandaloneWorkspaceCreated { .. }
14608 | NodeResponse::WorkspaceUnregistered { .. }
14609 | NodeResponse::WorktreeCreated { .. }
14610 | NodeResponse::WorktreeRemoved { .. }
14611 | NodeResponse::Accepted
14612 | NodeResponse::ShuttingDown => {}
14613 }
14614}
14615
14616fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14617 for record in &mut snapshot.session_records {
14618 record.bundle = None;
14619 }
14620}
14621
14622fn project_event_without_session_bundle(
14623 mut envelope: NodeEventEnvelope,
14624) -> NodeEventEnvelope {
14625 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14626 record.bundle = None;
14627 }
14628 envelope
14629}
14630
14631fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14632 let contains_bundle = match reply.result.as_ref() {
14633 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14634 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14635 receipt.spawn.bundle.is_some()
14636 }
14637 _ => false,
14638 };
14639 if contains_bundle {
14640 reply.result = Err(failure(
14641 NodeFailureCode::UnsupportedCapability,
14642 "session bundles were not negotiated",
14643 ));
14644 return;
14645 }
14646 let Ok(response) = reply.result.as_mut() else {
14647 return;
14648 };
14649 match response {
14650 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14651 NodeResponse::Resync { snapshot, events, .. } => {
14652 clear_snapshot_session_bundles(snapshot);
14653 for event in events {
14654 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14655 record.bundle = None;
14656 }
14657 }
14658 }
14659 NodeResponse::SessionRecordUpdated { record }
14660 | NodeResponse::ProviderSessionIndexed { record }
14661 | NodeResponse::NativeSessionIndexed { record, .. }
14662 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14663 NodeResponse::Armed { .. }
14664 | NodeResponse::Spawned { .. }
14665 | NodeResponse::Activated { .. }
14666 | NodeResponse::Aborted { .. }
14667 | NodeResponse::ReplyChunkAccepted { .. }
14668 | NodeResponse::CallRejected { .. }
14669 | NodeResponse::DeliveryStageBegun { .. }
14670 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14671 | NodeResponse::DeliveryCommitted { .. }
14672 | NodeResponse::DeliveryStageAborted { .. }
14673 | NodeResponse::WorkspaceInspected { .. }
14674 | NodeResponse::HostDirectoriesBrowsed { .. }
14675 | NodeResponse::WorkspaceFileRead { .. }
14676 | NodeResponse::WorkspaceFileWritten { .. }
14677 | NodeResponse::WorkspaceFileCreated { .. }
14678 | NodeResponse::WorkspaceDirectoryCreated { .. }
14679 | NodeResponse::GitHistoryRead { .. }
14680 | NodeResponse::GitDiffRead { .. }
14681 | NodeResponse::Controller { .. }
14682 | NodeResponse::SpawnAccepted { .. }
14683 | NodeResponse::SpawnSpecAccepted { .. }
14684 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14685 | NodeResponse::ManagedWorktreeCleanup { .. }
14686 | NodeResponse::NativeSessionsCataloged { .. }
14687 | NodeResponse::NativeSessionsPaged { .. }
14688 | NodeResponse::NativeSessionPreviewed { .. }
14689 | NodeResponse::SessionRecordPreviewed { .. }
14690 | NodeResponse::HistoryDiscovered { .. }
14691 | NodeResponse::HistoryLoaded { .. }
14692 | NodeResponse::ContextPackForSessionRecordExported { .. }
14693 | NodeResponse::ContextPackExported { .. }
14694 | NodeResponse::ContextPackForgotten { .. }
14695 | NodeResponse::DurableContextPackResolved { .. }
14696 | NodeResponse::ContextPackBytesRead { .. }
14697 | NodeResponse::SessionRecordForgotten { .. }
14698 | NodeResponse::WorkspaceRegistered { .. }
14699 | NodeResponse::StandaloneWorkspaceCreated { .. }
14700 | NodeResponse::WorkspaceUnregistered { .. }
14701 | NodeResponse::WorktreeCreated { .. }
14702 | NodeResponse::WorktreeRemoved { .. }
14703 | NodeResponse::Accepted
14704 | NodeResponse::ShuttingDown => {}
14705 }
14706}
14707
14708fn project_snapshot_history_for_wire(
14709 snapshot: &mut NodeSnapshot,
14710 include_history_metadata: bool,
14711) {
14712 for workspace in &mut snapshot.workspaces {
14713 for session in &mut workspace.sessions {
14714 if include_history_metadata {
14715 session.history.loaded = None;
14716 } else {
14717 session.history = Default::default();
14718 }
14719 }
14720 }
14721}
14722
14723fn project_event_history_for_wire(
14724 mut envelope: NodeEventEnvelope,
14725 include_history_metadata: bool,
14726) -> NodeEventEnvelope {
14727 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14728 for session in &mut workspace.sessions {
14729 if include_history_metadata {
14730 session.history.loaded = None;
14731 } else {
14732 session.history = Default::default();
14733 }
14734 }
14735 }
14736 envelope
14737}
14738
14739fn project_response_history_for_wire(
14740 reply: &mut ResponseEnvelope,
14741 include_history_metadata: bool,
14742) {
14743 let Ok(response) = reply.result.as_mut() else {
14744 return;
14745 };
14746 match response {
14747 NodeResponse::Snapshot { snapshot, .. } => {
14748 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14749 }
14750 NodeResponse::Resync {
14751 snapshot, events, ..
14752 } => {
14753 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14754 for event in events {
14755 *event = project_event_history_for_wire(
14756 event.clone(),
14757 include_history_metadata,
14758 );
14759 }
14760 }
14761 NodeResponse::WorkspaceRegistered { workspace }
14762 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14763 | NodeResponse::WorktreeCreated { workspace, .. } => {
14764 for session in &mut workspace.sessions {
14765 if include_history_metadata {
14766 session.history.loaded = None;
14767 } else {
14768 session.history = Default::default();
14769 }
14770 }
14771 }
14772 _ => {}
14773 }
14774}
14775
14776fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14777 for record in &mut snapshot.session_records {
14778 record.context_id = None;
14779 record.context = None;
14780 record.exported_context = None;
14781 }
14782}
14783
14784fn project_event_without_context_pack(
14785 mut envelope: NodeEventEnvelope,
14786) -> NodeEventEnvelope {
14787 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14788 record.context_id = None;
14789 record.context = None;
14790 record.exported_context = None;
14791 }
14792 envelope
14793}
14794
14795fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14796 let contains_context = match reply.result.as_ref() {
14797 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14798 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14799 receipt.spawn.context.is_some()
14800 }
14801 Ok(NodeResponse::HistoryDiscovered { .. })
14802 | Ok(NodeResponse::HistoryLoaded { .. })
14803 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14804 | Ok(NodeResponse::ContextPackExported { .. })
14805 | Ok(NodeResponse::ContextPackForgotten { .. })
14806 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14807 _ => false,
14808 };
14809 if contains_context {
14810 reply.result = Err(failure(
14811 NodeFailureCode::UnsupportedCapability,
14812 "history context packs were not negotiated",
14813 ));
14814 return;
14815 }
14816 let Ok(response) = reply.result.as_mut() else {
14817 return;
14818 };
14819 match response {
14820 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
14821 NodeResponse::Resync {
14822 snapshot, events, ..
14823 } => {
14824 clear_snapshot_context_packs(snapshot);
14825 for event in events {
14826 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14827 record.context_id = None;
14828 record.context = None;
14829 record.exported_context = None;
14830 }
14831 }
14832 }
14833 NodeResponse::SessionRecordUpdated { record }
14834 | NodeResponse::ProviderSessionIndexed { record }
14835 | NodeResponse::NativeSessionIndexed { record, .. }
14836 | NodeResponse::SessionRecordResumed { record, .. } => {
14837 record.context_id = None;
14838 record.context = None;
14839 record.exported_context = None;
14840 }
14841 _ => {}
14842 }
14843}
14844
14845fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
14846 let contains_open_record = match reply.result.as_ref() {
14847 Ok(NodeResponse::SessionRecordUpdated { record })
14848 | Ok(NodeResponse::ProviderSessionIndexed { record })
14849 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
14850 !provider_id_is_legacy(&record.provider)
14851 }
14852 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
14853 !provider_id_is_legacy(&selection.route.provider)
14854 || !provider_id_is_legacy(&record.provider)
14855 }
14856 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14857 !provider_id_is_legacy(&receipt.provider)
14858 }
14859 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14860 !provider_id_is_legacy(&receipt.spawn.provider)
14861 }
14862 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
14863 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
14864 !provider_id_is_legacy(&route.provider)
14865 }
14866 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
14867 !provider_id_is_legacy(&selection.route.provider)
14868 }
14869 _ => false,
14870 };
14871 if contains_open_record {
14872 reply.result = Err(failure(
14873 NodeFailureCode::UnsupportedCapability,
14874 "open provider IDs were not negotiated",
14875 ));
14876 return;
14877 }
14878 let Ok(response) = reply.result.as_mut() else {
14879 return;
14880 };
14881 match response {
14882 NodeResponse::Snapshot { snapshot, .. } => {
14883 project_snapshot_legacy_provider_ids(snapshot);
14884 }
14885 NodeResponse::Resync { snapshot, events, .. } => {
14886 project_snapshot_legacy_provider_ids(snapshot);
14887 *events = std::mem::take(events)
14888 .into_iter()
14889 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
14890 .collect();
14891 }
14892 NodeResponse::WorkspaceRegistered { workspace }
14893 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14894 | NodeResponse::WorktreeCreated { workspace, .. } => {
14895 workspace
14896 .sessions
14897 .retain(|session| provider_id_is_legacy(&session.agent_id));
14898 }
14899 NodeResponse::Armed { .. }
14900 | NodeResponse::Spawned { .. }
14901 | NodeResponse::Activated { .. }
14902 | NodeResponse::Aborted { .. }
14903 | NodeResponse::ReplyChunkAccepted { .. }
14904 | NodeResponse::CallRejected { .. }
14905 | NodeResponse::DeliveryStageBegun { .. }
14906 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14907 | NodeResponse::DeliveryCommitted { .. }
14908 | NodeResponse::DeliveryStageAborted { .. }
14909 | NodeResponse::WorkspaceInspected { .. }
14910 | NodeResponse::HostDirectoriesBrowsed { .. }
14911 | NodeResponse::WorkspaceFileRead { .. }
14912 | NodeResponse::WorkspaceFileWritten { .. }
14913 | NodeResponse::WorkspaceFileCreated { .. }
14914 | NodeResponse::WorkspaceDirectoryCreated { .. }
14915 | NodeResponse::GitHistoryRead { .. }
14916 | NodeResponse::GitDiffRead { .. }
14917 | NodeResponse::Controller { .. }
14918 | NodeResponse::SpawnAccepted { .. }
14919 | NodeResponse::SpawnSpecAccepted { .. }
14920 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14921 | NodeResponse::ManagedWorktreeCleanup { .. }
14922 | NodeResponse::NativeSessionsCataloged { .. }
14923 | NodeResponse::NativeSessionsPaged { .. }
14924 | NodeResponse::NativeSessionPreviewed { .. }
14925 | NodeResponse::SessionRecordPreviewed { .. }
14926 | NodeResponse::HistoryDiscovered { .. }
14927 | NodeResponse::HistoryLoaded { .. }
14928 | NodeResponse::ContextPackForSessionRecordExported { .. }
14929 | NodeResponse::ContextPackExported { .. }
14930 | NodeResponse::ContextPackForgotten { .. }
14931 | NodeResponse::DurableContextPackResolved { .. }
14932 | NodeResponse::ContextPackBytesRead { .. }
14933 | NodeResponse::SessionRecordUpdated { .. }
14934 | NodeResponse::ProviderSessionIndexed { .. }
14935 | NodeResponse::NativeSessionIndexed { .. }
14936 | NodeResponse::SessionRecordResumed { .. }
14937 | NodeResponse::SessionRecordForgotten { .. }
14938 | NodeResponse::WorkspaceUnregistered { .. }
14939 | NodeResponse::WorktreeRemoved { .. }
14940 | NodeResponse::Accepted
14941 | NodeResponse::ShuttingDown => {}
14942 }
14943}
14944
14945fn project_event_legacy_provider_ids(
14946 shared: &NodeShared,
14947 mut envelope: NodeEventEnvelope,
14948) -> Option<NodeEventEnvelope> {
14949 let include = match &mut envelope.event {
14950 NodeEvent::HarnessMcpReadCall { .. } => true,
14951 NodeEvent::Control { address, .. }
14952 | NodeEvent::AgentStream { address, .. }
14953 | NodeEvent::TerminalFrame { address, .. } => shared
14954 .validate_address(address)
14955 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
14956 NodeEvent::WorkspaceAdded { workspace } => {
14957 workspace
14958 .sessions
14959 .retain(|session| provider_id_is_legacy(&session.agent_id));
14960 true
14961 }
14962 NodeEvent::SessionRecordUpserted { record } => {
14963 provider_id_is_legacy(&record.provider)
14964 }
14965 NodeEvent::SessionRecordHistorySummarized { .. } => false,
14966 NodeEvent::SessionRecordRemoved { .. } => shared
14967 .history
14968 .lock()
14969 .unwrap_or_else(|poisoned| poisoned.into_inner())
14970 .removed_record_providers
14971 .get(&envelope.sequence)
14972 .is_some_and(provider_id_is_legacy),
14973 NodeEvent::ControllerChanged { .. }
14974 | NodeEvent::ManagedWorktreeUpserted { .. }
14975 | NodeEvent::ManagedWorktreeRemoved { .. }
14976 | NodeEvent::WorkspaceRemoved { .. }
14977 | NodeEvent::ResyncRequired { .. } => true,
14978 };
14979 include.then_some(envelope)
14980}
14981
14982fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
14983 let Ok(response) = reply.result.as_mut() else {
14984 return;
14985 };
14986 match response {
14987 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14988 snapshot.provider_runtime_statuses.clear();
14989 }
14990 NodeResponse::Armed { .. }
14991 | NodeResponse::Spawned { .. }
14992 | NodeResponse::Activated { .. }
14993 | NodeResponse::Aborted { .. }
14994 | NodeResponse::ReplyChunkAccepted { .. }
14995 | NodeResponse::CallRejected { .. }
14996 | NodeResponse::DeliveryStageBegun { .. }
14997 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14998 | NodeResponse::DeliveryCommitted { .. }
14999 | NodeResponse::DeliveryStageAborted { .. }
15000 | NodeResponse::WorkspaceInspected { .. }
15001 | NodeResponse::HostDirectoriesBrowsed { .. }
15002 | NodeResponse::WorkspaceFileRead { .. }
15003 | NodeResponse::WorkspaceFileWritten { .. }
15004 | NodeResponse::WorkspaceFileCreated { .. }
15005 | NodeResponse::WorkspaceDirectoryCreated { .. }
15006 | NodeResponse::GitHistoryRead { .. }
15007 | NodeResponse::GitDiffRead { .. }
15008 | NodeResponse::Controller { .. }
15009 | NodeResponse::SpawnAccepted { .. }
15010 | NodeResponse::SpawnSpecAccepted { .. }
15011 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15012 | NodeResponse::ManagedWorktreeCleanup { .. }
15013 | NodeResponse::NativeSessionsCataloged { .. }
15014 | NodeResponse::NativeSessionsPaged { .. }
15015 | NodeResponse::NativeSessionPreviewed { .. }
15016 | NodeResponse::SessionRecordPreviewed { .. }
15017 | NodeResponse::HistoryDiscovered { .. }
15018 | NodeResponse::HistoryLoaded { .. }
15019 | NodeResponse::ContextPackForSessionRecordExported { .. }
15020 | NodeResponse::ContextPackExported { .. }
15021 | NodeResponse::ContextPackForgotten { .. }
15022 | NodeResponse::DurableContextPackResolved { .. }
15023 | NodeResponse::ContextPackBytesRead { .. }
15024 | NodeResponse::SessionRecordUpdated { .. }
15025 | NodeResponse::ProviderSessionIndexed { .. }
15026 | NodeResponse::NativeSessionIndexed { .. }
15027 | NodeResponse::SessionRecordResumed { .. }
15028 | NodeResponse::SessionRecordForgotten { .. }
15029 | NodeResponse::WorkspaceRegistered { .. }
15030 | NodeResponse::StandaloneWorkspaceCreated { .. }
15031 | NodeResponse::WorkspaceUnregistered { .. }
15032 | NodeResponse::WorktreeCreated { .. }
15033 | NodeResponse::WorktreeRemoved { .. }
15034 | NodeResponse::Accepted
15035 | NodeResponse::ShuttingDown => {}
15036 }
15037}
15038
15039struct ControllerReleaseGuard {
15040 shared: Arc<NodeShared>,
15041 connection_id: u64,
15042}
15043
15044impl Drop for ControllerReleaseGuard {
15045 fn drop(&mut self) {
15046 self.shared.release_controller(self.connection_id);
15047 }
15048}
15049
15050struct AbortTaskOnDrop(AbortHandle);
15051
15052impl Drop for AbortTaskOnDrop {
15053 fn drop(&mut self) {
15054 self.0.abort();
15055 }
15056}
15057
15058fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15063 std::env::var(name)
15064 .ok()
15065 .and_then(|value| value.parse::<u64>().ok())
15066 .filter(|value| *value > 0)
15067 .unwrap_or(default)
15068 .min(max)
15069}
15070
15071fn workspace_inspection_time_budget_ms() -> u64 {
15072 env_config_bounded(
15073 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15074 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15075 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15076 )
15077}
15078
15079fn workspace_inspection_entry_cap() -> usize {
15080 env_config_bounded(
15081 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15082 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15083 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15084 ) as usize
15085}
15086
15087struct WorkspaceWalkBudget {
15093 deadline: Instant,
15094 entry_cap: usize,
15095 entries_visited: u64,
15096 time_budget_exceeded: bool,
15097 entry_cap_exceeded: bool,
15098}
15099
15100impl WorkspaceWalkBudget {
15101 fn new(deadline: Instant, entry_cap: usize) -> Self {
15102 Self {
15103 deadline,
15104 entry_cap,
15105 entries_visited: 0,
15106 time_budget_exceeded: false,
15107 entry_cap_exceeded: false,
15108 }
15109 }
15110
15111 fn is_exhausted(&self) -> bool {
15112 self.time_budget_exceeded || self.entry_cap_exceeded
15113 }
15114
15115 fn record_visit(&mut self) -> bool {
15118 self.entries_visited += 1;
15119 if self.entries_visited as usize >= self.entry_cap {
15120 self.entry_cap_exceeded = true;
15121 }
15122 if Instant::now() >= self.deadline {
15123 self.time_budget_exceeded = true;
15124 }
15125 self.is_exhausted()
15126 }
15127}
15128
15129fn collect_workspace_entries(
15130 root: &Path,
15131 deadline: Instant,
15132 entry_cap: usize,
15133) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15134 let mut entries = Vec::new();
15135 let mut truncated = false;
15136 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15137 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15138 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15142 (entries, truncated, budget)
15143}
15144
15145fn workspace_inspection_truncation(
15149 walk_budget: &WorkspaceWalkBudget,
15150 git_time_budget_exceeded: bool,
15151 elapsed: Duration,
15152) -> Option<WorkspaceInspectionTruncationV1> {
15153 if !walk_budget.time_budget_exceeded
15154 && !walk_budget.entry_cap_exceeded
15155 && !git_time_budget_exceeded
15156 {
15157 return None;
15158 }
15159 Some(WorkspaceInspectionTruncationV1 {
15160 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15161 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15162 git_time_budget_exceeded,
15163 entries_visited: walk_budget.entries_visited,
15164 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15165 })
15166}
15167
15168fn walk_workspace_directory(
15169 directory: &Path,
15170 relative_directory: Option<&RepositoryPath>,
15171 depth: usize,
15172 entries: &mut Vec<WorkspaceEntry>,
15173 truncated: &mut bool,
15174 budget: &mut WorkspaceWalkBudget,
15175) {
15176 if budget.is_exhausted() {
15177 *truncated = true;
15178 return;
15179 }
15180 let read_dir = match std::fs::read_dir(directory) {
15181 Ok(read_dir) => read_dir,
15182 Err(_) => {
15183 *truncated = true;
15184 return;
15185 }
15186 };
15187 let mut children = Vec::new();
15188 for child in read_dir {
15189 match child {
15190 Ok(child) => children.push(child),
15191 Err(_) => *truncated = true,
15192 }
15193 }
15194 children.sort_by_key(|child| child.file_name());
15195 for child in children {
15196 if budget.record_visit() {
15197 *truncated = true;
15198 return;
15199 }
15200 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15201 *truncated = true;
15202 return;
15203 }
15204 let file_type = match child.file_type() {
15205 Ok(file_type) => file_type,
15206 Err(_) => {
15207 *truncated = true;
15208 continue;
15209 }
15210 };
15211 if file_type.is_symlink() {
15212 continue;
15213 }
15214 let name = match child.file_name().into_string() {
15215 Ok(name) => name,
15216 Err(_) => {
15217 *truncated = true;
15218 continue;
15219 }
15220 };
15221 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15222 continue;
15223 }
15224 let relative_path = match relative_directory {
15225 None => windows_repository_path(name),
15226 Some(parent) => parent
15227 .as_utf8()
15228 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15229 };
15230 let Some(relative_path) = relative_path else {
15231 *truncated = true;
15232 continue;
15233 };
15234 let kind = if file_type.is_dir() {
15235 WorkspaceEntryKind::Directory
15236 } else if file_type.is_file() {
15237 WorkspaceEntryKind::File
15238 } else {
15239 continue;
15240 };
15241 entries.push(WorkspaceEntry {
15242 relative_path: relative_path.clone(),
15243 kind,
15244 });
15245 if file_type.is_dir() {
15246 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15247 walk_workspace_directory(
15248 &child.path(),
15249 Some(&relative_path),
15250 depth + 1,
15251 entries,
15252 truncated,
15253 budget,
15254 );
15255 } else {
15256 *truncated = true;
15257 }
15258 }
15259 }
15260}
15261
15262fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15263 if value.contains('\\') {
15264 return None;
15265 }
15266 RepositoryPath::utf8(value).ok()
15267}
15268
15269fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15270 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15271}
15272
15273fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15282 if name.eq_ignore_ascii_case(".git")
15283 || name.eq_ignore_ascii_case("target")
15284 || name.eq_ignore_ascii_case("node_modules")
15285 {
15286 return true;
15287 }
15288 path.join("CACHEDIR.TAG").is_file()
15289}
15290
15291fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15292 matches!(
15293 status,
15294 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15295 )
15296}
15297
15298#[derive(Clone, Debug, Eq, PartialEq)]
15299enum ContextPackRepositoryHead {
15300 NotRepository,
15301 Unborn,
15302 Commit(String),
15303}
15304
15305#[derive(Clone, Debug, Eq, PartialEq)]
15306struct ContextPackRepositoryObservation {
15307 head: ContextPackRepositoryHead,
15308 git: GitSnapshot,
15309 files: Vec<ContextPackRepositoryFileSource>,
15310}
15311
15312fn stable_context_pack_repository(
15313 first: ContextPackRepositoryObservation,
15314 second: ContextPackRepositoryObservation,
15315) -> Result<ContextPackRepository, NodeFailure> {
15316 if first != second {
15317 return Err(failure(
15318 NodeFailureCode::ContextPackMaterializationFailed,
15319 "context repository changed during bounded capture",
15320 ));
15321 }
15322 Ok(ContextPackRepository::from_sources(
15323 &second.git,
15324 second.files,
15325 ))
15326}
15327
15328fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15335 if Instant::now() < deadline {
15336 return false;
15337 }
15338 append_git_diagnostic(
15339 snapshot,
15340 &format!("{stage} skipped: workspace inspection time budget exhausted"),
15341 );
15342 true
15343}
15344
15345async fn run_git_bounded_with_deadline(
15354 root: &str,
15355 arguments: &[&str],
15356 output_limit: usize,
15357 deadline: Instant,
15358) -> io::Result<GitCommandOutput> {
15359 let remaining = deadline.checked_duration_since(Instant::now());
15360 let timeout_ms = remaining
15361 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15362 .unwrap_or(0)
15363 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15364 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15365}
15366
15367async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15374 let mut snapshot = GitSnapshot {
15375 is_repository: false,
15376 branch: None,
15377 status: Vec::new(),
15378 recent_commits: Vec::new(),
15379 worktrees: Vec::new(),
15380 managed_worktree: None,
15381 truncated: false,
15382 diagnostic: None,
15383 };
15384 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15385 return (snapshot, true);
15386 }
15387 let repository = match run_git_bounded_with_deadline(
15388 root,
15389 &["rev-parse", "--is-inside-work-tree"],
15390 4 * 1_024,
15391 deadline,
15392 )
15393 .await
15394 {
15395 Ok(output) => output,
15396 Err(error) => {
15397 append_git_diagnostic(
15398 &mut snapshot,
15399 &format!("git inspection unavailable: {error}"),
15400 );
15401 return (snapshot, false);
15402 }
15403 };
15404 snapshot.truncated |= repository.truncated;
15405 if repository.timed_out {
15406 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15407 return (snapshot, false);
15408 }
15409 if !repository.success
15410 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15411 {
15412 let stderr = String::from_utf8_lossy(&repository.stderr);
15413 if !stderr.to_ascii_lowercase().contains("not a git repository") {
15414 append_git_diagnostic(&mut snapshot, stderr.trim());
15415 }
15416 return (snapshot, false);
15417 }
15418 snapshot.is_repository = true;
15419
15420 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15421 return (snapshot, true);
15422 }
15423 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15424 Ok(output) => {
15425 snapshot.truncated |= output.truncated;
15426 if output.timed_out {
15427 append_git_diagnostic(&mut snapshot, "git branch query timed out");
15428 } else if output.success {
15429 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15430 if !branch.is_empty() {
15431 snapshot.branch = Some(truncate_git_field(
15432 &branch,
15433 MAX_REPOSITORY_PATH_BYTES,
15434 &mut snapshot.truncated,
15435 ));
15436 }
15437 }
15438 }
15439 Err(error) => append_git_diagnostic(
15440 &mut snapshot,
15441 &format!("git branch query failed: {error}"),
15442 ),
15443 }
15444 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15445 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15446 snapshot.truncated |= output.truncated;
15447 if output.success && !output.timed_out {
15448 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15449 if !head.is_empty() {
15450 snapshot.branch = Some(format!("detached:{head}"));
15451 }
15452 }
15453 }
15454 }
15455
15456 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15457 return (snapshot, true);
15458 }
15459 match run_git_bounded_with_deadline(
15460 root,
15461 &[
15462 "status",
15463 "--porcelain=v1",
15464 "-z",
15465 "--renames",
15466 "--untracked-files=normal",
15467 "--",
15468 ".",
15469 ],
15470 GIT_OUTPUT_MAX_BYTES,
15471 deadline,
15472 )
15473 .await
15474 {
15475 Ok(output) => {
15476 snapshot.truncated |= output.truncated;
15477 if output.timed_out {
15478 append_git_diagnostic(&mut snapshot, "git status timed out");
15479 } else if output.success {
15480 parse_git_status(&output.stdout, &mut snapshot);
15481 } else {
15482 append_git_diagnostic(
15483 &mut snapshot,
15484 String::from_utf8_lossy(&output.stderr).trim(),
15485 );
15486 }
15487 }
15488 Err(error) => append_git_diagnostic(
15489 &mut snapshot,
15490 &format!("git status failed: {error}"),
15491 ),
15492 }
15493
15494 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15495 return (snapshot, true);
15496 }
15497 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15498 match run_git_bounded_with_deadline(
15499 root,
15500 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15501 16 * 1_024,
15502 deadline,
15503 )
15504 .await
15505 {
15506 Ok(output) => {
15507 snapshot.truncated |= output.truncated;
15508 if output.timed_out {
15509 append_git_diagnostic(&mut snapshot, "git log timed out");
15510 } else if output.success {
15511 parse_git_commits(&output.stdout, &mut snapshot);
15512 } else {
15513 let stderr = String::from_utf8_lossy(&output.stderr);
15514 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15515 append_git_diagnostic(&mut snapshot, stderr.trim());
15516 }
15517 }
15518 }
15519 Err(error) => append_git_diagnostic(
15520 &mut snapshot,
15521 &format!("git log failed: {error}"),
15522 ),
15523 }
15524
15525 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15526 return (snapshot, true);
15527 }
15528 match list_git_worktrees_with_deadline(root, deadline).await {
15529 Ok(worktrees) => {
15530 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15531 }
15532 Err(error) => append_git_diagnostic(
15533 &mut snapshot,
15534 &format!("git worktree list failed: {}", error.message),
15535 ),
15536 }
15537 (snapshot, false)
15538}
15539
15540fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15541 let mut remaining = output;
15542 while !remaining.is_empty() {
15543 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15544 snapshot.truncated = true;
15545 break;
15546 }
15547 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15548 snapshot.truncated = true;
15549 break;
15550 };
15551 let record = &remaining[..record_end];
15552 remaining = &remaining[record_end + 1..];
15553 let has_previous_path = record
15554 .get(..2)
15555 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15556 let previous_path_bytes = if has_previous_path {
15557 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15558 snapshot.truncated = true;
15559 break;
15560 };
15561 let previous = &remaining[..previous_end];
15562 remaining = &remaining[previous_end + 1..];
15563 Some(previous)
15564 } else {
15565 None
15566 };
15567 if record.len() < 4
15568 || record[2] != b' '
15569 || !is_git_status_code(record[0])
15570 || !is_git_status_code(record[1])
15571 {
15572 snapshot.truncated = true;
15573 continue;
15574 }
15575 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15576 snapshot.truncated = true;
15577 continue;
15578 };
15579 let previous_path = match previous_path_bytes {
15580 Some(previous) => {
15581 let Some(previous) = windows_repository_path_from_bytes(previous) else {
15582 snapshot.truncated = true;
15583 continue;
15584 };
15585 Some(previous)
15586 }
15587 None => None,
15588 };
15589 snapshot.status.push(GitStatusEntry {
15590 index_status: (record[0] as char).to_string(),
15591 worktree_status: (record[1] as char).to_string(),
15592 path,
15593 previous_path,
15594 });
15595 }
15596}
15597
15598fn is_git_status_code(byte: u8) -> bool {
15599 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15600}
15601
15602fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15603 for line in String::from_utf8_lossy(output).lines() {
15604 let Some((id, summary)) = line.split_once('\u{1f}') else {
15605 continue;
15606 };
15607 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15608 snapshot.truncated = true;
15609 break;
15610 }
15611 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15612 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15613 snapshot.recent_commits.push(GitCommitSummary { id, summary });
15614 }
15615}
15616
15617fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15618 if value.len() <= max_bytes {
15619 return value.to_owned();
15620 }
15621 *truncated = true;
15622 let mut boundary = max_bytes;
15623 while boundary > 0 && !value.is_char_boundary(boundary) {
15624 boundary -= 1;
15625 }
15626 value[..boundary].to_owned()
15627}
15628
15629fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15630 if message.is_empty() {
15631 return;
15632 }
15633 let normalized = message
15634 .chars()
15635 .map(|character| if character.is_control() { ' ' } else { character })
15636 .collect::<String>();
15637 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15638 if !diagnostic.is_empty() {
15639 diagnostic.push_str("; ");
15640 }
15641 diagnostic.push_str(normalized.trim());
15642 snapshot.diagnostic = Some(truncate_git_field(
15643 &diagnostic,
15644 GIT_DIAGNOSTIC_MAX_BYTES,
15645 &mut snapshot.truncated,
15646 ));
15647}
15648
15649async fn run_git_bounded(
15650 root: &str,
15651 arguments: &[&str],
15652 output_limit: usize,
15653) -> io::Result<GitCommandOutput> {
15654 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15655}
15656
15657async fn read_git_history_bounded(
15658 root: &str,
15659 path: Option<&RepositoryPath>,
15660 before: Option<&GitObjectId>,
15661 limit: u16,
15662) -> Result<GitHistoryPage, NodeFailure> {
15663 let path = path
15664 .map(|path| {
15665 path.as_utf8().ok_or_else(|| {
15666 failure(
15667 NodeFailureCode::InvalidRepositoryPath,
15668 "git history path must use UTF-8",
15669 )
15670 })
15671 })
15672 .transpose()?
15673 .unwrap_or(".");
15674 let count = (usize::from(limit) + 1).to_string();
15675 let start = before
15676 .map(|revision| format!("{}^", revision.as_str()))
15677 .unwrap_or_else(|| "HEAD".to_owned());
15678 let arguments = [
15679 "--no-pager",
15680 "log",
15681 "--no-decorate",
15682 "--date=iso-strict",
15683 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15684 "-n",
15685 count.as_str(),
15686 start.as_str(),
15687 "--",
15688 path,
15689 ];
15690 let output = run_git_bounded(root, &arguments, 256 * 1_024)
15691 .await
15692 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15693 if output.timed_out {
15694 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15695 }
15696 if !output.success {
15697 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15698 }
15699 let mut commits = parse_git_history(&output.stdout);
15700 let has_more = commits.len() > usize::from(limit) || output.truncated;
15701 commits.truncate(usize::from(limit));
15702 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15703 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15704}
15705
15706fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15707 String::from_utf8_lossy(output)
15708 .split('\u{1e}')
15709 .filter_map(|record| {
15710 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15711 if fields.len() != 9 {
15712 return None;
15713 }
15714 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15715 let parents = if fields[1].is_empty() {
15716 Vec::new()
15717 } else {
15718 fields[1]
15719 .split(' ')
15720 .map(|value| GitObjectId::new(value.to_owned()))
15721 .collect::<Result<Vec<_>, _>>()
15722 .ok()?
15723 };
15724 Some(GitCommitDetails {
15725 id,
15726 parents,
15727 subject: bounded_git_text(fields[2], 512),
15728 author_name: bounded_git_text(fields[3], 256),
15729 author_email: bounded_git_text(fields[4], 320),
15730 authored_at: bounded_git_text(fields[5], 64),
15731 committer_name: bounded_git_text(fields[6], 256),
15732 committer_email: bounded_git_text(fields[7], 320),
15733 committed_at: bounded_git_text(fields[8], 64),
15734 signature_status: GitSignatureStatus::NoSignature,
15735 signer: None,
15736 })
15737 })
15738 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15739 .collect()
15740}
15741
15742fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15743 let normalized = value
15744 .chars()
15745 .map(|character| if character.is_control() { ' ' } else { character })
15746 .collect::<String>();
15747 if normalized.len() <= max_bytes {
15748 return normalized;
15749 }
15750 let mut boundary = max_bytes;
15751 while boundary > 0 && !normalized.is_char_boundary(boundary) {
15752 boundary -= 1;
15753 }
15754 normalized[..boundary].to_owned()
15755}
15756
15757async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15758 let path = request.path.as_ref().map(|path| {
15759 path.as_utf8()
15760 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15761 }).transpose()?;
15762 let mut arguments = match &request.mode {
15763 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15764 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15765 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15766 };
15767 if let GitDiffMode::Commit { revision } = &request.mode {
15768 arguments.push(revision.as_str());
15769 }
15770 arguments.push("--");
15771 if let Some(path) = path {
15772 arguments.push(path);
15773 }
15774 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15775 .await
15776 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15777 if output.timed_out {
15778 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15779 }
15780 if !output.success {
15781 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15782 }
15783 Ok(GitDiff {
15784 mode: request.mode,
15785 path: request.path,
15786 text: String::from_utf8_lossy(&output.stdout).into_owned(),
15787 truncated: output.truncated,
15788 })
15789}
15790
15791async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15792 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15793 ResponseEnvelope { request_id: envelope.request_id, result }
15794}
15795
15796async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15797 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15798 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15799 }
15800 let read_only = matches!(
15801 &request,
15802 NodeRequest::Snapshot
15803 | NodeRequest::Resync { .. }
15804 | NodeRequest::BrowseHostDirectories { .. }
15805 | NodeRequest::InspectWorkspace { .. }
15806 | NodeRequest::ReadWorkspaceFile { .. }
15807 | NodeRequest::ReadGitHistory { .. }
15808 | NodeRequest::ReadGitDiff { .. }
15809 | NodeRequest::CatalogNativeSessions { .. }
15810 | NodeRequest::PageNativeSessions { .. }
15811 | NodeRequest::PreviewNativeSession { .. }
15812 | NodeRequest::PreviewSessionRecord { .. }
15813 );
15814 let _mutation_guard = if read_only {
15815 None
15816 } else {
15817 let guard = shared.mutation_gate.lock().await;
15818 if shared.shutdown.load(Ordering::Acquire) {
15819 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
15820 }
15821 Some(guard)
15822 };
15823 match request {
15824 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
15825 event_sequence: shared.current_sequence(),
15826 controller: shared.controller_state(),
15827 snapshot: shared.snapshot(),
15828 }),
15829 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
15830 NodeRequest::ArmHarnessMcpReservation {
15831 reservation_id,
15832 activation_digest,
15833 spawn_spec,
15834 launch,
15835 expires_at_unix_ms,
15836 } => {
15837 shared.require_controller(connection_id, role)?;
15838 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15839 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15840 })?;
15841 let expires_at_unix_ms = registry
15842 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
15843 .await
15844 .map_err(harness_mcp_failure)?;
15845 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
15846 }
15847 NodeRequest::SpawnSpecWithHarnessMcp {
15848 reservation_id,
15849 activation_digest,
15850 spec,
15851 deadline_unix_ms,
15852 } => {
15853 shared.require_controller(connection_id, role)?;
15854 let receipt = shared
15855 .spawn_from_spec_with_harness_mcp(
15856 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
15857 )
15858 .await?;
15859 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
15860 }
15861 NodeRequest::ActivateHarnessMcpReservation {
15862 reservation_id,
15863 activation_digest,
15864 record_id,
15865 session,
15866 } => {
15867 shared.require_controller(connection_id, role)?;
15868 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
15869 current.instance_id == session.session.instance_id
15870 && current.generation == session.session.generation
15871 }).and_then(|current| current.process_id).ok_or_else(|| {
15872 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
15873 })?;
15874 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15875 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15876 })?.activate(
15877 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
15878 ).map_err(harness_mcp_failure)?;
15879 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
15880 }
15881 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
15882 shared.require_controller(connection_id, role)?;
15883 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15884 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15885 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
15886 if let Some(instance_id) = instance_id {
15887 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
15888 }
15889 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
15890 }
15891 NodeRequest::PutHarnessMcpReplyChunk {
15892 reservation_id, activation_digest, record_id, session, call_id,
15893 offset, final_chunk, chunk_hex,
15894 } => {
15895 shared.require_controller(connection_id, role)?;
15896 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15897 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15898 })?.put_reply_chunk(
15899 &reservation_id, &activation_digest, &record_id, &session, &call_id,
15900 offset, final_chunk, &chunk_hex,
15901 ).map_err(harness_mcp_failure)?;
15902 Ok(NodeResponse::ReplyChunkAccepted {
15903 reservation_id, activation_digest, record_id, session, call_id,
15904 next_offset, completed,
15905 })
15906 }
15907 NodeRequest::RejectHarnessMcpCall {
15908 reservation_id, activation_digest, record_id, session, call_id, reason,
15909 } => {
15910 shared.require_controller(connection_id, role)?;
15911 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15912 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15913 })?.reject_call(
15914 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
15915 ).map_err(harness_mcp_failure)?;
15916 Ok(NodeResponse::CallRejected {
15917 reservation_id, activation_digest, record_id, session, call_id,
15918 })
15919 }
15920 NodeRequest::BeginDeliveryStage { manifest } => {
15921 shared.require_controller(connection_id, role)?;
15922 shared.begin_delivery_stage(manifest)
15923 }
15924 NodeRequest::PutDeliveryBlobChunk {
15925 stage_id,
15926 blob_digest,
15927 offset,
15928 chunk_hex,
15929 } => {
15930 shared.require_controller(connection_id, role)?;
15931 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
15932 }
15933 NodeRequest::CommitDeliveryStage { stage_id } => {
15934 shared.require_controller(connection_id, role)?;
15935 let receipt = shared.commit_delivery_stage(stage_id)?;
15936 Ok(NodeResponse::DeliveryCommitted { receipt })
15937 }
15938 NodeRequest::AbortDeliveryStage { stage_id } => {
15939 shared.require_controller(connection_id, role)?;
15940 shared.abort_delivery_stage(&stage_id)?;
15941 Ok(NodeResponse::DeliveryStageAborted { stage_id })
15942 }
15943 NodeRequest::BrowseHostDirectories { directory, after } => {
15944 let listing = shared.browse_host_directories(directory, after).await?;
15945 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
15946 }
15947 NodeRequest::InspectWorkspace { workspace_id } => {
15948 let inspection = shared.inspect_workspace(workspace_id).await?;
15949 Ok(NodeResponse::WorkspaceInspected { inspection })
15950 }
15951 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
15952 let file = shared.read_workspace_file(workspace_id, path).await?;
15953 Ok(NodeResponse::WorkspaceFileRead { file })
15954 }
15955 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
15956 shared.require_controller(connection_id, role)?;
15957 let file = shared
15958 .write_workspace_file(workspace_id, path, expected_revision, text)
15959 .await?;
15960 Ok(NodeResponse::WorkspaceFileWritten { file })
15961 }
15962 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
15963 shared.require_controller(connection_id, role)?;
15964 let file = shared.create_workspace_file(workspace_id, path).await?;
15965 Ok(NodeResponse::WorkspaceFileCreated { file })
15966 }
15967 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
15968 shared.require_controller(connection_id, role)?;
15969 let entry = shared
15970 .create_workspace_directory(workspace_id.clone(), path)
15971 .await?;
15972 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
15973 }
15974 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
15975 if role != ClientRole::Operator {
15976 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
15977 }
15978 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
15979 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
15980 }
15981 let page = shared
15982 .read_git_history(workspace_id.clone(), path, before, limit)
15983 .await?;
15984 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
15985 }
15986 NodeRequest::ReadGitDiff { workspace_id, request } => {
15987 if role != ClientRole::Operator {
15988 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
15989 }
15990 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
15991 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
15992 }
15993 NodeRequest::CatalogNativeSessions { route, limit } => {
15994 if role != ClientRole::Operator {
15995 return Err(failure(
15996 NodeFailureCode::ObserverReadOnly,
15997 "native session catalog requires an operator connection",
15998 ));
15999 }
16000 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16001 return Err(failure(
16002 NodeFailureCode::InvalidRequest,
16003 "native session catalog limit is invalid",
16004 ));
16005 }
16006 let (entries, summary) = shared
16007 .catalog_native_sessions(route.clone(), limit)
16008 .await?;
16009 Ok(NodeResponse::NativeSessionsCataloged {
16010 route,
16011 entries,
16012 summary: Some(summary),
16013 })
16014 }
16015 NodeRequest::PageNativeSessions {
16016 route,
16017 window,
16018 catalog_revision,
16019 recent_cutoff_unix_ms,
16020 after_selection_id,
16021 limit,
16022 } => {
16023 if role != ClientRole::Operator {
16024 return Err(failure(
16025 NodeFailureCode::ObserverReadOnly,
16026 "native session catalog paging requires an operator connection",
16027 ));
16028 }
16029 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16030 || after_selection_id
16031 .as_deref()
16032 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16033 {
16034 return Err(failure(
16035 NodeFailureCode::InvalidRequest,
16036 "native session catalog page request is invalid",
16037 ));
16038 }
16039 let page = shared
16040 .page_native_sessions(
16041 route.clone(),
16042 window,
16043 catalog_revision,
16044 recent_cutoff_unix_ms,
16045 after_selection_id,
16046 limit,
16047 )
16048 .await?;
16049 Ok(NodeResponse::NativeSessionsPaged {
16050 route,
16051 page,
16052 })
16053 }
16054 NodeRequest::PreviewNativeSession {
16055 selection,
16056 message_limit,
16057 } => {
16058 if role != ClientRole::Operator {
16059 return Err(failure(
16060 NodeFailureCode::ObserverReadOnly,
16061 "native session preview requires an operator connection",
16062 ));
16063 }
16064 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16065 .contains(&message_limit)
16066 || selection.validate().is_err()
16067 {
16068 return Err(failure(
16069 NodeFailureCode::InvalidRequest,
16070 "native session preview request is invalid",
16071 ));
16072 }
16073 let preview = shared
16074 .preview_native_session(selection.clone(), message_limit)
16075 .await?;
16076 Ok(NodeResponse::NativeSessionPreviewed {
16077 selection,
16078 preview,
16079 })
16080 }
16081 NodeRequest::IndexNativeSession {
16082 selection,
16083 display_name,
16084 } => {
16085 shared.require_controller(connection_id, role)?;
16086 let record = shared
16087 .index_native_session(selection.clone(), display_name)
16088 .await?;
16089 Ok(NodeResponse::NativeSessionIndexed { selection, record })
16090 }
16091 NodeRequest::PreviewSessionRecord {
16092 record_id,
16093 message_limit,
16094 } => {
16095 if role != ClientRole::Operator {
16096 return Err(failure(
16097 NodeFailureCode::ObserverReadOnly,
16098 "native session preview requires an operator connection",
16099 ));
16100 }
16101 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16102 .contains(&message_limit)
16103 {
16104 return Err(failure(
16105 NodeFailureCode::InvalidRequest,
16106 "native session preview request is invalid",
16107 ));
16108 }
16109 let record = shared.record(&record_id)?;
16110 let working_directory = shared.workspace_root(&record.workspace_id)?;
16111 if !platform::roots_equal(
16112 &working_directory,
16113 windows_path_text(&record.canonical_root),
16114 ) {
16115 return Err(failure(
16116 NodeFailureCode::SessionRecordConflict,
16117 "managed session workspace root changed; refusing to preview another directory",
16118 ));
16119 }
16120 let identity = record.provider_session.clone().ok_or_else(|| {
16121 failure(
16122 NodeFailureCode::BackendOperationFailed,
16123 "native session preview is unavailable for this session record",
16124 )
16125 })?;
16126 if identity.key != ProviderSessionKey::SessionId {
16127 return Err(failure(
16128 NodeFailureCode::BackendOperationFailed,
16129 "native session preview is unavailable for this session identity",
16130 ));
16131 }
16132 let preview = shared
16133 .preview_native_session_by_session_id(
16134 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16135 )
16136 .await?;
16137 if preview.session_id != identity.id {
16138 return Err(failure(
16139 NodeFailureCode::SessionRecordConflict,
16140 "native session preview identity changed while loading",
16141 ));
16142 }
16143 shared.revalidate_session_record_preview(&record, &identity)?;
16144 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16145 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16146 record_id: record_id.clone(),
16147 summary: SessionHistorySummaryV1::from(&preview),
16148 });
16149 Ok(NodeResponse::SessionRecordPreviewed {
16150 record_id,
16151 preview,
16152 })
16153 }
16154 NodeRequest::AcquireController { lease_ms } => {
16155 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16156 Ok(NodeResponse::Controller { controller: Some(controller) })
16157 }
16158 NodeRequest::ReleaseController => {
16159 shared.release_controller(connection_id);
16160 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16161 }
16162 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16163 shared.require_controller(connection_id, role)?;
16164 let root = require_windows_path(root)?;
16165 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16166 let workspace = shared.register_workspace(workspace_id, root).await?;
16167 Ok(NodeResponse::WorkspaceRegistered { workspace })
16168 }
16169 NodeRequest::CreateStandaloneWorkspace {
16170 workspace_id,
16171 root,
16172 initial_branch,
16173 } => {
16174 shared.require_controller(connection_id, role)?;
16175 let root = require_windows_path(root)?;
16176 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16177 let workspace = shared
16178 .create_standalone_workspace(workspace_id, root, initial_branch)
16179 .await?;
16180 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16181 }
16182 NodeRequest::UnregisterWorkspace { workspace_id } => {
16183 shared.require_controller(connection_id, role)?;
16184 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16185 shared.unregister_workspace(&workspace_id)?;
16186 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16187 }
16188 NodeRequest::CreateWorktree {
16189 source_workspace_id,
16190 workspace_id,
16191 target_root,
16192 branch,
16193 base,
16194 } => {
16195 shared.require_controller(connection_id, role)?;
16196 let target_root = require_windows_path(target_root)?;
16197 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16198 let (worktree, workspace) = shared
16199 .create_worktree(
16200 source_workspace_id,
16201 workspace_id,
16202 target_root,
16203 branch,
16204 base,
16205 )
16206 .await?;
16207 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16208 }
16209 NodeRequest::RemoveWorktree {
16210 source_workspace_id,
16211 target_root,
16212 } => {
16213 shared.require_controller(connection_id, role)?;
16214 let native_target_root = require_windows_path(target_root.clone())?;
16215 shared.reject_managed_reservation(None, Some(&native_target_root))?;
16216 let workspace_id = shared
16217 .remove_worktree(source_workspace_id, native_target_root)
16218 .await?;
16219 Ok(NodeResponse::WorktreeRemoved {
16220 target_root,
16221 workspace_id,
16222 })
16223 }
16224 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16225 shared.require_controller(connection_id, role)?;
16226 if !terminal_size.is_valid() {
16227 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16228 }
16229 if let Some(prompt) = initial_prompt.as_deref() {
16230 validate_node_text("spawn initial prompt", prompt)?;
16231 }
16232 match shared
16233 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16234 .await
16235 {
16236 Ok(session) => {
16237 shared.mark_spawn_provisional(session.clone());
16238 Ok(NodeResponse::SpawnAccepted { session })
16239 }
16240 Err(err) => {
16241 tracing::warn!(
16242 %provider,
16243 %workspace_id,
16244 ?mode,
16245 code = ?err.code,
16246 message = %err.message,
16247 "session spawn refused"
16248 );
16249 Err(err)
16250 }
16251 }
16252 }
16253 NodeRequest::SpawnSpec { spec } => {
16254 shared.require_controller(connection_id, role)?;
16255 let receipt = shared.spawn_from_spec(spec).await?;
16256 Ok(NodeResponse::SpawnSpecAccepted { receipt })
16257 }
16258 NodeRequest::SpawnManagedWorktree { request } => {
16259 shared.require_controller(connection_id, role)?;
16260 let receipt = shared.spawn_managed_worktree(request).await?;
16261 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16262 }
16263 NodeRequest::SpawnManagedWorktreeV2 { request } => {
16264 shared.require_controller(connection_id, role)?;
16265 let receipt = shared.spawn_managed_worktree_v2(request).await?;
16266 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16267 }
16268 NodeRequest::CleanupManagedWorktree { lease_id } => {
16269 shared.require_controller(connection_id, role)?;
16270 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16271 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16272 }
16273 NodeRequest::DiscoverHistory { session, limit } => {
16274 controlled_session(shared, connection_id, role, &session)?;
16275 let candidates = shared.discover_history(&session, limit).await?;
16276 Ok(NodeResponse::HistoryDiscovered {
16277 session,
16278 candidates,
16279 })
16280 }
16281 NodeRequest::LoadHistory {
16282 session,
16283 candidate_id,
16284 } => {
16285 controlled_session(shared, connection_id, role, &session)?;
16286 let loaded = shared
16287 .load_history(&session, candidate_id)
16288 .await?;
16289 Ok(NodeResponse::HistoryLoaded {
16290 session,
16291 session_id: loaded.session_id,
16292 message_count: loaded.message_count,
16293 completed_turn_count: loaded.completed_turn_count,
16294 })
16295 }
16296 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16297 shared.require_controller(connection_id, role)?;
16298 let context = shared
16299 .export_context_pack_for_session_record(&record_id, &session, false)
16300 .await?;
16301 Ok(NodeResponse::ContextPackForSessionRecordExported {
16302 record_id,
16303 session,
16304 context,
16305 })
16306 }
16307 NodeRequest::ExportContextPack { session } => {
16308 controlled_session(shared, connection_id, role, &session)?;
16309 let context = shared.export_context_pack(&session).await?;
16310 Ok(NodeResponse::ContextPackExported { context })
16311 }
16312 NodeRequest::ForgetContextPack { context_id } => {
16313 shared.require_controller(connection_id, role)?;
16314 shared.forget_context_pack(&context_id)?;
16315 Ok(NodeResponse::ContextPackForgotten { context_id })
16316 }
16317 NodeRequest::ResolveDurableContextPack { context_id } => {
16318 let context = shared
16322 .context_catalog
16323 .read()
16324 .unwrap_or_else(|poisoned| poisoned.into_inner())
16325 .get(&context_id)
16326 .map(|pack| pack.receipt().clone())
16327 .ok_or_else(|| {
16328 failure(
16329 NodeFailureCode::UnknownContextPack,
16330 "context pack is not present",
16331 )
16332 })?;
16333 Ok(NodeResponse::DurableContextPackResolved { context })
16334 }
16335 NodeRequest::ReadContextPack { digest } => {
16336 let (id, byte_len, bytes) = shared
16341 .context_catalog
16342 .read()
16343 .unwrap_or_else(|poisoned| poisoned.into_inner())
16344 .get_by_digest(&digest)
16345 .map(|pack| {
16346 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16347 })
16348 .ok_or_else(|| {
16349 failure(
16350 NodeFailureCode::UnknownContextPack,
16351 &format!("context pack {digest} not on this node"),
16352 )
16353 })?;
16354 tracing::debug!(
16355 digest = digest.as_str(),
16356 byte_len,
16357 "read context pack bytes",
16358 );
16359 Ok(NodeResponse::ContextPackBytesRead {
16360 pack: ContextPackBytesRead {
16361 digest,
16362 id,
16363 byte_len,
16364 bytes,
16365 },
16366 })
16367 }
16368 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16369 let provider = controlled_session(shared, connection_id, role, &session)?;
16370 if let Some(prompt) = initial_prompt.as_deref() {
16371 validate_node_text("resume initial prompt", prompt)?;
16372 }
16373 let runtime_requirement = if initial_prompt.is_some() {
16374 ProviderRuntimeRequirement::ResumeWithPrompt
16375 } else {
16376 ProviderRuntimeRequirement::Resume
16377 };
16378 shared.require_session_runtime_policy(&session, runtime_requirement)?;
16379 let runtime_policy = shared
16380 .admit_provider_runtime(&provider, runtime_requirement)
16381 .await?;
16382 let working_directory = shared.bound_workspace_root(&session)?;
16383 let request = ResumeLaunchRequest {
16384 working_directory,
16385 terminal_size,
16386 initial_prompt,
16387 };
16388 request
16389 .validate()
16390 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16391 let command = shared.prepare_command(ControlCommand::Resume {
16392 instance_id: session.session.instance_id,
16393 target: ResumeTarget::CurrentProvider,
16394 runtime_policy,
16395 request,
16396 });
16397 shared.arm_resume(&session, command.id, runtime_policy)?;
16398 let dispatch = shared.dispatch_envelope(command);
16399 if let Err(error) = dispatch {
16400 shared.clear_armed_resume(&session);
16401 return Err(error);
16402 }
16403 Ok(NodeResponse::Accepted)
16404 }
16405 NodeRequest::RenameSessionRecord {
16406 record_id,
16407 display_name,
16408 } => {
16409 shared.require_controller(connection_id, role)?;
16410 let record = shared.rename_session_record(&record_id, display_name)?;
16411 Ok(NodeResponse::SessionRecordUpdated { record })
16412 }
16413 NodeRequest::SetSessionTask {
16414 record_id,
16415 expected_revision,
16416 target,
16417 } => {
16418 shared.require_controller(connection_id, role)?;
16419 let record = shared.set_session_task(&record_id, expected_revision, target)?;
16420 Ok(NodeResponse::SessionRecordUpdated { record })
16421 }
16422 NodeRequest::IndexProviderSession {
16423 workspace_id,
16424 provider,
16425 identity,
16426 display_name,
16427 } => {
16428 shared.require_controller(connection_id, role)?;
16429 let record = shared.index_provider_session(
16430 workspace_id,
16431 provider,
16432 identity,
16433 display_name,
16434 )?;
16435 Ok(NodeResponse::ProviderSessionIndexed { record })
16436 }
16437 NodeRequest::ResumeSessionRecord {
16438 record_id,
16439 terminal_size,
16440 initial_prompt,
16441 } => {
16442 shared.require_controller(connection_id, role)?;
16443 if let Some(prompt) = initial_prompt.as_deref() {
16444 validate_node_text("managed resume initial prompt", prompt)?;
16445 }
16446 let (record, session) = shared
16447 .resume_session_record(
16448 &record_id,
16449 terminal_size,
16450 initial_prompt,
16451 )
16452 .await?;
16453 Ok(NodeResponse::SessionRecordResumed { record, session })
16454 }
16455 NodeRequest::ForgetSessionRecord { record_id } => {
16456 shared.require_controller(connection_id, role)?;
16457 shared.forget_session_record(&record_id).await?;
16458 Ok(NodeResponse::SessionRecordForgotten { record_id })
16459 }
16460 NodeRequest::Prompt { session, text } => {
16461 let agent_id = controlled_session(shared, connection_id, role, &session)?;
16462 validate_node_text("prompt", &text)?;
16463 shared.require_session_runtime_policy(
16464 &session,
16465 ProviderRuntimeRequirement::SemanticPrompt,
16466 )?;
16467 let framing = prompt_framing(&agent_id);
16468 shared
16469 .dispatch_input_bounded(
16470 &session,
16471 InputAction::SubmitPrompt(PromptPayload { text, framing }),
16472 )
16473 .await?;
16474 Ok(NodeResponse::Accepted)
16475 }
16476 NodeRequest::Paste { session, text } => {
16477 controlled_session(shared, connection_id, role, &session)?;
16478 validate_node_text("paste", &text)?;
16479 shared.require_session_runtime_policy(
16480 &session,
16481 ProviderRuntimeRequirement::SemanticPrompt,
16482 )?;
16483 shared
16484 .dispatch_input_bounded(
16485 &session,
16486 InputAction::InsertDraft(PromptPayload {
16487 text,
16488 framing: PromptFraming::BracketedPaste,
16489 }),
16490 )
16491 .await?;
16492 Ok(NodeResponse::Accepted)
16493 }
16494 NodeRequest::Input { session, text } => {
16495 controlled_session(shared, connection_id, role, &session)?;
16496 validate_node_text("terminal input", &text)?;
16497 shared
16498 .dispatch_input_bounded(
16499 &session,
16500 InputAction::TerminalText(TerminalText { text }),
16501 )
16502 .await?;
16503 Ok(NodeResponse::Accepted)
16504 }
16505 NodeRequest::TerminalBytes { session, bytes } => {
16506 controlled_session(shared, connection_id, role, &session)?;
16507 shared.sessionize_spawn(session.session.instance_id);
16508 let action = terminal_bytes_action(bytes)?;
16509 shared
16510 .dispatch_input_bounded(&session, action)
16511 .await?;
16512 Ok(NodeResponse::Accepted)
16513 }
16514 NodeRequest::TerminalControl { session, control } => {
16515 controlled_session(shared, connection_id, role, &session)?;
16516 shared
16517 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16518 .await?;
16519 Ok(NodeResponse::Accepted)
16520 }
16521 NodeRequest::Resize { session, size } => {
16522 controlled_session(shared, connection_id, role, &session)?;
16523 shared.sessionize_spawn(session.session.instance_id);
16524 if !size.is_valid() {
16525 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16526 }
16527 shared.dispatch_resize_bounded(&session, size).await?;
16528 Ok(NodeResponse::Accepted)
16529 }
16530 NodeRequest::Interrupt { session } => {
16531 controlled_session(shared, connection_id, role, &session)?;
16532 shared
16533 .dispatch_input_bounded(
16534 &session,
16535 InputAction::TerminalControl(TerminalControl::Interrupt),
16536 )
16537 .await?;
16538 Ok(NodeResponse::Accepted)
16539 }
16540 NodeRequest::Stop { session, force } => {
16541 controlled_session(shared, connection_id, role, &session)?;
16542 shared.sessionize_spawn(session.session.instance_id);
16543 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16544 Ok(NodeResponse::Accepted)
16545 }
16546 NodeRequest::Remove { session } => {
16547 controlled_session(shared, connection_id, role, &session)?;
16548 shared.remove_session(&session).await?;
16549 Ok(NodeResponse::Accepted)
16550 }
16551 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16552 shared.require_controller(connection_id, role)?;
16553 let snapshot = shared.require_acp_session(&session)?;
16554 let interaction = snapshot
16555 .provider
16556 .interactions
16557 .iter()
16558 .find(|interaction| {
16559 interaction_correlation(
16560 session.session.instance_id,
16561 session.session.generation,
16562 interaction.id.0,
16563 ) == correlation_id
16564 })
16565 .ok_or_else(|| failure(
16566 NodeFailureCode::InvalidRequest,
16567 "unknown interaction correlation",
16568 ))?;
16569 if interaction.status != ProviderInteractionStatus::Pending {
16570 return Err(failure(
16571 NodeFailureCode::BackendOperationFailed,
16572 "interaction has already been resolved",
16573 ));
16574 }
16575 response
16576 .validate_for(interaction.interaction_kind)
16577 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16578 let interaction_id = interaction.id;
16579 let response_kind = response.kind();
16580 let response_outcome = response.outcome();
16581 shared.dispatch(ControlCommand::ResolveInteraction {
16582 instance_id: session.session.instance_id,
16583 generation: session.session.generation,
16584 interaction_id,
16585 response,
16586 })?;
16587 tracing::info!(
16593 node_id = %shared.node_id,
16594 session = ?session.session,
16595 correlation_id = %correlation_id,
16596 interaction_id = interaction_id.0,
16597 response_kind = ?response_kind,
16598 outcome = ?response_outcome,
16599 "interaction resolved",
16600 );
16601 Ok(NodeResponse::Accepted)
16602 }
16603 NodeRequest::SetSessionMode { session, mode_id } => {
16604 shared.require_controller(connection_id, role)?;
16605 shared.require_acp_session(&session)?;
16606 shared.dispatch(ControlCommand::SetSessionMode {
16607 instance_id: session.session.instance_id,
16608 mode_id,
16609 })?;
16610 Ok(NodeResponse::Accepted)
16611 }
16612 NodeRequest::SetSessionConfigOption {
16613 session,
16614 option_id,
16615 value_json,
16616 } => {
16617 shared.require_controller(connection_id, role)?;
16618 shared.require_acp_session(&session)?;
16619 shared.dispatch(ControlCommand::SetSessionConfigOption {
16620 instance_id: session.session.instance_id,
16621 option_id,
16622 value_json,
16623 })?;
16624 Ok(NodeResponse::Accepted)
16625 }
16626 NodeRequest::SetSessionModel { session, model_id } => {
16627 shared.require_controller(connection_id, role)?;
16628 shared.require_acp_session(&session)?;
16629 shared.dispatch(ControlCommand::SetSessionModel {
16630 instance_id: session.session.instance_id,
16631 model_id,
16632 })?;
16633 Ok(NodeResponse::Accepted)
16634 }
16635 NodeRequest::Shutdown => {
16636 shared.require_controller(connection_id, role)?;
16637 shared.begin_shutdown_locked().await?;
16638 Ok(NodeResponse::ShuttingDown)
16639 }
16640 }
16641}
16642
16643fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16644 shared.require_controller(connection_id, role)?;
16645 shared.validate_address(session)
16646}
16647
16648fn select_history_candidate(
16654 candidates: &[HistoryCandidateSummary],
16655 provider_session_id: Option<&str>,
16656) -> Result<String, NodeFailure> {
16657 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16658 Some(id) => candidate.session_id_hint == id,
16659 None => true,
16660 });
16661 let candidate = matches.next().ok_or_else(|| {
16662 failure(
16663 NodeFailureCode::SessionRecordConflict,
16664 if provider_session_id.is_some() {
16665 "no history candidate matches the managed provider session"
16666 } else {
16667 "no history candidate exists for the managed session"
16668 },
16669 )
16670 })?;
16671 if matches.next().is_some() {
16672 return Err(failure(
16673 NodeFailureCode::SessionRecordConflict,
16674 if provider_session_id.is_some() {
16675 "history identity is ambiguous for the managed provider session"
16676 } else {
16677 "history is ambiguous without a verified provider session identity"
16678 },
16679 ));
16680 }
16681 Ok(candidate.id.clone())
16682}
16683
16684fn resolved_spawn_profile_summary(
16685 profile: &SpawnProfileDefaults,
16686 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16687) -> Option<SpawnProfileSummary> {
16688 let environment_profile = match profile.environment_profile_id.as_ref() {
16689 None => None,
16690 Some(profile_id) => {
16691 let binding = environment_profiles.get(profile_id)?;
16692 if binding.provider != profile.provider
16693 || binding.native_profile_id(profile.mode).is_none()
16694 {
16695 return None;
16696 }
16697 Some(ResolvedEnvironmentProfileReceipt {
16698 profile_id: binding.id.clone(),
16699 profile_revision: binding.revision.clone(),
16700 network_allowlist: None,
16701 browser_profile_id: None,
16702 })
16703 }
16704 };
16705 Some(SpawnProfileSummary {
16706 id: profile.profile_id.clone(),
16707 revision: profile.revision.clone(),
16708 environment_profile,
16709 })
16710}
16711
16712fn session_record_export_target_matches(
16731 expected: &ManagedSessionRecord,
16732 current: &ManagedSessionRecord,
16733 session: &SessionAddress,
16734 allow_clean_detachment: bool,
16735) -> bool {
16736 if current == expected && current.active_session.as_ref() == Some(session) {
16737 return current.state == ManagedSessionState::Live;
16738 }
16739 allow_clean_detachment
16740 && current.state == ManagedSessionState::Dormant
16741 && current.active_session.is_none()
16742 && current.record_id == expected.record_id
16743 && current.display_name == expected.display_name
16744 && current.provider == expected.provider
16745 && current.mode == expected.mode
16746 && current.workspace_id == expected.workspace_id
16747 && current.canonical_root == expected.canonical_root
16748 && current.provider_session == expected.provider_session
16749 && current.environment_profile == expected.environment_profile
16750 && current.bundle == expected.bundle
16751 && current.context_id == expected.context_id
16752 && current.context == expected.context
16753 && current.exported_context == expected.exported_context
16754 && current.task_binding == expected.task_binding
16755 && current.created_at_unix_ms == expected.created_at_unix_ms
16756 && current.last_error == expected.last_error
16757}
16758
16759fn session_record_context_export_binding_is_exact(
16760 expected: &ManagedSessionRecord,
16761 current: &ManagedSessionRecord,
16762 identity: Option<&ProviderSessionIdentity>,
16763 session: &SessionAddress,
16764 binding: &SessionBinding,
16765 provider: &AgentId,
16766 current_root: &str,
16767 allow_clean_detachment: bool,
16768) -> bool {
16769 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
16770 && current.provider_session.as_ref() == identity
16771 && current.workspace_id == session.workspace_id
16772 && provider == ¤t.provider
16773 && binding.workspace_id == session.workspace_id
16774 && binding.generation == session.session.generation
16775 && binding.record_id.as_ref() == Some(¤t.record_id)
16776 && platform::roots_equal(
16777 current_root,
16778 windows_path_text(¤t.canonical_root),
16779 )
16780}
16781
16782fn session_record_context_export_source_is_usable(
16783 record: &ManagedSessionRecord,
16784 status: &SessionStatus,
16785 allow_clean_detachment: bool,
16786) -> bool {
16787 (record.state == ManagedSessionState::Live
16788 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
16789 && context_pack_source_status_is_usable(status)
16790}
16791
16792fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
16793 PromptFraming::BracketedPaste
16794}
16795
16796fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
16797 if text.len() > MAX_NODE_TEXT_BYTES {
16798 return Err(failure(
16799 NodeFailureCode::InvalidRequest,
16800 &format!(
16801 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
16802 text.len(),
16803 ),
16804 ));
16805 }
16806 Ok(())
16807}
16808
16809fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
16810 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
16811 return Err(failure(
16812 NodeFailureCode::InvalidRequest,
16813 &format!(
16814 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
16815 bytes.len(),
16816 ),
16817 ));
16818 }
16819 Ok(())
16820}
16821
16822fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
16823 validate_terminal_bytes(&bytes)?;
16824 Ok(InputAction::TerminalBytes(bytes))
16825}
16826
16827fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
16828 if root.is_empty()
16829 || root.len() > MAX_WORKSPACE_ROOT_BYTES
16830 || root.chars().any(char::is_control)
16831 {
16832 return Err(failure(
16833 NodeFailureCode::InvalidWorkspaceRoot,
16834 &format!(
16835 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
16836 ),
16837 ));
16838 }
16839 Ok(())
16840}
16841
16842fn canonical_standalone_workspace_candidate(
16843 workspace_id: &WorkspaceId,
16844 root: &str,
16845) -> Result<String, NodeServerError> {
16846 let path = Path::new(root);
16847 match std::fs::symlink_metadata(path) {
16848 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
16849 .map(|workspace| workspace.canonical_root),
16850 Err(error) if error.kind() == io::ErrorKind::NotFound => {
16851 if !path.is_absolute() {
16852 return Err(NodeServerError::InvalidWorkspaceRoot {
16853 workspace_id: workspace_id.clone(),
16854 path: root.to_owned(),
16855 message: "path is not absolute".to_owned(),
16856 });
16857 }
16858 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16859 workspace_id: workspace_id.clone(),
16860 path: root.to_owned(),
16861 message: "path has no standalone directory name".to_owned(),
16862 })?;
16863 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16864 workspace_id: workspace_id.clone(),
16865 path: root.to_owned(),
16866 message: "path has no parent directory".to_owned(),
16867 })?;
16868 let canonical = std::fs::canonicalize(parent)
16869 .map(|parent| parent.join(name))
16870 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
16871 workspace_id: workspace_id.clone(),
16872 path: root.to_owned(),
16873 message: error.to_string(),
16874 })?;
16875 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
16876 NodeServerError::InvalidWorkspaceRoot {
16877 workspace_id: workspace_id.clone(),
16878 path: path.to_string_lossy().into_owned(),
16879 message: "canonical path is not valid Unicode".to_owned(),
16880 }
16881 })?;
16882 let canonical_root = platform::normalize_canonical_root(canonical_root);
16883 validate_workspace_root(workspace_id, &canonical_root)?;
16884 Ok(canonical_root)
16885 }
16886 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
16887 workspace_id: workspace_id.clone(),
16888 path: root.to_owned(),
16889 message: error.to_string(),
16890 }),
16891 }
16892}
16893
16894fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
16895 let Some(revision) = revision else {
16896 return Ok(());
16897 };
16898 if revision.is_empty()
16899 || revision.len() > MAX_REPOSITORY_PATH_BYTES
16900 || revision.chars().any(char::is_control)
16901 {
16902 return Err(failure(
16903 NodeFailureCode::InvalidRequest,
16904 &format!(
16905 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
16906 ),
16907 ));
16908 }
16909 Ok(())
16910}
16911
16912fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16913 let code = match error.kind {
16914 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
16915 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
16916 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
16917 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
16918 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
16919 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
16920 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
16921 };
16922 failure(code, &error.message)
16923}
16924
16925fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
16926 let code = match error.kind {
16927 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
16928 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
16929 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
16930 NodeFailureCode::BackendOperationFailed
16931 }
16932 StandaloneWorkspaceErrorKind::RecoveryRequired => {
16933 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
16934 }
16935 };
16936 failure(code, error.message)
16937}
16938
16939fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16940 let (code, message) = match error.kind {
16941 GitWorktreeErrorKind::Invalid => (
16942 NodeFailureCode::InvalidRequest,
16943 "managed Git input is invalid",
16944 ),
16945 GitWorktreeErrorKind::NotRepository => (
16946 NodeFailureCode::NotGitRepository,
16947 "managed source is not a Git repository",
16948 ),
16949 GitWorktreeErrorKind::Conflict => (
16950 NodeFailureCode::WorktreeConflict,
16951 "managed Git identity conflicts with existing repository state",
16952 ),
16953 GitWorktreeErrorKind::Protected => (
16954 NodeFailureCode::ManagedWorktreeOwnershipConflict,
16955 "managed worktree ownership validation failed",
16956 ),
16957 GitWorktreeErrorKind::Dirty => (
16958 NodeFailureCode::WorktreeDirty,
16959 "managed worktree contains uncommitted changes",
16960 ),
16961 GitWorktreeErrorKind::Locked => (
16962 NodeFailureCode::WorktreeLocked,
16963 "managed worktree is locked",
16964 ),
16965 GitWorktreeErrorKind::Failed => (
16966 NodeFailureCode::BackendOperationFailed,
16967 "managed Git operation failed",
16968 ),
16969 };
16970 NodeFailure { code, message: message.to_owned() }
16971}
16972
16973fn managed_spawn_receipt(
16974 resolved: &ResolvedSpawnSpec,
16975 incarnation_id: NodeIncarnationId,
16976 session: SessionAddress,
16977 lease: ManagedWorktreeLeaseSnapshot,
16978 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
16979 bundle: Option<ResolvedBundleReceipt>,
16980 context: Option<ResolvedContextPackReceipt>,
16981) -> ManagedWorktreeSpawnReceipt {
16982 let mut managed = resolved.clone();
16983 managed.target.worktree_id = Some(lease.workspace_id.clone());
16984 ManagedWorktreeSpawnReceipt {
16985 spawn: managed.receipt_with_materialization(
16986 incarnation_id,
16987 session,
16988 environment_profile,
16989 bundle,
16990 context,
16991 ),
16992 lease,
16993 }
16994}
16995
16996fn validate_selected_worktree(
16997 source_root: &str,
16998 selected_root: &str,
16999 worktrees: &[NativeGitWorktreeSnapshot],
17000) -> Result<(), NodeFailure> {
17001 if !worktrees
17002 .iter()
17003 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17004 {
17005 return Err(failure(
17006 NodeFailureCode::WorktreeProtected,
17007 "source workspace is not an authoritative non-bare Git worktree root",
17008 ));
17009 }
17010 if !worktrees.iter().any(|worktree| {
17011 worktree_paths_equal(&worktree.path, selected_root)
17012 && !worktree.is_main
17013 && !worktree.is_bare
17014 }) {
17015 return Err(failure(
17016 NodeFailureCode::WorktreeProtected,
17017 "selected workspace is not an eligible Git worktree in the source repository",
17018 ));
17019 }
17020 Ok(())
17021}
17022
17023fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17024 let code = match error.kind() {
17025 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17026 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17027 NodeFailureCode::RepositoryPathUnsafe
17028 }
17029 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17030 WorkspaceFileReadErrorKind::NotRegularFile => {
17031 NodeFailureCode::RepositoryFileNotRegular
17032 }
17033 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17034 NodeFailureCode::RepositoryFileReadFailed
17035 }
17036 WorkspaceFileReadErrorKind::RevisionConflict => {
17037 NodeFailureCode::RepositoryFileRevisionConflict
17038 }
17039 WorkspaceFileReadErrorKind::AlreadyExists
17040 | WorkspaceFileReadErrorKind::ParentNotFound
17041 | WorkspaceFileReadErrorKind::ParentNotDirectory
17042 | WorkspaceFileReadErrorKind::Canceled => {
17043 NodeFailureCode::RepositoryFileReadFailed
17044 }
17045 };
17046 failure(code, "repository file read failed")
17047}
17048
17049fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17050 let code = match error.kind() {
17051 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17052 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17053 NodeFailureCode::RepositoryPathUnsafe
17054 }
17055 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17056 WorkspaceFileReadErrorKind::NotRegularFile => {
17057 NodeFailureCode::RepositoryFileNotRegular
17058 }
17059 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17060 NodeFailureCode::RepositoryFileWriteFailed
17061 }
17062 WorkspaceFileReadErrorKind::RevisionConflict => {
17063 NodeFailureCode::RepositoryFileRevisionConflict
17064 }
17065 WorkspaceFileReadErrorKind::AlreadyExists
17066 | WorkspaceFileReadErrorKind::ParentNotFound
17067 | WorkspaceFileReadErrorKind::ParentNotDirectory
17068 | WorkspaceFileReadErrorKind::Canceled => {
17069 NodeFailureCode::RepositoryFileWriteFailed
17070 }
17071 };
17072 failure(code, "repository file write failed")
17073}
17074
17075async fn settle_workspace_entry_create<T>(
17076 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17077 commit_state: Arc<AtomicU8>,
17078 deadline: Duration,
17079 timeout_message: &'static str,
17080 task_failure_message: &'static str,
17081) -> Result<T, NodeFailure> {
17082 let joined = match timeout(deadline, &mut task).await {
17083 Ok(joined) => joined,
17084 Err(_) => {
17085 if commit_state
17086 .compare_exchange(
17087 WORKSPACE_ENTRY_CREATE_PENDING,
17088 WORKSPACE_ENTRY_CREATE_CANCELED,
17089 Ordering::AcqRel,
17090 Ordering::Acquire,
17091 )
17092 .is_ok()
17093 {
17094 return Err(failure(
17095 NodeFailureCode::RepositoryEntryCreateTimedOut,
17096 timeout_message,
17097 ));
17098 }
17099 debug_assert_eq!(
17100 commit_state.load(Ordering::Acquire),
17101 WORKSPACE_ENTRY_CREATE_COMMITTING,
17102 );
17103 task.await
17104 }
17105 };
17106 joined
17107 .map_err(|_| {
17108 failure(
17109 NodeFailureCode::RepositoryEntryCreateFailed,
17110 task_failure_message,
17111 )
17112 })?
17113 .map_err(workspace_entry_create_failure)
17114}
17115
17116fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17117 let code = match error.kind() {
17118 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17119 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17120 NodeFailureCode::RepositoryPathUnsafe
17121 }
17122 WorkspaceFileReadErrorKind::AlreadyExists => {
17123 NodeFailureCode::RepositoryEntryAlreadyExists
17124 }
17125 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17126 NodeFailureCode::RepositoryParentNotFound
17127 }
17128 WorkspaceFileReadErrorKind::ParentNotDirectory
17129 | WorkspaceFileReadErrorKind::NotRegularFile => {
17130 NodeFailureCode::RepositoryParentNotDirectory
17131 }
17132 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17133 NodeFailureCode::RepositoryEntryCreateFailed
17134 }
17135 WorkspaceFileReadErrorKind::RevisionConflict => {
17136 NodeFailureCode::RepositoryEntryCreateFailed
17137 }
17138 WorkspaceFileReadErrorKind::Canceled => {
17139 NodeFailureCode::RepositoryEntryCreateTimedOut
17140 }
17141 };
17142 failure(code, "repository entry create failed")
17143}
17144
17145fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17146 let value = digest(&SHA256, bytes)
17147 .as_ref()
17148 .iter()
17149 .map(|byte| format!("{byte:02x}"))
17150 .collect();
17151 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17152}
17153
17154fn managed_spawn_request_digest_v2(
17155 request: &ManagedWorktreeSpawnRequestV2,
17156) -> Result<String, NodeFailure> {
17157 let encoded = serde_json::to_vec(request).map_err(|_| {
17158 failure(
17159 NodeFailureCode::InvalidRequest,
17160 "managed worktree V2 request could not be canonicalized",
17161 )
17162 })?;
17163 let mut value = String::with_capacity(71);
17164 value.push_str("sha256:");
17165 for byte in digest(&SHA256, &encoded).as_ref() {
17166 use std::fmt::Write as _;
17167 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17168 }
17169 Ok(value)
17170}
17171
17172fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17173 let code = match error {
17174 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17175 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17176 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17177 NativeSessionPreviewError::UnsupportedProvider
17178 | NativeSessionPreviewError::PreviewUnavailable
17179 | NativeSessionPreviewError::SessionNotFound
17180 | NativeSessionPreviewError::AmbiguousSession => {
17181 NodeFailureCode::BackendOperationFailed
17182 }
17183 };
17184 failure(code, "native session preview failed")
17185}
17186
17187fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17188 let code = match error.kind() {
17189 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17190 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17191 };
17192 failure(code, "host directory browse failed")
17193}
17194
17195fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17196 let code = match error {
17197 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17198 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17199 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17200 NodeFailureCode::DeliveryStageConflict
17201 }
17202 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17203 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17204 NodeFailureCode::DeliveryChunkOutOfOrder
17205 }
17206 DeliveryStoreError::BlobDigestMismatch => {
17207 NodeFailureCode::DeliveryBlobDigestMismatch
17208 }
17209 DeliveryStoreError::BundleDigestMismatch => {
17210 NodeFailureCode::DeliveryBundleDigestMismatch
17211 }
17212 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17213 DeliveryStoreError::Unavailable
17214 | DeliveryStoreError::Corrupt
17215 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17216 };
17217 failure(code, "delivery operation failed")
17218}
17219
17220fn validate_workspace_root(
17221 workspace_id: &WorkspaceId,
17222 canonical_root: &str,
17223) -> Result<(), NodeServerError> {
17224 #[cfg(windows)]
17225 if canonical_root.starts_with(r"\\") {
17226 return Err(NodeServerError::InvalidWorkspaceRoot {
17227 workspace_id: workspace_id.clone(),
17228 path: canonical_root.to_owned(),
17229 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17230 });
17231 }
17232 if !platform::workspace_root_supported(canonical_root)
17233 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17234 {
17235 return Err(NodeServerError::InvalidWorkspaceRoot {
17236 workspace_id: workspace_id.clone(),
17237 path: canonical_root.to_owned(),
17238 message: "canonical path must be absolute, bounded, and free of control characters"
17239 .to_owned(),
17240 });
17241 }
17242 Ok(())
17243}
17244
17245fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17246 NodeFailure {
17247 code,
17248 message: node_failure_category(code).to_owned(),
17249 }
17250}
17251
17252fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17253 let code = match error {
17254 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17255 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17256 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17257 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17258 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17259 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17260 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17261 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17262 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17263 };
17264 failure(code, "harness MCP proxy operation failed")
17265}
17266
17267static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17271
17272fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17279 let trace = launch.trace.as_ref()?;
17280 let trace_dir = std::env::var(&trace.dir_env).ok()?;
17281 if trace_dir.is_empty() {
17282 return None;
17283 }
17284 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17285 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17286 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17287}
17288
17289fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17290 let remaining = deadline.saturating_duration_since(Instant::now());
17291 if remaining.is_zero() {
17292 Err(failure(
17293 NodeFailureCode::SpawnDeadlineExceeded,
17294 "spawn deadline elapsed",
17295 ))
17296 } else {
17297 Ok(remaining)
17298 }
17299}
17300
17301fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17302 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17303 match deadline {
17304 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17305 None => Ok(bounded),
17306 }
17307}
17308
17309fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17310 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17311 failure(
17312 NodeFailureCode::SpawnDeadlineExceeded,
17313 "spawn dispatch exceeded the spawn deadline",
17314 )
17315 } else {
17316 error
17317 }
17318}
17319
17320fn spawn_runtime_capabilities(
17321 capabilities: &SpawnRequiredCapabilities,
17322) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17323 capabilities
17324 .iter()
17325 .map(|capability| match capability.as_str() {
17326 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17327 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17328 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17329 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17330 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17331 }
17332 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17333 _ => Err(failure(
17334 NodeFailureCode::UnsupportedSpawnCapability,
17335 "spawn required capability is unknown",
17336 )),
17337 })
17338 .collect()
17339}
17340
17341fn persistence_failure(_error: io::Error) -> NodeFailure {
17342 NodeFailure {
17343 code: NodeFailureCode::BackendOperationFailed,
17344 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17345 }
17346}
17347
17348fn node_failure_category(code: NodeFailureCode) -> &'static str {
17349 match code {
17350 NodeFailureCode::InvalidRequest => "invalid-request",
17351 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17352 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17353 NodeFailureCode::ReservationNotFound => "reservation-not-found",
17354 NodeFailureCode::ReservationConflict => "reservation-conflict",
17355 NodeFailureCode::ReservationExpired => "reservation-expired",
17356 NodeFailureCode::BindingMismatch => "binding-mismatch",
17357 NodeFailureCode::NotActivated => "not-activated",
17358 NodeFailureCode::CallNotFound => "call-not-found",
17359 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17360 NodeFailureCode::ResponseTooLarge => "response-too-large",
17361 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17362 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17363 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17364 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17365 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17366 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17367 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17368 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17369 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17370 NodeFailureCode::Unauthorized => "unauthorized",
17371 NodeFailureCode::ObserverReadOnly => "observer-read-only",
17372 NodeFailureCode::ControllerBusy => "controller-busy",
17373 NodeFailureCode::ControllerRequired => "controller-required",
17374 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17375 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17376 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17377 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17378 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17379 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17380 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17381 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17382 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17383 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17384 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17385 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17386 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17387 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17388 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17389 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17390 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17391 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17392 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17393 NodeFailureCode::GitReadFailed => "git-read-failed",
17394 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17395 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17396 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17397 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17398 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17399 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17400 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17401 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17402 NodeFailureCode::TurnInFlight => "turn-in-flight",
17403 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17404 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17405 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17406 "unsupported-network-allowlist-mapping"
17407 }
17408 NodeFailureCode::BrowserStationProbeUnavailable => {
17409 "browser-station-probe-unavailable"
17410 }
17411 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17412 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17413 NodeFailureCode::EnvironmentProfileBindingMismatch => {
17414 "environment-profile-binding-mismatch"
17415 }
17416 NodeFailureCode::UnknownBundle => "unknown-bundle",
17417 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17418 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17419 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17420 NodeFailureCode::ContextPackBusy => "context-pack-busy",
17421 NodeFailureCode::ContextPackMaterializationFailed => {
17422 "context-pack-materialization-failed"
17423 }
17424 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17425 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17426 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17427 NodeFailureCode::WorkspaceBusy => "workspace-busy",
17428 NodeFailureCode::LastWorkspace => "last-workspace",
17429 NodeFailureCode::NotGitRepository => "not-git-repository",
17430 NodeFailureCode::WorktreeConflict => "worktree-conflict",
17431 NodeFailureCode::WorktreeProtected => "worktree-protected",
17432 NodeFailureCode::WorktreeDirty => "worktree-dirty",
17433 NodeFailureCode::WorktreeLocked => "worktree-locked",
17434 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17435 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17436 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17437 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17438 "managed-worktree-profile-revision-mismatch"
17439 }
17440 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17441 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17442 "standalone-workspace-recovery-required"
17443 }
17444 NodeFailureCode::UnknownSession => "unknown-session",
17445 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17446 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17447 NodeFailureCode::SessionRecordBusy => "session-record-busy",
17448 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17449 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17450 NodeFailureCode::WorkspaceRegistrationRequired => {
17451 "workspace-registration-required"
17452 }
17453 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17454 NodeFailureCode::StaleGeneration => "stale-generation",
17455 NodeFailureCode::BackendBusy => "backend-busy",
17456 NodeFailureCode::BackendDisconnected => "backend-disconnected",
17457 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17458 NodeFailureCode::ShuttingDown => "shutting-down",
17459 }
17460}
17461
17462fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17463 NodeServerError::DurableState(io::Error::new(error.kind(), category))
17464}
17465
17466fn durable_state_load_error(error: io::Error) -> NodeServerError {
17467 let category = match session_registry::state_load_refusal(&error) {
17468 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17469 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17470 }
17471 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17472 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17473 }
17474 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17475 DURABLE_STATE_CONFLICT_ERROR
17476 }
17477 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17478 };
17479 durable_state_server_error(error, category)
17480}
17481
17482fn unix_time_ms() -> u64 {
17483 std::time::SystemTime::now()
17484 .duration_since(std::time::UNIX_EPOCH)
17485 .unwrap_or_default()
17486 .as_millis()
17487 .min(u64::MAX as u128) as u64
17488}
17489
17490fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17491 if record.provider_session.is_some() {
17492 ManagedSessionState::Dormant
17493 } else {
17494 ManagedSessionState::Unavailable
17495 }
17496}
17497
17498fn active_record_state(
17499 identity_admitted: bool,
17500 provider_session_is_known: bool,
17501) -> ManagedSessionState {
17502 if identity_admitted && !provider_session_is_known {
17503 ManagedSessionState::IdentityPending
17504 } else {
17505 ManagedSessionState::Live
17506 }
17507}
17508
17509fn managed_worktree_record_holders(
17510 records: &[ManagedSessionRecord],
17511) -> Vec<(SessionRecordId, WorkspaceId)> {
17512 records
17513 .iter()
17514 .filter(|record| record.provider_session.is_some())
17515 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17516 .collect()
17517}
17518
17519fn managed_spawn_record_ownership(
17520 binding: &SessionBinding,
17521 effective_runtime_policy: ProviderRuntimePolicy,
17522) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17523 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17524 if effective_runtime_policy.provider_session_identity {
17525 (binding.record_id.clone(), None)
17526 } else {
17527 (None, binding.record_id.clone())
17528 }
17529}
17530
17531#[derive(Debug, Error)]
17532pub enum NodeServerError {
17533 #[error("node endpoint must be a bounded absolute local transport path")]
17534 InvalidEndpoint,
17535 #[error("node access token must contain 1..=4096 bytes")]
17536 InvalidAccessToken,
17537 #[error("node HTTP observer must listen on a loopback address: {0}")]
17538 InvalidApiListen(std::net::SocketAddr),
17539 #[error("node browser bridge must listen on a loopback address: {0}")]
17540 InvalidBridgeListen(std::net::SocketAddr),
17541 #[error("node browser bridge token must contain 1..=4096 bytes")]
17542 InvalidBridgeToken,
17543 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17544 BridgeUnderlayRequiresBridgeListen,
17545 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17546 BridgeUnderlayRequiresBridgeToken,
17547 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17548 InvalidBridgeUnderlayToken,
17549 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17550 InvalidBridgeUnderlayKey,
17551 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17552 InvalidCallHome(std::net::SocketAddr),
17553 #[error("node could not reach its call-home relay: {0}")]
17554 CallHomeTransport(#[source] std::io::Error),
17555 #[error("node durable state path must be an absolute file path: {0}")]
17556 InvalidStatePath(String),
17557 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17558 InvalidHarnessMcpHelper,
17559 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17560 InvalidNetworkAllowlistCatalogPath(String),
17561 #[error("network allowlist catalog could not be read: {0}")]
17562 NetworkAllowlistCatalogIo(#[source] std::io::Error),
17563 #[error("network allowlist catalog line {line}: {message}")]
17564 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17565 #[error("network allowlist catalog: {0}")]
17566 InvalidNetworkAllowlistCatalog(String),
17567 #[error("network allowlist catalog exceeds the {max}-entry limit")]
17568 NetworkAllowlistCatalogCapacity { max: usize },
17569 #[error("the local state directory is unavailable; supply an explicit state path")]
17570 LocalStateDirectoryUnavailable,
17571 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17572 LocalRuntimeDirectoryUnavailable,
17573 #[error("node durable state failed: {0}")]
17574 DurableState(io::Error),
17575 #[error("node requires at least one configured workspace")]
17576 NoWorkspaces,
17577 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17578 InvalidWorkspaceRoot {
17579 workspace_id: WorkspaceId,
17580 path: String,
17581 message: String,
17582 },
17583 #[error("duplicate workspace ID: {0}")]
17584 DuplicateWorkspaceId(WorkspaceId),
17585 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17586 DuplicateWorkspaceRoot {
17587 first: WorkspaceId,
17588 second: WorkspaceId,
17589 root: String,
17590 },
17591 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17592 ManagedWorktreeProfileRequired(WorkspaceId),
17593 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17594 ManagedWorktreeProfileModeMismatch(WorkspaceId),
17595 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17596 DuplicateManagedWorktreeProfile {
17597 workspace_id: WorkspaceId,
17598 profile_id: WorktreeProfileId,
17599 },
17600 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17601 ManagedWorktreeProfileCapacity {
17602 workspace_id: WorkspaceId,
17603 max: usize,
17604 },
17605 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17606 InvalidManagedWorktreeProfile {
17607 workspace_id: WorkspaceId,
17608 profile_id: WorktreeProfileId,
17609 message: String,
17610 },
17611 #[error("duplicate node environment profile: {0}")]
17612 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17613 #[error("node environment profile provider is unavailable: {0}")]
17614 EnvironmentProfileProviderUnavailable(AgentId),
17615 #[error("node environment profile capacity is {max}")]
17616 EnvironmentProfileCapacity { max: usize },
17617 #[error("node environment profiles reuse native profile ID '{0}'")]
17618 DuplicateNativeEnvironmentProfile(String),
17619 #[error("node native environment profile is invalid: {0}")]
17620 NativeEnvironmentProfile(String),
17621 #[error("node session-environment materialization root must be absolute")]
17622 InvalidSessionEnvironmentRoot,
17623 #[error("node session-environment materializer is unavailable")]
17624 SessionEnvironmentMaterializerRequired,
17625 #[error("node session-environment materializer failed to initialize")]
17626 SessionEnvironmentMaterializer,
17627 #[error("node bundle catalog is invalid: {0}")]
17628 BundleCatalog(String),
17629 #[error("node delivery store failed to initialize: {0}")]
17630 DeliveryStore(String),
17631 #[error("node context pack store failed to initialize")]
17632 ContextPackStore,
17633 #[error("active agent registry failed: {0}")]
17634 Registry(String),
17635 #[error("node provider contract manifest is invalid: {0}")]
17636 ProviderContractManifest(String),
17637 #[error("named pipe I/O failed: {0}")]
17638 Io(#[from] io::Error),
17639 #[error("node HTTP observer failed: {0}")]
17640 HttpApi(io::Error),
17641 #[error("node browser bridge failed: {0}")]
17642 BridgeApi(io::Error),
17643 #[error(transparent)]
17644 Frame(#[from] FrameError),
17645 #[error("node handshake failed: {0}")]
17646 Handshake(String),
17647 #[error("node authentication frame was not received before the bounded deadline")]
17648 AuthenticationTimedOut,
17649 #[error("node authentication primitive failed: {0}")]
17650 Authentication(String),
17651 #[error("node incarnation identity generation failed: {0}")]
17652 IncarnationIdentity(String),
17653 #[error("node pre-authentication limiter is closed")]
17654 PreauthClosed,
17655 #[error("node authenticated connection limit was reached")]
17656 AuthenticatedConnectionLimit,
17657 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17658 SignalStreamClosed,
17659 #[error("node stop-all dispatch failed: {0:?}")]
17660 ShutdownDispatch(NodeFailure),
17661 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17662 ShutdownTimedOut { active_native_sessions: usize },
17663}
17664
17665impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17666 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17667 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17668 match error {
17669 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17670 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17671 E::InvalidEntry { line, message } => {
17672 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17673 }
17674 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17675 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17676 }
17677 }
17678}
17679
17680#[cfg(test)]
17681mod agent_stream_blocked_tests {
17682 use super::*;
17683 use gate4agent_types::{
17684 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17685 ProviderSource,
17686 };
17687
17688 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17689 ProviderSource {
17690 family,
17691 binding: AdapterBinding::new(
17692 AdapterId::new(adapter).unwrap(),
17693 "fixture/v1",
17694 AdapterVerification::SyntheticFixture,
17695 )
17696 .unwrap(),
17697 }
17698 }
17699
17700 fn provider_control_event_at(
17701 family: AdapterFamily,
17702 adapter: &str,
17703 source_sequence: u64,
17704 provider_event: ProviderEvent,
17705 ) -> ControlEvent {
17706 ControlEvent {
17707 sequence: 41,
17708 command_id: None,
17709 instance_id: AgentInstanceId(7),
17710 generation: SessionGeneration(3),
17711 event: ControlEventKind::ProviderEvent {
17712 sequence: 8,
17713 source: provider_source(family, adapter),
17714 source_sequence,
17715 event: provider_event,
17716 },
17717 }
17718 }
17719
17720 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17721 provider_control_event_at(family, "fixture", 9, provider_event)
17722 }
17723
17724 fn denied_host_request(
17725 by: ProviderHostDecisionAuthority,
17726 reason: Option<String>,
17727 ) -> ControlEvent {
17728 provider_control_event_at(
17729 AdapterFamily::Acp,
17730 "claude-code",
17731 2,
17732 ProviderEvent::HostRequestObserved {
17733 method: "terminal/create".to_owned(),
17734 params_json: String::new(),
17735 decision: ProviderHostRequestDecision::Denied { by },
17736 outcome: ProviderHostRequestOutcome::Executed,
17737 reason,
17738 },
17739 )
17740 }
17741
17742 fn blocked(
17743 event: &ControlEvent,
17744 ) -> (
17745 Option<String>,
17746 String,
17747 BlockAuthorityV1,
17748 Option<String>,
17749 String,
17750 Option<String>,
17751 ) {
17752 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17753 chunk.validate().expect("minted chunk must validate");
17754 let AgentStreamChunkKindV1::Blocked {
17755 correlation_id,
17756 tool_class,
17757 authority,
17758 reason_kind,
17759 reason,
17760 help,
17761 } = chunk.kind
17762 else {
17763 panic!("expected AgentStreamChunkKindV1::Blocked");
17764 };
17765 (correlation_id, tool_class, authority, reason_kind, reason, help)
17766 }
17767
17768 #[test]
17772 fn the_enabled_provider_matrix_is_exact() {
17773 let registry = active_registry().unwrap();
17774 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
17775 assert_eq!(
17776 providers
17777 .iter()
17778 .map(|contract| contract.provider.as_str())
17779 .collect::<Vec<_>>(),
17780 vec!["claude", "codex", "grok", "kimi"],
17781 );
17782 assert!(adapters.iter().all(|contract| {
17783 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
17784 }));
17785 }
17786
17787 #[test]
17790 fn gate_denied_host_request_mints_a_blocked_chunk() {
17791 let gate_text =
17792 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
17793 let event = denied_host_request(
17794 ProviderHostDecisionAuthority::Gate,
17795 Some(gate_text.to_owned()),
17796 );
17797 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
17798 assert_eq!(chunk.source_sequence, 2);
17799 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
17800 assert_eq!(authority, BlockAuthorityV1::HostGate);
17801 assert_eq!(reason, gate_text);
17802 assert_eq!(correlation_id, None);
17803 assert_eq!(reason_kind, None);
17804 assert_eq!(help, None);
17805 assert_eq!(tool_class, "Write");
17806 }
17807
17808 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.";
17809
17810 #[test]
17816 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
17817 let event = provider_control_event(
17818 AdapterFamily::Acp,
17819 ProviderEvent::ToolCompleted {
17820 id: "toolu_9".to_owned(),
17821 output: CLASSIFIER_SAMPLE.to_owned(),
17822 is_error: true,
17823 duration_ms: None,
17824 agent_id: None,
17825 non_execution_kind: Some("permission-rule".to_owned()),
17826 },
17827 );
17828 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
17829 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
17830 assert_eq!(reason, "Blocked by classifier");
17831 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
17832 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
17833 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
17834 }
17835
17836 #[test]
17841 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
17842 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.";
17843 let event = provider_control_event(
17844 AdapterFamily::Acp,
17845 ProviderEvent::SessionEnded {
17846 result: "JSON-RPC error -32603: Internal error".to_owned(),
17847 cost_usd: None,
17848 is_error: true,
17849 stop_reason: Some(ProviderStopReason::ProviderError {
17850 code: -32603,
17851 message: message.to_owned(),
17852 vendor_code: Some("usageLimitExceeded".to_owned()),
17853 }),
17854 },
17855 );
17856 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
17857 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
17858 assert_eq!(reason, message);
17859 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
17860 assert_eq!(tool_class, "Session");
17861 }
17862
17863 #[test]
17866 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
17867 let event = provider_control_event(
17868 AdapterFamily::Acp,
17869 ProviderEvent::SessionEnded {
17870 result: "refusal".to_owned(),
17871 cost_usd: None,
17872 is_error: true,
17873 stop_reason: Some(ProviderStopReason::Refusal),
17874 },
17875 );
17876 let (_, _, authority, _, _, _) = blocked(&event);
17877 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
17878 }
17879
17880 #[test]
17884 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
17885 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
17886 let event = provider_control_event(
17887 AdapterFamily::Acp,
17888 ProviderEvent::ToolCompleted {
17889 id: "toolu_1".to_owned(),
17890 output: "command exited with status 1".to_owned(),
17891 is_error: true,
17892 duration_ms: Some(12),
17893 agent_id: None,
17894 non_execution_kind,
17895 },
17896 );
17897 assert!(agent_stream_chunk(&event).is_none());
17898 }
17899 let narrative = provider_control_event(
17900 AdapterFamily::Acp,
17901 ProviderEvent::ToolCompleted {
17902 id: "toolu_2".to_owned(),
17903 output: "the harness blocked the write, approval needed".to_owned(),
17904 is_error: false,
17905 duration_ms: Some(3),
17906 agent_id: None,
17907 non_execution_kind: None,
17908 },
17909 );
17910 assert!(agent_stream_chunk(&narrative).is_none());
17911 }
17912
17913 #[test]
17917 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
17918 for (decision, outcome) in [
17919 (
17920 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17921 ProviderHostRequestOutcome::Executed,
17922 ),
17923 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
17924 (
17925 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17926 ProviderHostRequestOutcome::Failed {
17927 error: "terminal/create spawn failed: os error 3".to_owned(),
17928 },
17929 ),
17930 ] {
17931 let event = provider_control_event_at(
17932 AdapterFamily::Acp,
17933 "claude-code",
17934 3,
17935 ProviderEvent::HostRequestObserved {
17936 method: "fs/read_text_file".to_owned(),
17937 params_json: String::new(),
17938 decision: decision.clone(),
17939 outcome,
17940 reason: None,
17941 },
17942 );
17943 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
17944 }
17945 }
17946
17947 #[test]
17951 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
17952 for (by, expected_authority, expected_reason) in [
17953 (
17954 ProviderHostDecisionAuthority::Operator,
17955 BlockAuthorityV1::Operator,
17956 "declined by the operator",
17957 ),
17958 (
17959 ProviderHostDecisionAuthority::DeadlinePolicy,
17960 BlockAuthorityV1::HostDeadline,
17961 "denied by host policy after the operator deadline expired",
17962 ),
17963 ] {
17964 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
17965 assert_eq!(authority, expected_authority);
17966 assert_eq!(reason, expected_reason);
17967 }
17968 }
17969
17970 #[test]
17974 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
17975 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
17976 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
17977 ProviderHostDecisionAuthority::Policy,
17978 Some(oversized),
17979 ));
17980 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
17981 }
17982}
17983
17984#[cfg(test)]
17985mod standalone_capability_platform_tests {
17986 use super::*;
17987
17988 #[test]
17989 fn standalone_workspace_capability_requires_windows_identity_guards() {
17990 let capabilities = baseline_capabilities().unwrap();
17991 let advertised = capabilities.iter().any(|capability| {
17992 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
17993 });
17994 assert_eq!(advertised, cfg!(windows));
17995
17996 let request = NodeRequest::CreateStandaloneWorkspace {
17997 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
17998 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
17999 initial_branch: None,
18000 };
18001 assert_eq!(
18002 request_uses_unnegotiated_capability(&request, &capabilities),
18003 !cfg!(windows),
18004 );
18005 }
18006}
18007
18008
18009#[cfg(test)]
18010mod station_knobs_resolve_tests {
18011 use super::*;
18012 use gate4agent_catalog::EnvMutation;
18013 use gate4agent_runtime_native::{
18014 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18015 NativeLaunchProfileId,
18016 };
18017 use std::ffi::OsString;
18018 use std::sync::Arc;
18019
18020 struct EmptyEnvironmentResolver;
18021
18022 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18023 fn resolve_child_environment(
18024 &self,
18025 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18026 Ok(vec![EnvMutation {
18027 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18028 value: None,
18029 }])
18030 }
18031 }
18032
18033 fn agent(value: &str) -> AgentId {
18034 AgentId::new(value).unwrap()
18035 }
18036
18037 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18038 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18039 let profile_revision =
18040 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18041 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18042 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18043 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18044 provider: agent("claude"),
18045 mode: SessionMode::Pty,
18046 terminal_size: gate4agent_types::TerminalSize {
18047 rows: 24,
18048 columns: 80,
18049 },
18050 prompt: None,
18051 bundle_id: None,
18052 context_id: None,
18053 environment_profile_id: Some(profile_id.clone()),
18054 };
18055 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18056 let catalog = active_registry().unwrap();
18057 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18058 let control = runtime.native_launch_profile_control();
18059 let node_profile = NodeEnvironmentProfile::new(
18060 profile_id.clone(),
18061 profile_revision,
18062 agent("claude"),
18063 [NativeLaunchProfile::new(
18064 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18065 agent("claude"),
18066 TransportKind::Pty,
18067 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18068 Arc::new(EmptyEnvironmentResolver),
18069 )
18070 .unwrap()],
18071 )
18072 .unwrap();
18073 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18074 for native_profile in native_profiles {
18075 runtime.upsert_native_launch_profile(native_profile).unwrap();
18076 }
18077 let workspace = WorkspaceConfig::new(
18078 WorkspaceId::new("primary").unwrap(),
18079 std::env::current_dir().unwrap(),
18080 )
18081 .unwrap();
18082 let shared = NodeShared::new_with_incarnation(
18083 handle,
18084 "fixture-token".to_owned(),
18085 NodeId::new("node-station-knobs-test").unwrap(),
18086 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18087 vec![workspace],
18088 vec![agent("claude")],
18089 ProviderRuntimeStatuses::default(),
18090 None,
18091 Vec::new(),
18092 Vec::new(),
18093 spawn_profiles,
18094 Some(control),
18095 None,
18096 None,
18097 None,
18098 Vec::new(),
18099 Vec::new(),
18100 Vec::new(),
18101 Vec::new(),
18102 None,
18103 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18104 );
18105 shared
18106 .environment_profiles
18107 .write()
18108 .unwrap_or_else(|poisoned| poisoned.into_inner())
18109 .insert(binding.id.clone(), binding);
18110 (shared, spawn_defaults)
18111 }
18112
18113 #[test]
18116 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18117 let (shared, defaults) = station_knobs_fixture();
18118 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18119 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18120 let spec = crate::protocol::SpawnSpec {
18121 target: crate::protocol::SpawnTarget {
18122 node_id: shared.node_id.clone(),
18123 workspace_id: WorkspaceId::new("primary").unwrap(),
18124 worktree_id: None,
18125 },
18126 profile_id: defaults.profile_id.clone(),
18127 expected_profile_revision: defaults.revision.clone(),
18128 overrides: crate::protocol::SpawnOverrides {
18129 network_allowlist: Some(unknown),
18130 browser_profile_id: Some(browser),
18131 ..crate::protocol::SpawnOverrides::default()
18132 },
18133 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18134 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18135 "station-knobs-unknown-allowlist",
18136 )
18137 .unwrap(),
18138 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18139 };
18140 let resolved = spec.resolve(&defaults).unwrap();
18141 let failure = shared
18142 .resolve_environment_profile(&resolved)
18143 .expect_err("unknown allowlist must refuse");
18144 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18145 }
18146
18147 #[test]
18153 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18154 let (shared, defaults) = station_knobs_fixture();
18155 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18156 #[cfg(not(feature = "dig2-station-probe"))]
18157 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18158 shared
18159 .network_allowlist_catalog
18160 .write()
18161 .unwrap_or_else(|poisoned| poisoned.into_inner())
18162 .insert_id(allowlist.clone())
18163 .unwrap();
18164 let spec = crate::protocol::SpawnSpec {
18165 target: crate::protocol::SpawnTarget {
18166 node_id: shared.node_id.clone(),
18167 workspace_id: WorkspaceId::new("primary").unwrap(),
18168 worktree_id: None,
18169 },
18170 profile_id: defaults.profile_id.clone(),
18171 expected_profile_revision: defaults.revision.clone(),
18172 overrides: crate::protocol::SpawnOverrides {
18173 network_allowlist: Some(allowlist.clone()),
18174 #[cfg(not(feature = "dig2-station-probe"))]
18175 browser_profile_id: Some(browser.clone()),
18176 ..crate::protocol::SpawnOverrides::default()
18177 },
18178 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18179 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18180 .unwrap(),
18181 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18182 };
18183 let resolved = spec.resolve(&defaults).unwrap();
18184 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18185 #[cfg(not(feature = "dig2-station-probe"))]
18186 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18187 let receipt = shared
18188 .resolve_environment_profile(&resolved)
18189 .expect("resolve ok")
18190 .expect("env profile present");
18191 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18192 #[cfg(not(feature = "dig2-station-probe"))]
18193 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18194 #[cfg(feature = "dig2-station-probe")]
18195 assert_eq!(receipt.browser_profile_id, None);
18196 assert_eq!(receipt.profile_id.as_str(), "local-claude");
18197
18198 let omitted = crate::protocol::SpawnSpec {
18199 overrides: crate::protocol::SpawnOverrides::default(),
18200 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18201 .unwrap(),
18202 ..spec
18203 };
18204 let resolved_omit = omitted.resolve(&defaults).unwrap();
18205 let bare = shared
18206 .resolve_environment_profile(&resolved_omit)
18207 .expect("resolve ok")
18208 .expect("env profile present");
18209 assert_eq!(bare.network_allowlist, None);
18210 assert_eq!(bare.browser_profile_id, None);
18211 }
18212
18213 #[cfg(feature = "dig2-station-probe")]
18218 #[test]
18219 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18220 let (shared, defaults) = station_knobs_fixture();
18221 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18222 let spec = crate::protocol::SpawnSpec {
18223 target: crate::protocol::SpawnTarget {
18224 node_id: shared.node_id.clone(),
18225 workspace_id: WorkspaceId::new("primary").unwrap(),
18226 worktree_id: None,
18227 },
18228 profile_id: defaults.profile_id.clone(),
18229 expected_profile_revision: defaults.revision.clone(),
18230 overrides: crate::protocol::SpawnOverrides {
18231 browser_profile_id: Some(browser),
18232 ..crate::protocol::SpawnOverrides::default()
18233 },
18234 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18235 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18236 "station-knobs-probe-refuse",
18237 )
18238 .unwrap(),
18239 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18240 };
18241 let resolved = spec.resolve(&defaults).unwrap();
18242 let failure = shared
18243 .resolve_environment_profile(&resolved)
18244 .expect_err("browser_profile with failing probe must refuse");
18245 #[cfg(windows)]
18246 {
18247 assert!(
18248 matches!(
18249 failure.code,
18250 NodeFailureCode::BrowserStationUnreachable
18251 | NodeFailureCode::BrowserStationProbeUnavailable
18252 ),
18253 "unexpected code {:?}",
18254 failure.code
18255 );
18256 }
18257 #[cfg(not(windows))]
18258 {
18259 assert_eq!(
18260 failure.code,
18261 NodeFailureCode::BrowserStationProbeUnavailable
18262 );
18263 }
18264 }
18265
18266 #[cfg(feature = "dig2-station-probe")]
18268 #[test]
18269 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18270 let (shared, defaults) = station_knobs_fixture();
18271 let spec = crate::protocol::SpawnSpec {
18272 target: crate::protocol::SpawnTarget {
18273 node_id: shared.node_id.clone(),
18274 workspace_id: WorkspaceId::new("primary").unwrap(),
18275 worktree_id: None,
18276 },
18277 profile_id: defaults.profile_id.clone(),
18278 expected_profile_revision: defaults.revision.clone(),
18279 overrides: crate::protocol::SpawnOverrides::default(),
18280 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18281 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18282 "station-knobs-probe-omit",
18283 )
18284 .unwrap(),
18285 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18286 };
18287 let resolved = spec.resolve(&defaults).unwrap();
18288 let receipt = shared
18289 .resolve_environment_profile(&resolved)
18290 .expect("omit browser_profile must not probe")
18291 .expect("env profile present");
18292 assert_eq!(receipt.browser_profile_id, None);
18293 }
18294
18295 #[cfg(feature = "dig2-station-probe")]
18299 #[test]
18300 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18301 let (shared, _defaults) = station_knobs_fixture();
18302 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18303 let workspace_id = WorkspaceId::new("primary").unwrap();
18304 let address_a = SessionAddress {
18305 workspace_id: workspace_id.clone(),
18306 session: SessionKey {
18307 instance_id: AgentInstanceId(101),
18308 generation: SessionGeneration(1),
18309 },
18310 };
18311 let address_b = SessionAddress {
18312 workspace_id: workspace_id.clone(),
18313 session: SessionKey {
18314 instance_id: AgentInstanceId(102),
18315 generation: SessionGeneration(1),
18316 },
18317 };
18318 let guard_a = crate::dig2_station_lease::acquire_lease(
18319 &shared.browser_station_leases,
18320 profile.clone(),
18321 address_a.session.instance_id,
18322 1,
18323 )
18324 .expect("first lease");
18325 let busy = crate::dig2_station_lease::acquire_lease(
18326 &shared.browser_station_leases,
18327 profile.clone(),
18328 address_b.session.instance_id,
18329 2,
18330 )
18331 .expect_err("second holder must refuse");
18332 assert!(matches!(
18333 busy,
18334 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18335 ));
18336 shared.bind_session_with_policy(
18338 &address_a,
18339 ProviderRuntimePolicy::raw_pty(),
18340 None,
18341 );
18342 guard_a.retain();
18343 assert!(shared.remove_binding(&address_a).is_some());
18344 crate::dig2_station_lease::acquire_lease(
18345 &shared.browser_station_leases,
18346 profile,
18347 address_b.session.instance_id,
18348 3,
18349 )
18350 .expect("reacquire after clear")
18351 .retain();
18352 assert!(
18353 crate::dig2_station_lease::release_lease_for_holder(
18354 &shared.browser_station_leases,
18355 address_b.session.instance_id,
18356 )
18357 );
18358 }
18359
18360 #[cfg(not(feature = "dig2-station-probe"))]
18363 #[test]
18364 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18365 let (shared, defaults) = station_knobs_fixture();
18366 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18367 let spec = crate::protocol::SpawnSpec {
18368 target: crate::protocol::SpawnTarget {
18369 node_id: shared.node_id.clone(),
18370 workspace_id: WorkspaceId::new("primary").unwrap(),
18371 worktree_id: None,
18372 },
18373 profile_id: defaults.profile_id.clone(),
18374 expected_profile_revision: defaults.revision.clone(),
18375 overrides: crate::protocol::SpawnOverrides {
18376 browser_profile_id: Some(browser.clone()),
18377 ..crate::protocol::SpawnOverrides::default()
18378 },
18379 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18380 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18381 .unwrap(),
18382 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18383 };
18384 let resolved = spec.resolve(&defaults).unwrap();
18385 let receipt = shared
18386 .resolve_environment_profile(&resolved)
18387 .expect("stub resolve")
18388 .expect("env profile");
18389 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18390 }
18391
18392
18393 #[test]
18396 fn network_allowlist_catalog_loads_from_temp_file() {
18397 let unique = std::time::SystemTime::now()
18398 .duration_since(std::time::UNIX_EPOCH)
18399 .unwrap()
18400 .as_nanos();
18401 let path = std::env::temp_dir().join(format!(
18402 "gate4agent-node-allowlist-config-{}-{unique}.txt",
18403 std::process::id(),
18404 ));
18405 std::fs::write(
18406 &path,
18407 "# opaque station allowlist ids\negress-default\nlab-egress\n",
18408 )
18409 .unwrap();
18410 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18411 .expect("temp catalog file must load");
18412 let _ = std::fs::remove_file(&path);
18413 assert_eq!(loaded.len(), 2);
18414 let (shared, defaults) = station_knobs_fixture();
18415 {
18416 let mut catalog = shared
18417 .network_allowlist_catalog
18418 .write()
18419 .unwrap_or_else(|poisoned| poisoned.into_inner());
18420 *catalog = loaded;
18421 }
18422 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18423 let spec = crate::protocol::SpawnSpec {
18424 target: crate::protocol::SpawnTarget {
18425 node_id: shared.node_id.clone(),
18426 workspace_id: WorkspaceId::new("primary").unwrap(),
18427 worktree_id: None,
18428 },
18429 profile_id: defaults.profile_id.clone(),
18430 expected_profile_revision: defaults.revision.clone(),
18431 overrides: crate::protocol::SpawnOverrides {
18432 network_allowlist: Some(allowlist.clone()),
18433 ..crate::protocol::SpawnOverrides::default()
18434 },
18435 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18436 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18437 "station-knobs-catalog-file",
18438 )
18439 .unwrap(),
18440 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18441 };
18442 let resolved = spec.resolve(&defaults).unwrap();
18443 let receipt = shared
18444 .resolve_environment_profile(&resolved)
18445 .expect("registered via file load")
18446 .expect("env profile present");
18447 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18448 }
18449
18450 #[test]
18453 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18454 let (shared, _defaults) = station_knobs_fixture();
18455 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18456 shared
18457 .network_allowlist_catalog
18458 .write()
18459 .unwrap_or_else(|poisoned| poisoned.into_inner())
18460 .insert_id(allowlist.clone())
18461 .unwrap();
18462 let snapshot = shared.snapshot();
18463 let inventory = snapshot
18464 .launch_inventory
18465 .expect("launch inventory present");
18466 let ids = inventory
18467 .network_allowlists
18468 .expect("non-empty catalog surfaces ids");
18469 assert_eq!(ids, vec![allowlist]);
18470 }
18471
18472 #[test]
18476 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18477 let (shared, defaults) = station_knobs_fixture();
18478 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18479 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18480 id: allowlist.clone(),
18481 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18482 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18483 peer: "127.0.0.1:443".to_owned(),
18484 }],
18485 provider_native: Some(
18486 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18487 codex_network_access: Some(true),
18488 },
18489 ),
18490 };
18491 shared
18492 .network_allowlist_catalog
18493 .write()
18494 .unwrap_or_else(|poisoned| poisoned.into_inner())
18495 .insert(entry)
18496 .unwrap();
18497 let spec = crate::protocol::SpawnSpec {
18498 target: crate::protocol::SpawnTarget {
18499 node_id: shared.node_id.clone(),
18500 workspace_id: WorkspaceId::new("primary").unwrap(),
18501 worktree_id: None,
18502 },
18503 profile_id: defaults.profile_id.clone(),
18504 expected_profile_revision: defaults.revision.clone(),
18505 overrides: crate::protocol::SpawnOverrides {
18506 network_allowlist: Some(allowlist),
18507 ..crate::protocol::SpawnOverrides::default()
18508 },
18509 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18510 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18511 "station-knobs-unsupported-mapping",
18512 )
18513 .unwrap(),
18514 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18515 };
18516 let resolved = spec.resolve(&defaults).unwrap();
18517 assert_eq!(resolved.provider.as_str(), "claude");
18518 let failure = shared
18519 .resolve_environment_profile(&resolved)
18520 .expect_err("claude + codex_network_access must refuse");
18521 assert_eq!(
18522 failure.code,
18523 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18524 );
18525 }
18526
18527 #[test]
18531 fn resolve_codex_network_access_overlay_moderate_only() {
18532 let (shared, defaults) = station_knobs_fixture();
18533 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18534 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18535 id: allowlist.clone(),
18536 permits: Vec::new(),
18537 provider_native: Some(
18538 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18539 codex_network_access: Some(true),
18540 },
18541 ),
18542 };
18543 shared
18544 .network_allowlist_catalog
18545 .write()
18546 .unwrap_or_else(|poisoned| poisoned.into_inner())
18547 .insert(entry)
18548 .unwrap();
18549
18550 let overlay = shared
18551 .resolve_codex_network_access_overlay_args(
18552 &agent("codex"),
18553 SessionMode::Pty,
18554 ApprovalLevel::Moderate,
18555 Some(&allowlist),
18556 )
18557 .expect("Moderate Codex maps network_access");
18558 assert_eq!(
18559 overlay,
18560 vec![
18561 std::ffi::OsString::from("-c"),
18562 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18563 ]
18564 );
18565
18566 let acp = shared
18567 .resolve_codex_network_access_overlay_args(
18568 &agent("codex"),
18569 SessionMode::Acp,
18570 ApprovalLevel::Moderate,
18571 Some(&allowlist),
18572 )
18573 .unwrap();
18574 assert_eq!(acp, overlay);
18575
18576 let inline = shared
18577 .resolve_codex_network_access_overlay_args(
18578 &agent("codex"),
18579 SessionMode::Inline,
18580 ApprovalLevel::Moderate,
18581 Some(&allowlist),
18582 )
18583 .unwrap_err();
18584 assert_eq!(
18585 inline.code,
18586 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18587 );
18588
18589 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18590 let spec = crate::protocol::SpawnSpec {
18591 target: crate::protocol::SpawnTarget {
18592 node_id: shared.node_id.clone(),
18593 workspace_id: WorkspaceId::new("primary").unwrap(),
18594 worktree_id: None,
18595 },
18596 profile_id: defaults.profile_id.clone(),
18597 expected_profile_revision: defaults.revision.clone(),
18598 overrides: crate::protocol::SpawnOverrides {
18599 provider: crate::protocol::SpawnOverride::Set {
18600 value: agent("codex"),
18601 },
18602 approval_level: Some(level),
18603 network_allowlist: Some(allowlist.clone()),
18604 ..crate::protocol::SpawnOverrides::default()
18605 },
18606 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18607 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18608 "codex-net-refuse-{}",
18609 match level {
18610 ApprovalLevel::ReadOnly => "ro",
18611 ApprovalLevel::FullAuto => "fa",
18612 _ => "other",
18613 }
18614 ))
18615 .unwrap(),
18616 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18617 };
18618 let resolved = spec.resolve(&defaults).unwrap();
18619 let failure = shared
18620 .resolve_environment_profile(&resolved)
18621 .expect_err("RO/FullAuto must refuse");
18622 assert_eq!(
18623 failure.code,
18624 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18625 );
18626 }
18627 }
18628
18629 #[test]
18633 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18634 let (shared, _defaults) = station_knobs_fixture();
18635 let allowlist =
18636 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18637 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18638 id: allowlist.clone(),
18639 permits: vec![
18640 crate::network_allowlist_catalog::NetworkPermitSketch {
18641 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18642 peer: "github.com:443".to_owned(),
18643 },
18644 crate::network_allowlist_catalog::NetworkPermitSketch {
18645 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18646 peer: "*.npmjs.org".to_owned(),
18647 },
18648 ],
18649 provider_native: None,
18650 };
18651 shared
18652 .network_allowlist_catalog
18653 .write()
18654 .unwrap_or_else(|poisoned| poisoned.into_inner())
18655 .insert(entry)
18656 .unwrap();
18657
18658 let unique = std::time::SystemTime::now()
18659 .duration_since(std::time::UNIX_EPOCH)
18660 .unwrap()
18661 .as_nanos();
18662 let provider_home = std::env::temp_dir().join(format!(
18663 "gate4agent-claude-provider-home-{}-{unique}",
18664 std::process::id(),
18665 ));
18666 std::fs::create_dir_all(&provider_home).unwrap();
18667
18668 let overlay = shared
18669 .resolve_network_allowlist_overlay_args(
18670 &agent("claude"),
18671 SessionMode::Pty,
18672 ApprovalLevel::Moderate,
18673 Some(&allowlist),
18674 Some(provider_home.as_path()),
18675 )
18676 .expect("Claude PTY settings overlay on unix");
18677 let settings_path = provider_home.join(
18678 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18679 );
18680 assert_eq!(
18681 overlay,
18682 vec![
18683 std::ffi::OsString::from("--settings"),
18684 std::ffi::OsString::from(settings_path.as_os_str()),
18685 ]
18686 );
18687 let body = std::fs::read_to_string(&settings_path).unwrap();
18688 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18689 assert_eq!(value["sandbox"]["enabled"], true);
18690 assert_eq!(
18691 value["sandbox"]["network"]["allowedDomains"],
18692 serde_json::json!(["github.com", "*.npmjs.org"])
18693 );
18694 assert!(settings_path.starts_with(&provider_home));
18695
18696 let acp = shared
18697 .resolve_network_allowlist_overlay_args(
18698 &agent("claude"),
18699 SessionMode::Acp,
18700 ApprovalLevel::Moderate,
18701 Some(&allowlist),
18702 Some(provider_home.as_path()),
18703 )
18704 .expect_err("Claude ACP must refuse settings overlay");
18705 assert_eq!(
18706 acp.code,
18707 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18708 );
18709
18710 let no_home = shared
18711 .resolve_network_allowlist_overlay_args(
18712 &agent("claude"),
18713 SessionMode::Pty,
18714 ApprovalLevel::Moderate,
18715 Some(&allowlist),
18716 None,
18717 )
18718 .expect_err("Claude overlay requires ProviderHome");
18719 assert_eq!(
18720 no_home.code,
18721 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18722 );
18723
18724 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18725 shared
18726 .network_allowlist_catalog
18727 .write()
18728 .unwrap_or_else(|poisoned| poisoned.into_inner())
18729 .insert_id(bare.clone())
18730 .unwrap();
18731 let empty = shared
18732 .resolve_network_allowlist_overlay_args(
18733 &agent("claude"),
18734 SessionMode::Pty,
18735 ApprovalLevel::Moderate,
18736 Some(&bare),
18737 Some(provider_home.as_path()),
18738 )
18739 .unwrap();
18740 assert!(empty.is_empty());
18741
18742 let _ = std::fs::remove_dir_all(&provider_home);
18743 }
18744
18745 #[test]
18747 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18748 let (shared, _defaults) = station_knobs_fixture();
18749 let allowlist =
18750 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18751 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18752 id: allowlist.clone(),
18753 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18754 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18755 peer: "example.com:443".to_owned(),
18756 }],
18757 provider_native: None,
18758 };
18759 shared
18760 .network_allowlist_catalog
18761 .write()
18762 .unwrap_or_else(|poisoned| poisoned.into_inner())
18763 .insert(entry)
18764 .unwrap();
18765 for provider in ["kimi", "grok"] {
18766 let overlay = shared
18767 .resolve_network_allowlist_overlay_args(
18768 &agent(provider),
18769 SessionMode::Pty,
18770 ApprovalLevel::Moderate,
18771 Some(&allowlist),
18772 None,
18773 )
18774 .unwrap();
18775 assert!(
18776 overlay.is_empty(),
18777 "{provider} must not invent network argv from permits"
18778 );
18779 }
18780 }
18781
18782 #[test]
18784 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
18785 let unique = std::time::SystemTime::now()
18786 .duration_since(std::time::UNIX_EPOCH)
18787 .unwrap()
18788 .as_nanos();
18789 let path = std::env::temp_dir().join(format!(
18790 "gate4agent-node-allowlist-json-{}-{unique}.json",
18791 std::process::id(),
18792 ));
18793 std::fs::write(
18794 &path,
18795 r#"{
18796 "schema_version": 2,
18797 "entries": [
18798 {
18799 "id": "egress-default",
18800 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
18801 },
18802 { "id": "lab-egress" }
18803 ]
18804 }"#,
18805 )
18806 .unwrap();
18807 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18808 .expect("json v2 catalog must load");
18809 let _ = std::fs::remove_file(&path);
18810 assert_eq!(loaded.len(), 2);
18811 let entry = loaded
18812 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
18813 .unwrap();
18814 assert_eq!(entry.permits.len(), 1);
18815 let (shared, defaults) = station_knobs_fixture();
18816 {
18817 let mut catalog = shared
18818 .network_allowlist_catalog
18819 .write()
18820 .unwrap_or_else(|poisoned| poisoned.into_inner());
18821 *catalog = loaded;
18822 }
18823 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18824 let spec = crate::protocol::SpawnSpec {
18825 target: crate::protocol::SpawnTarget {
18826 node_id: shared.node_id.clone(),
18827 workspace_id: WorkspaceId::new("primary").unwrap(),
18828 worktree_id: None,
18829 },
18830 profile_id: defaults.profile_id.clone(),
18831 expected_profile_revision: defaults.revision.clone(),
18832 overrides: crate::protocol::SpawnOverrides {
18833 network_allowlist: Some(allowlist.clone()),
18834 ..crate::protocol::SpawnOverrides::default()
18835 },
18836 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18837 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18838 "station-knobs-catalog-json",
18839 )
18840 .unwrap(),
18841 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18842 };
18843 let resolved = spec.resolve(&defaults).unwrap();
18844 let receipt = shared
18845 .resolve_environment_profile(&resolved)
18846 .expect("permits-only entry ok for claude")
18847 .expect("env profile present");
18848 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18849 let inventory = shared
18850 .snapshot()
18851 .launch_inventory
18852 .expect("launch inventory present");
18853 let ids = inventory.network_allowlists.expect("ids surface");
18854 assert_eq!(
18855 ids,
18856 vec![
18857 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
18858 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
18859 ]
18860 );
18861 }
18862
18863
18864}
18865
18866
18867#[cfg(test)]
18868mod workspace_environment_collision_tests {
18869 use super::*;
18870 use crate::session_environment::{
18871 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
18872 };
18873 use gate4agent_catalog::EnvMutation;
18874 use gate4agent_runtime_native::{
18875 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18876 NativeLaunchProfileId,
18877 };
18878 use std::ffi::OsString;
18879 use std::sync::Arc;
18880
18881 struct EmptyEnvironmentResolver;
18882
18883 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18884 fn resolve_child_environment(
18885 &self,
18886 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18887 Ok(vec![EnvMutation {
18888 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18889 value: None,
18890 }])
18891 }
18892 }
18893
18894 fn agent(value: &str) -> AgentId {
18895 AgentId::new(value).unwrap()
18896 }
18897
18898 fn claude_config_dir_collision_fixture() -> (
18900 NodeShared,
18901 ResolvedEnvironmentProfileReceipt,
18902 ResolvedEnvironmentProfileReceipt,
18903 ) {
18904 let catalog = active_registry().unwrap();
18905 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18906 let control = runtime.native_launch_profile_control();
18907 let workspace = WorkspaceConfig::new(
18908 WorkspaceId::new("primary").unwrap(),
18909 std::env::current_dir().unwrap(),
18910 )
18911 .unwrap();
18912 let shared = NodeShared::new_with_incarnation(
18913 handle,
18914 "fixture-token".to_owned(),
18915 NodeId::new("node-env-collision-test").unwrap(),
18916 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18917 vec![workspace],
18918 vec![agent("claude")],
18919 ProviderRuntimeStatuses::default(),
18920 None,
18921 Vec::new(),
18922 Vec::new(),
18923 SpawnProfileRegistry::default(),
18924 Some(control),
18925 None,
18926 None,
18927 None,
18928 Vec::new(),
18929 Vec::new(),
18930 Vec::new(),
18931 Vec::new(),
18932 None,
18933 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18934 );
18935
18936 let mut receipts = Vec::new();
18937 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
18938 let profile_id =
18939 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
18940 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
18941 "claude-config-dir-{suffix}-r1"
18942 ))
18943 .unwrap();
18944 let materialization = NodeSessionMaterializationProfile::new(
18945 Vec::new(),
18946 vec![NodeSessionPathBinding::new(
18947 "CLAUDE_CONFIG_DIR",
18948 NodeSessionPathClass::ProviderHome,
18949 )
18950 .unwrap()],
18951 Vec::new(),
18952 )
18953 .unwrap();
18954 let node_profile = NodeEnvironmentProfile::new_with_materialization(
18955 profile_id.clone(),
18956 profile_revision.clone(),
18957 agent("claude"),
18958 [NativeLaunchProfile::new(
18959 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
18960 .unwrap(),
18961 agent("claude"),
18962 TransportKind::Pty,
18963 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18964 Arc::new(EmptyEnvironmentResolver),
18965 )
18966 .unwrap()],
18967 Some(materialization),
18968 )
18969 .unwrap();
18970 let (binding, native_profiles, materialization) = node_profile.into_parts();
18971 for native_profile in native_profiles {
18972 runtime.upsert_native_launch_profile(native_profile).unwrap();
18973 }
18974 shared
18975 .environment_profiles
18976 .write()
18977 .unwrap_or_else(|poisoned| poisoned.into_inner())
18978 .insert(binding.id.clone(), binding);
18979 shared
18980 .environment_materialization_profiles
18981 .write()
18982 .unwrap_or_else(|poisoned| poisoned.into_inner())
18983 .insert(profile_id.clone(), materialization.unwrap());
18984 receipts.push(ResolvedEnvironmentProfileReceipt {
18985 profile_id,
18986 profile_revision,
18987 network_allowlist: None,
18988 browser_profile_id: None,
18989 });
18990 }
18991 let profile_b = receipts.pop().unwrap();
18992 let profile_a = receipts.pop().unwrap();
18993 (shared, profile_a, profile_b)
18994 }
18995
18996 #[test]
18997 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
18998 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18999 let primary = WorkspaceId::new("primary").unwrap();
19000 let worktree = WorkspaceId::new("worktree-b").unwrap();
19001 let address = SessionAddress {
19002 workspace_id: primary.clone(),
19003 session: SessionKey {
19004 instance_id: AgentInstanceId(41),
19005 generation: SessionGeneration(1),
19006 },
19007 };
19008 shared.bind_session_with_policy(
19009 &address,
19010 ProviderRuntimePolicy::raw_pty(),
19011 Some(profile_a.clone()),
19012 );
19013
19014 let refused = shared
19015 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19016 .unwrap_err();
19017 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19018
19019 let refused_none = shared
19021 .ensure_workspace_environment_profile_compatible(&primary, None)
19022 .unwrap_err();
19023 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19024
19025 shared
19027 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19028 .unwrap();
19029
19030 shared
19032 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19033 .unwrap();
19034 }
19035
19036 #[tokio::test]
19037 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19038 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19039 let primary = WorkspaceId::new("primary").unwrap();
19040 let address = SessionAddress {
19041 workspace_id: primary.clone(),
19042 session: SessionKey {
19043 instance_id: AgentInstanceId(41),
19044 generation: SessionGeneration(1),
19045 },
19046 };
19047 shared.bind_session_with_policy(
19048 &address,
19049 ProviderRuntimePolicy::raw_pty(),
19050 Some(profile_a),
19051 );
19052
19053 let failure = shared
19054 .spawn_session_with_deadline(
19055 primary,
19056 agent("claude"),
19057 SessionMode::Pty,
19058 gate4agent_types::TerminalSize {
19059 rows: 24,
19060 columns: 80,
19061 },
19062 None,
19063 Some(profile_b),
19064 None,
19065 None,
19066 None,
19067 None,
19068 SpawnRecordPolicy::ProviderIdentityOnly,
19069 Some(Instant::now() + Duration::from_secs(30)),
19070 &[],
19071 None,
19072 ApprovalLevel::default(),
19073 None,
19074 None,
19075 )
19076 .await
19077 .unwrap_err();
19078 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19079 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19081 assert!(shared.handle.snapshot().sessions.is_empty());
19082 }
19083}
19084
19085#[cfg(test)]
19086mod spawn_characterization_tests {
19087 use super::*;
19088 use crate::session_environment::{
19089 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19090 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19091 NodeSessionPathClass,
19092 };
19093 use gate4agent_catalog::EnvMutation;
19094 use gate4agent_runtime_native::{
19095 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19096 NativeLaunchProfile, NativeLaunchProfileId,
19097 };
19098 use std::ffi::OsString;
19099 use std::sync::Arc;
19100
19101 struct EmptyEnvironmentResolver;
19102
19103 struct FixtureSecretResolver {
19104 deny: Arc<AtomicBool>,
19105 }
19106
19107 impl NodeSecretResolver for FixtureSecretResolver {
19108 fn resolve(
19109 &self,
19110 _reference: &NodeSecretReference,
19111 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19112 if self.deny.load(Ordering::Acquire) {
19113 Err(NodeSecretResolveError::Denied)
19114 } else {
19115 NodeSecretValue::text("fixture-secret")
19116 .map_err(|_| NodeSecretResolveError::Unavailable)
19117 }
19118 }
19119 }
19120
19121 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19122 fn resolve_child_environment(
19123 &self,
19124 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19125 Ok(vec![EnvMutation {
19126 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19127 value: None,
19128 }])
19129 }
19130 }
19131
19132 fn agent(value: &str) -> AgentId {
19133 AgentId::new(value).unwrap()
19134 }
19135
19136 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19137 let unique = std::time::SystemTime::now()
19138 .duration_since(std::time::UNIX_EPOCH)
19139 .unwrap()
19140 .as_nanos();
19141 std::env::temp_dir().join(format!(
19142 "gate4agent-node-{label}-{}-{unique}",
19143 std::process::id(),
19144 ))
19145 }
19146
19147 fn materialization_test_shared(
19148 deny_secret: bool,
19149 ) -> (
19150 NodeShared,
19151 NativeRuntime,
19152 ResolvedEnvironmentProfileReceipt,
19153 PathBuf,
19154 Arc<AtomicBool>,
19155 ) {
19156 let workspace = WorkspaceConfig::new(
19157 WorkspaceId::new("primary").unwrap(),
19158 std::env::current_dir().unwrap(),
19159 )
19160 .unwrap();
19161 materialization_test_shared_with_workspace(deny_secret, workspace)
19162 }
19163
19164 fn materialization_test_shared_with_workspace(
19165 deny_secret: bool,
19166 workspace: WorkspaceConfig,
19167 ) -> (
19168 NodeShared,
19169 NativeRuntime,
19170 ResolvedEnvironmentProfileReceipt,
19171 PathBuf,
19172 Arc<AtomicBool>,
19173 ) {
19174 let root = temporary_workspace_root(if deny_secret {
19175 "materialization-denied"
19176 } else {
19177 "materialization-ready"
19178 });
19179 std::fs::create_dir_all(&root).unwrap();
19180 let materialization_root = root.join("session-environments");
19181 let deny = Arc::new(AtomicBool::new(deny_secret));
19182 let resolver = Arc::new(FixtureSecretResolver {
19183 deny: Arc::clone(&deny),
19184 });
19185 let materializer = SessionEnvironmentMaterializer::new(
19186 materialization_root,
19187 resolver,
19188 )
19189 .unwrap();
19190 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19191 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19192 "materialized-claude-r1",
19193 )
19194 .unwrap();
19195 let materialization = NodeSessionMaterializationProfile::new(
19196 vec![NodeSessionEnvironmentMutation::SetSecret {
19197 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19198 reference: NodeSecretReference::new("fixture-token").unwrap(),
19199 }],
19200 vec![NodeSessionPathBinding::new(
19201 "GATE4AGENT_PROVIDER_HOME",
19202 NodeSessionPathClass::ProviderHome,
19203 )
19204 .unwrap()],
19205 vec![NodeSessionFile::secret(
19206 NodeSessionPathClass::Config,
19207 "auth/token",
19208 NodeSecretReference::new("fixture-token").unwrap(),
19209 )
19210 .unwrap()],
19211 )
19212 .unwrap();
19213 let catalog = active_registry().unwrap();
19214 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19215 let control = runtime.native_launch_profile_control();
19216 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19217 profile_id.clone(),
19218 profile_revision.clone(),
19219 agent("claude"),
19220 [NativeLaunchProfile::new(
19221 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19222 agent("claude"),
19223 TransportKind::Pty,
19224 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19225 Arc::new(EmptyEnvironmentResolver),
19226 )
19227 .unwrap()],
19228 Some(materialization),
19229 )
19230 .unwrap();
19231 let (binding, native_profiles, materialization) = node_profile.into_parts();
19232 for native_profile in native_profiles {
19233 runtime.upsert_native_launch_profile(native_profile).unwrap();
19234 }
19235 let shared = NodeShared::new_with_incarnation(
19236 handle,
19237 "fixture-token".to_owned(),
19238 NodeId::new("node-materialization-test").unwrap(),
19239 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19240 vec![workspace],
19241 vec![agent("claude")],
19242 ProviderRuntimeStatuses::default(),
19243 None,
19244 Vec::new(),
19245 Vec::new(),
19246 SpawnProfileRegistry::default(),
19247 Some(control),
19248 None,
19249 Some(materializer),
19250 None,
19251 Vec::new(),
19252 Vec::new(),
19253 Vec::new(),
19254 Vec::new(),
19255 None,
19256 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19257 );
19258 shared
19259 .environment_profiles
19260 .write()
19261 .unwrap_or_else(|poisoned| poisoned.into_inner())
19262 .insert(binding.id.clone(), binding);
19263 shared
19264 .environment_materialization_profiles
19265 .write()
19266 .unwrap_or_else(|poisoned| poisoned.into_inner())
19267 .insert(profile_id.clone(), materialization.unwrap());
19268 (
19269 shared,
19270 runtime,
19271 ResolvedEnvironmentProfileReceipt {
19272 profile_id,
19273 profile_revision,
19274 network_allowlist: None,
19275 browser_profile_id: None,
19276 },
19277 root,
19278 deny,
19279 )
19280 }
19281
19282
19283 async fn characterization_spawn(
19286 shared: &NodeShared,
19287 mode: SessionMode,
19288 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19289 required: &[ProviderRuntimeCapability],
19290 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19291 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19292 shared
19293 .spawn_session_with_deadline(
19294 WorkspaceId::new("primary").unwrap(),
19295 agent("claude"),
19296 mode,
19297 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19298 None,
19299 environment_profile,
19300 None,
19301 None,
19302 None,
19303 Some(ProviderRuntimePolicy::raw_pty()),
19304 SpawnRecordPolicy::ProviderIdentityOnly,
19305 Some(Instant::now() + Duration::from_secs(30)),
19306 required,
19307 harness_mcp,
19308 ApprovalLevel::default(),
19309 None,
19310 None,
19311 )
19312 .await
19313 }
19314
19315 fn assert_no_session_state(shared: &NodeShared) {
19316 assert!(shared.handle.snapshot().sessions.is_empty());
19317 assert!(shared
19318 .session_bindings
19319 .lock()
19320 .unwrap_or_else(|poisoned| poisoned.into_inner())
19321 .is_empty());
19322 assert!(shared
19323 .materializations
19324 .lock()
19325 .unwrap_or_else(|poisoned| poisoned.into_inner())
19326 .is_empty());
19327 }
19328
19329 #[tokio::test]
19330 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19331 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19332 let error = characterization_spawn(
19333 &shared,
19334 SessionMode::Pty,
19335 None,
19336 &[ProviderRuntimeCapability::SemanticReadiness],
19337 None,
19338 )
19339 .await
19340 .unwrap_err();
19341 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19342 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19343 assert_no_session_state(&shared);
19344 drop(shared);
19345 drop(runtime);
19346 std::fs::remove_dir_all(root).unwrap();
19347 }
19348
19349 #[tokio::test]
19350 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19351 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19352 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19353 .await
19354 .unwrap_err();
19355 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19356 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19357 assert_no_session_state(&shared);
19358 drop(shared);
19359 drop(runtime);
19360 std::fs::remove_dir_all(root).unwrap();
19361 }
19362
19363 #[test]
19364 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19365 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19366 let address = SessionAddress {
19367 workspace_id: WorkspaceId::new("primary").unwrap(),
19368 session: SessionKey {
19369 instance_id: AgentInstanceId(77),
19370 generation: SessionGeneration(1),
19371 },
19372 };
19373 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19374 let removed = shared.remove_binding(&address).expect("binding removed");
19375 assert!(removed.environment_profile.is_some());
19376 assert!(shared.remove_binding(&address).is_none());
19377 assert_no_session_state(&shared);
19378 drop(shared);
19379 drop(runtime);
19380 std::fs::remove_dir_all(root).unwrap();
19381 }
19382
19383 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19384 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19385 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19386 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19387 .expect("review current test executable as fixture helper");
19388 let registry = HarnessMcpProxyRegistry::new(helper);
19389 let spec = crate::protocol::SpawnSpec {
19390 target: crate::protocol::SpawnTarget {
19391 node_id: NodeId::new("node-characterization").unwrap(),
19392 workspace_id: WorkspaceId::new("primary").unwrap(),
19393 worktree_id: None,
19394 },
19395 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19396 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19397 overrides: crate::protocol::SpawnOverrides::default(),
19398 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19399 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19400 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19401 };
19402 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19403 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19404 let launch = HarnessMcpLaunchV1 {
19405 server_name: "fixture-harness".to_owned(),
19406 args: vec!["--session-proxy".to_owned()],
19407 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19408 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19409 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19410 trace: None,
19411 scrub_env: Vec::new(),
19412 };
19413 let expires = unix_time_ms().saturating_add(30_000);
19414 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19415 registry
19416 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19417 .unwrap()
19418 }
19419
19420 #[tokio::test]
19421 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19422 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19423 let prepared = characterization_prepared_harness("claude").await;
19424 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19425 .await
19426 .unwrap_err();
19427 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19429 let mismatched = characterization_prepared_harness("codex").await;
19430 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19431 .await
19432 .unwrap_err();
19433 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19434 assert_no_session_state(&shared);
19435 drop(shared);
19436 drop(runtime);
19437 std::fs::remove_dir_all(root).unwrap();
19438 }
19439
19440 #[tokio::test]
19441 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19442 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19443 shared.native_launch_profile_control = None;
19444 let prepared = characterization_prepared_harness("claude").await;
19445 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19446 .await
19447 .unwrap_err();
19448 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19449 assert_no_session_state(&shared);
19450 drop(shared);
19451 drop(runtime);
19452 std::fs::remove_dir_all(root).unwrap();
19453 }
19454}
19455
19456#[cfg(all(test, windows))]
19457mod tests {
19458 use super::*;
19459 use gate4agent_types::ProviderActivity;
19460 use crate::session_environment::{
19461 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19462 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19463 NodeSessionPathClass,
19464 };
19465 use gate4agent_catalog::EnvMutation;
19466 use gate4agent_runtime_native::{
19467 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19468 NativeLaunchProfile, NativeLaunchProfileId,
19469 };
19470 use std::ffi::OsString;
19471 use std::sync::Arc;
19472
19473 struct EmptyEnvironmentResolver;
19474
19475 struct FixtureSecretResolver {
19476 deny: Arc<AtomicBool>,
19477 }
19478
19479 impl NodeSecretResolver for FixtureSecretResolver {
19480 fn resolve(
19481 &self,
19482 _reference: &NodeSecretReference,
19483 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19484 if self.deny.load(Ordering::Acquire) {
19485 Err(NodeSecretResolveError::Denied)
19486 } else {
19487 NodeSecretValue::text("fixture-secret")
19488 .map_err(|_| NodeSecretResolveError::Unavailable)
19489 }
19490 }
19491 }
19492
19493 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19494 fn resolve_child_environment(
19495 &self,
19496 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19497 Ok(vec![EnvMutation {
19498 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19499 value: None,
19500 }])
19501 }
19502 }
19503
19504 fn agent(value: &str) -> AgentId {
19505 AgentId::new(value).unwrap()
19506 }
19507
19508 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19509 let catalog = active_registry().unwrap();
19510 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19511 let workspace = WorkspaceConfig::new(
19512 WorkspaceId::new("primary").unwrap(),
19513 std::env::current_dir().unwrap(),
19514 )
19515 .unwrap()
19516 .with_worktree_service_mode(mode);
19517 NodeShared::new(
19518 handle,
19519 "fixture-token".to_owned(),
19520 NodeId::new("node-terminal-test").unwrap(),
19521 vec![workspace],
19522 vec![agent("claude")],
19523 )
19524 }
19525
19526 fn terminal_test_shared() -> NodeShared {
19527 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19528 }
19529
19530 #[cfg(feature = "fixture")]
19531 #[test]
19532 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19533 let probe = SpawnManagedWorktreeV2FailureProbe::default();
19534 assert_eq!(probe.latest_code(), None);
19535
19536 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19537 assert_eq!(
19538 probe.latest_code(),
19539 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19540 );
19541
19542 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19543 assert_eq!(
19544 probe.latest_code(),
19545 Some(NodeFailureCode::SpawnDeadlineExceeded),
19546 );
19547
19548 probe.record(None);
19549 assert_eq!(probe.latest_code(), None);
19550 }
19551
19552 #[test]
19553 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19554 let source = temporary_workspace_root("launch-inventory-source");
19555 let allocation = temporary_workspace_root("launch-inventory-allocation");
19556 std::fs::create_dir_all(&source).unwrap();
19557 std::fs::create_dir_all(&allocation).unwrap();
19558 let workspace_id = WorkspaceId::new("primary").unwrap();
19559 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19560 .unwrap()
19561 .with_worktree_service_mode(WorktreeServiceMode::Managed);
19562 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19563 workspace = workspace
19564 .with_managed_worktree_profile(
19565 ManagedWorktreeProfile::new(
19566 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19567 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19568 &allocation,
19569 "gate4agent",
19570 "HEAD",
19571 ManagedWorktreeRetention::RemoveWhenReleased,
19572 )
19573 .unwrap(),
19574 )
19575 .unwrap();
19576 }
19577 let overflow = workspace.clone().with_managed_worktree_profile(
19578 ManagedWorktreeProfile::new(
19579 WorktreeProfileId::new("overflow").unwrap(),
19580 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19581 &allocation,
19582 "gate4agent",
19583 "HEAD",
19584 ManagedWorktreeRetention::Retain,
19585 )
19586 .unwrap(),
19587 );
19588 assert!(matches!(
19589 overflow,
19590 Err(NodeServerError::ManagedWorktreeProfileCapacity {
19591 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19592 ..
19593 }),
19594 ));
19595
19596 let catalog = active_registry().unwrap();
19597 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19598 let shared = NodeShared::new(
19599 handle,
19600 "fixture-token".to_owned(),
19601 NodeId::new("node-launch-inventory").unwrap(),
19602 vec![workspace],
19603 vec![agent("claude")],
19604 );
19605 let snapshot = shared.snapshot();
19606 let launch = snapshot.launch_inventory.as_ref().unwrap();
19607 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19608 assert!(launch.bundles.as_ref().unwrap().is_empty());
19609 let profiles = snapshot.workspaces[0]
19610 .managed_worktree_profiles
19611 .as_ref()
19612 .unwrap();
19613 assert_eq!(
19614 snapshot.workspaces[0].worktree_service_mode,
19615 Some(WorktreeServiceMode::Managed),
19616 );
19617 assert_eq!(
19618 profiles.profiles.len(),
19619 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19620 );
19621 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19622 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19623 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19624 }
19625 let legacy = snapshot_for_wire(
19626 &shared,
19627 true,
19628 true,
19629 false,
19630 false,
19631 true,
19632 false,
19633 true,
19634 false,
19635 true,
19636 );
19637 assert!(legacy.launch_inventory.is_none());
19638 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19639
19640 drop(shared);
19641 std::fs::remove_dir_all(source).unwrap();
19642 std::fs::remove_dir_all(allocation).unwrap();
19643 }
19644
19645 #[test]
19646 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19647 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19648 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19649 }));
19650 let source = gate4agent_types::ProviderSource {
19651 family: AdapterFamily::PtySemantic,
19652 binding: AdapterBinding::new(
19653 gate4agent_types::AdapterId::new("claude").unwrap(),
19654 "fixture/v1",
19655 gate4agent_types::AdapterVerification::SyntheticFixture,
19656 )
19657 .unwrap(),
19658 };
19659 let provider = ProviderSnapshot {
19660 sequence: 17,
19661 session: Some(ProviderSessionIdentity {
19662 key: ProviderSessionKey::SessionId,
19663 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19664 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
19665 }),
19666 model: Some("PRIVATE_MODEL".to_owned()),
19667 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
19668 completed_turns: 3,
19669 usage: gate4agent_types::TokenUsage {
19670 input_tokens: 10,
19671 output_tokens: 20,
19672 cache_read_tokens: 30,
19673 cache_write_tokens: 40,
19674 reasoning_tokens: 50,
19675 context_window: Some(200_000),
19676 },
19677 lead_activity: ProviderActivity::WaitingForInput,
19678 activity: ProviderActivity::WaitingForInput,
19679 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
19680 active_tools: (0..9)
19681 .map(|index| gate4agent_types::ActiveProviderTool {
19682 id: format!("private-tool-id-{index}"),
19683 name: if index == 0 {
19684 format!(" Read\u{0000}{}", "x".repeat(80))
19685 } else {
19686 format!("Tool-{index}")
19687 },
19688 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
19689 })
19690 .collect(),
19691 interactions: vec![gate4agent_types::ProviderInteraction {
19692 id: gate4agent_types::ProviderInteractionId(17),
19693 source: source.clone(),
19694 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
19695 interaction_kind: ProviderInteractionKind::Approval,
19696 tool_name: " Approve\u{0007}Tool ".to_owned(),
19697 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
19698 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
19699 resume_lead_activity: Some(ProviderActivity::Working),
19700 status: ProviderInteractionStatus::Pending,
19701 }],
19702 subagents: vec![gate4agent_types::ProviderSubagent {
19703 source: source.clone(),
19704 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
19705 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
19706 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
19707 }],
19708 sources: vec![gate4agent_types::ProviderSourceCursor {
19709 source,
19710 sequence: 17,
19711 gap_count: 2,
19712 stale: true,
19713 }],
19714 last_event: Some(ProviderEvent::Error {
19715 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
19716 }),
19717 gap_count: 2,
19718 stale: true,
19719 };
19720 let address = terminal_address(3);
19721 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
19722 assert_eq!(entry.address, address);
19723 assert_eq!(entry.progress.provider_sequence, 17);
19724 assert_eq!(entry.progress.active_tool_count, 9);
19725 assert_eq!(
19726 entry.progress.active_tool_labels.len(),
19727 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
19728 );
19729 assert_eq!(entry.progress.subagent_count, 1);
19730 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
19731 assert!(entry.progress.truncated);
19732 let encoded = serde_json::to_string(&entry).unwrap();
19733 for forbidden in [
19734 "PRIVATE_PROVIDER_SESSION",
19735 "PRIVATE_PROVIDER_PATH",
19736 "PRIVATE_MODEL",
19737 "PRIVATE_TOOL_INVENTORY",
19738 "PRIVATE_CURRENT_PROMPT",
19739 "PRIVATE_TOOL_INPUT_JSON",
19740 "PRIVATE_PROVIDER_REQUEST",
19741 "PRIVATE_INTERACTION_PROMPT",
19742 "PRIVATE_INTERACTION_AGENT",
19743 "PRIVATE_SUBAGENT_ID",
19744 "PRIVATE_SUBAGENT_TYPE",
19745 "PRIVATE_SUBAGENT_DESCRIPTION",
19746 "PRIVATE_PROVIDER_ERROR",
19747 "input_json",
19748 "current_prompt",
19749 "session_id",
19750 ] {
19751 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
19752 }
19753
19754 let snapshot = NodeSnapshot {
19755 node_id: NodeId::new("node-progress-test").unwrap(),
19756 enabled_providers: vec![agent("claude")],
19757 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19758 workspaces: Vec::new(),
19759 session_records: Vec::new(),
19760 managed_worktrees: Vec::new(),
19761 launch_inventory: None,
19762 agent_progress: vec![entry],
19763 };
19764 let mut legacy_reply = ResponseEnvelope {
19765 request_id: 1,
19766 result: Ok(NodeResponse::Snapshot {
19767 event_sequence: 0,
19768 controller: None,
19769 snapshot: snapshot.clone(),
19770 }),
19771 };
19772 project_response_without_agent_progress(&mut legacy_reply);
19773 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
19774 panic!("agent progress projection changed response kind");
19775 };
19776 assert!(legacy.agent_progress.is_empty());
19777 assert_eq!(snapshot.agent_progress.len(), 1);
19778 }
19779
19780 fn terminal_address(generation: u64) -> SessionAddress {
19781 SessionAddress {
19782 workspace_id: WorkspaceId::new("primary").unwrap(),
19783 session: SessionKey {
19784 instance_id: AgentInstanceId(41),
19785 generation: SessionGeneration(generation),
19786 },
19787 }
19788 }
19789
19790 fn spawn_spec_fixture() -> SpawnSpec {
19791 SpawnSpec {
19792 target: crate::protocol::SpawnTarget {
19793 node_id: NodeId::new("node-terminal-test").unwrap(),
19794 workspace_id: WorkspaceId::new("primary").unwrap(),
19795 worktree_id: None,
19796 },
19797 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19798 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
19799 .unwrap(),
19800 overrides: crate::protocol::SpawnOverrides::default(),
19801 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19802 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
19803 required_capabilities: SpawnRequiredCapabilities::default(),
19804 }
19805 }
19806
19807 fn environment_profile_test_shared(
19808 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
19809 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
19810 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19811 "local-claude-r1",
19812 )
19813 .unwrap();
19814 let spawn_profiles = SpawnProfileRegistry::new([
19815 crate::protocol::SpawnProfileDefaults {
19816 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19817 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
19818 provider: agent("claude"),
19819 mode: SessionMode::Pty,
19820 terminal_size: gate4agent_types::TerminalSize {
19821 rows: 24,
19822 columns: 80,
19823 },
19824 prompt: None,
19825 bundle_id: None,
19826 context_id: None,
19827 environment_profile_id: Some(profile_id.clone()),
19828 },
19829 ])
19830 .unwrap();
19831 let catalog = active_registry().unwrap();
19832 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19833 let control = runtime.native_launch_profile_control();
19834 let node_profile = NodeEnvironmentProfile::new(
19835 profile_id.clone(),
19836 profile_revision.clone(),
19837 agent("claude"),
19838 [NativeLaunchProfile::new(
19839 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
19840 agent("claude"),
19841 TransportKind::Pty,
19842 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19843 Arc::new(EmptyEnvironmentResolver),
19844 )
19845 .unwrap()],
19846 )
19847 .unwrap();
19848 let (binding, native_profiles, _materialization) = node_profile.into_parts();
19849 for native_profile in native_profiles {
19850 runtime.upsert_native_launch_profile(native_profile).unwrap();
19851 }
19852 let workspace = WorkspaceConfig::new(
19853 WorkspaceId::new("primary").unwrap(),
19854 std::env::current_dir().unwrap(),
19855 )
19856 .unwrap();
19857 let shared = NodeShared::new_with_incarnation(
19858 handle,
19859 "fixture-token".to_owned(),
19860 NodeId::new("node-terminal-test").unwrap(),
19861 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19862 vec![workspace],
19863 vec![agent("claude")],
19864 ProviderRuntimeStatuses::default(),
19865 None,
19866 Vec::new(),
19867 Vec::new(),
19868 spawn_profiles,
19869 Some(control),
19870 None,
19871 None,
19872 None,
19873 Vec::new(),
19874 Vec::new(),
19875 Vec::new(),
19876 Vec::new(),
19877 None,
19878 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19879 );
19880 shared
19881 .environment_profiles
19882 .write()
19883 .unwrap_or_else(|poisoned| poisoned.into_inner())
19884 .insert(binding.id.clone(), binding);
19885 (
19886 shared,
19887 runtime,
19888 ResolvedEnvironmentProfileReceipt {
19889 profile_id,
19890 profile_revision,
19891 network_allowlist: None,
19892 browser_profile_id: None,
19893 },
19894 )
19895 }
19896
19897 fn materialization_test_shared(
19898 deny_secret: bool,
19899 ) -> (
19900 NodeShared,
19901 NativeRuntime,
19902 ResolvedEnvironmentProfileReceipt,
19903 PathBuf,
19904 Arc<AtomicBool>,
19905 ) {
19906 let workspace = WorkspaceConfig::new(
19907 WorkspaceId::new("primary").unwrap(),
19908 std::env::current_dir().unwrap(),
19909 )
19910 .unwrap();
19911 materialization_test_shared_with_workspace(deny_secret, workspace)
19912 }
19913
19914 fn materialization_test_shared_with_workspace(
19915 deny_secret: bool,
19916 workspace: WorkspaceConfig,
19917 ) -> (
19918 NodeShared,
19919 NativeRuntime,
19920 ResolvedEnvironmentProfileReceipt,
19921 PathBuf,
19922 Arc<AtomicBool>,
19923 ) {
19924 let root = temporary_workspace_root(if deny_secret {
19925 "materialization-denied"
19926 } else {
19927 "materialization-ready"
19928 });
19929 std::fs::create_dir_all(&root).unwrap();
19930 let materialization_root = root.join("session-environments");
19931 let deny = Arc::new(AtomicBool::new(deny_secret));
19932 let resolver = Arc::new(FixtureSecretResolver {
19933 deny: Arc::clone(&deny),
19934 });
19935 let materializer = SessionEnvironmentMaterializer::new(
19936 materialization_root,
19937 resolver,
19938 )
19939 .unwrap();
19940 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19941 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19942 "materialized-claude-r1",
19943 )
19944 .unwrap();
19945 let materialization = NodeSessionMaterializationProfile::new(
19946 vec![NodeSessionEnvironmentMutation::SetSecret {
19947 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19948 reference: NodeSecretReference::new("fixture-token").unwrap(),
19949 }],
19950 vec![NodeSessionPathBinding::new(
19951 "GATE4AGENT_PROVIDER_HOME",
19952 NodeSessionPathClass::ProviderHome,
19953 )
19954 .unwrap()],
19955 vec![NodeSessionFile::secret(
19956 NodeSessionPathClass::Config,
19957 "auth/token",
19958 NodeSecretReference::new("fixture-token").unwrap(),
19959 )
19960 .unwrap()],
19961 )
19962 .unwrap();
19963 let catalog = active_registry().unwrap();
19964 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19965 let control = runtime.native_launch_profile_control();
19966 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19967 profile_id.clone(),
19968 profile_revision.clone(),
19969 agent("claude"),
19970 [NativeLaunchProfile::new(
19971 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19972 agent("claude"),
19973 TransportKind::Pty,
19974 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19975 Arc::new(EmptyEnvironmentResolver),
19976 )
19977 .unwrap()],
19978 Some(materialization),
19979 )
19980 .unwrap();
19981 let (binding, native_profiles, materialization) = node_profile.into_parts();
19982 for native_profile in native_profiles {
19983 runtime.upsert_native_launch_profile(native_profile).unwrap();
19984 }
19985 let shared = NodeShared::new_with_incarnation(
19986 handle,
19987 "fixture-token".to_owned(),
19988 NodeId::new("node-materialization-test").unwrap(),
19989 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19990 vec![workspace],
19991 vec![agent("claude")],
19992 ProviderRuntimeStatuses::default(),
19993 None,
19994 Vec::new(),
19995 Vec::new(),
19996 SpawnProfileRegistry::default(),
19997 Some(control),
19998 None,
19999 Some(materializer),
20000 None,
20001 Vec::new(),
20002 Vec::new(),
20003 Vec::new(),
20004 Vec::new(),
20005 None,
20006 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20007 );
20008 shared
20009 .environment_profiles
20010 .write()
20011 .unwrap_or_else(|poisoned| poisoned.into_inner())
20012 .insert(binding.id.clone(), binding);
20013 shared
20014 .environment_materialization_profiles
20015 .write()
20016 .unwrap_or_else(|poisoned| poisoned.into_inner())
20017 .insert(profile_id.clone(), materialization.unwrap());
20018 (
20019 shared,
20020 runtime,
20021 ResolvedEnvironmentProfileReceipt {
20022 profile_id,
20023 profile_revision,
20024 network_allowlist: None,
20025 browser_profile_id: None,
20026 },
20027 root,
20028 deny,
20029 )
20030 }
20031
20032 #[cfg(feature = "fixture")]
20033 #[derive(Clone, Copy)]
20034 enum CodexHomeProfile {
20035 Missing,
20036 WrongClass,
20037 Exact,
20038 }
20039
20040 #[cfg(feature = "fixture")]
20041 fn codex_bundle_test_shared(
20042 home_profile: CodexHomeProfile,
20043 ) -> (
20044 NodeShared,
20045 NativeRuntime,
20046 ResolvedEnvironmentProfileReceipt,
20047 NodeBundle,
20048 PathBuf,
20049 ) {
20050 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20051 const CLAUDE_MANIFEST: &[u8] =
20052 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20053 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20054 const DIGEST: &str =
20055 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20056
20057 let (shared, mut runtime, _claude_receipt, root, _deny) =
20058 materialization_test_shared(false);
20059 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20060 CodexHomeProfile::Missing => "codex-home-missing",
20061 CodexHomeProfile::WrongClass => "codex-home-wrong",
20062 CodexHomeProfile::Exact => "codex-home-exact",
20063 })
20064 .unwrap();
20065 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20066 "codex-home-r1",
20067 )
20068 .unwrap();
20069 let materialization = match home_profile {
20070 CodexHomeProfile::Missing => None,
20071 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20072 Some(NodeSessionMaterializationProfile::new(
20073 Vec::new(),
20074 vec![NodeSessionPathBinding::new(
20075 "CODEX_HOME",
20076 if matches!(home_profile, CodexHomeProfile::Exact) {
20077 NodeSessionPathClass::ProviderHome
20078 } else {
20079 NodeSessionPathClass::Config
20080 },
20081 )
20082 .unwrap()],
20083 Vec::new(),
20084 )
20085 .unwrap())
20086 }
20087 };
20088 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20089 profile_id.clone(),
20090 profile_revision.clone(),
20091 agent("codex"),
20092 [NativeLaunchProfile::new(
20093 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20094 agent("codex"),
20095 TransportKind::Pty,
20096 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20097 Arc::new(EmptyEnvironmentResolver),
20098 )
20099 .unwrap()],
20100 materialization,
20101 )
20102 .unwrap();
20103 let (binding, native_profiles, materialization) = node_profile.into_parts();
20104 for native_profile in native_profiles {
20105 runtime.upsert_native_launch_profile(native_profile).unwrap();
20106 }
20107 shared
20108 .environment_profiles
20109 .write()
20110 .unwrap_or_else(|poisoned| poisoned.into_inner())
20111 .insert(binding.id.clone(), binding);
20112 if let Some(materialization) = materialization {
20113 shared
20114 .environment_materialization_profiles
20115 .write()
20116 .unwrap_or_else(|poisoned| poisoned.into_inner())
20117 .insert(profile_id.clone(), materialization);
20118 }
20119
20120 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20121 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20122 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20123 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20124 std::fs::write(
20125 bundle_source.join(".claude-plugin/plugin.json"),
20126 CLAUDE_MANIFEST,
20127 )
20128 .unwrap();
20129 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20130 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20131 let bundle = NodeBundle::new(
20132 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20133 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20134 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20135 &bundle_source,
20136 )
20137 .unwrap();
20138 *shared
20139 .bundle_catalog
20140 .write()
20141 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20142 BundleCatalog::new([bundle.clone()]).unwrap();
20143 (
20144 shared,
20145 runtime,
20146 ResolvedEnvironmentProfileReceipt {
20147 profile_id,
20148 profile_revision,
20149 network_allowlist: None,
20150 browser_profile_id: None,
20151 },
20152 bundle,
20153 root,
20154 )
20155 }
20156
20157 #[cfg(feature = "fixture")]
20158 #[test]
20159 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20160 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20161 let (shared, runtime, receipt, bundle, root) =
20162 codex_bundle_test_shared(home_profile);
20163 let error = shared
20164 .bundle_layout(
20165 &agent("codex"),
20166 SessionMode::Pty,
20167 Some(&receipt),
20168 &bundle,
20169 )
20170 .unwrap_err();
20171 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20172 assert!(shared
20173 .materializations
20174 .lock()
20175 .unwrap_or_else(|poisoned| poisoned.into_inner())
20176 .is_empty());
20177 let materialization_root = root.join("session-environments");
20178 assert!(!std::fs::read_dir(&materialization_root)
20179 .unwrap()
20180 .filter_map(Result::ok)
20181 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20182 drop(shared);
20183 drop(runtime);
20184 let _ = std::fs::remove_dir_all(root);
20185 }
20186 }
20187
20188 #[cfg(feature = "fixture")]
20189 #[test]
20190 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20191 let (shared, runtime, receipt, bundle, root) =
20192 codex_bundle_test_shared(CodexHomeProfile::Exact);
20193 let address = SessionAddress {
20194 workspace_id: WorkspaceId::new("primary").unwrap(),
20195 session: SessionKey {
20196 instance_id: AgentInstanceId(310),
20197 generation: SessionGeneration(1),
20198 },
20199 };
20200 let (overlay, guard) = shared
20201 .prepare_session_materialization(
20202 &address,
20203 &agent("codex"),
20204 SessionMode::Pty,
20205 Some(&receipt),
20206 Some(&bundle.receipt()),
20207 None,
20208 None,
20209 )
20210 .unwrap()
20211 .unwrap();
20212 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20213 let ownership = shared
20214 .materializations
20215 .lock()
20216 .unwrap_or_else(|poisoned| poisoned.into_inner())
20217 .get(guard.id().unwrap())
20218 .unwrap()
20219 .clone();
20220 let (profile, _, _) = shared
20221 .materialization_profile(
20222 &agent("codex"),
20223 SessionMode::Pty,
20224 Some(&receipt),
20225 Some(&bundle.receipt()),
20226 None,
20227 )
20228 .unwrap()
20229 .unwrap();
20230 let environment = shared
20231 .session_environment_materializer
20232 .as_ref()
20233 .unwrap()
20234 .resolve_environment(&ownership, &profile)
20235 .unwrap();
20236 assert!(environment.iter().any(|mutation| {
20237 mutation.key == OsString::from("CODEX_HOME")
20238 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20239 }));
20240 assert_eq!(
20241 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20242 .unwrap(),
20243 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20244 );
20245 assert!(std::fs::read_dir(ownership.bundle_root())
20246 .unwrap()
20247 .next()
20248 .is_none());
20249 drop(guard);
20250 drop(shared);
20251 drop(runtime);
20252 let _ = std::fs::remove_dir_all(root);
20253 }
20254
20255 #[cfg(feature = "fixture")]
20256 #[test]
20257 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20258 let (shared, runtime, receipt, bundle, root) =
20259 codex_bundle_test_shared(CodexHomeProfile::Exact);
20260 let bundle_receipt = bundle.receipt();
20261 let address = SessionAddress {
20262 workspace_id: WorkspaceId::new("primary").unwrap(),
20263 session: SessionKey {
20264 instance_id: AgentInstanceId(311),
20265 generation: SessionGeneration(1),
20266 },
20267 };
20268 let (_overlay, guard) = shared
20269 .prepare_session_materialization(
20270 &address,
20271 &agent("codex"),
20272 SessionMode::Pty,
20273 Some(&receipt),
20274 Some(&bundle_receipt),
20275 None,
20276 None,
20277 )
20278 .unwrap()
20279 .unwrap();
20280 let materialization_id = guard.id().unwrap().clone();
20281 let skill_path = shared
20282 .materializations
20283 .lock()
20284 .unwrap_or_else(|poisoned| poisoned.into_inner())
20285 .get(&materialization_id)
20286 .unwrap()
20287 .provider_home()
20288 .join("skills/review-code/SKILL.md");
20289 let record_id = shared
20290 .bind_spawn_session_with_materialization(
20291 &address,
20292 agent("codex"),
20293 SessionMode::Pty,
20294 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20295 SpawnRecordPolicy::ProviderIdentityOnly,
20296 Some(receipt.clone()),
20297 Some(bundle_receipt.clone()),
20298 None,
20299 Some(materialization_id.clone()),
20300 )
20301 .unwrap()
20302 .unwrap();
20303 guard.retain();
20304 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20305
20306 let error = match shared.resolve_record_materialization(
20307 &record_id,
20308 &agent("codex"),
20309 SessionMode::Pty,
20310 Some(&receipt),
20311 Some(&bundle_receipt),
20312 None,
20313 ) {
20314 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20315 Err(error) => error,
20316 };
20317 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20318 assert_eq!(
20319 shared
20320 .materializations
20321 .lock()
20322 .unwrap_or_else(|poisoned| poisoned.into_inner())
20323 .get(&materialization_id)
20324 .unwrap()
20325 .state(),
20326 MaterializationState::RecoveryRequired,
20327 );
20328 drop(shared);
20329 drop(runtime);
20330 let _ = std::fs::remove_dir_all(root);
20331 }
20332
20333 fn bind_record_owned_materialization_fixture(
20334 shared: &NodeShared,
20335 receipt: &ResolvedEnvironmentProfileReceipt,
20336 instance_id: u64,
20337 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20338 let address = SessionAddress {
20339 workspace_id: WorkspaceId::new("primary").unwrap(),
20340 session: SessionKey {
20341 instance_id: AgentInstanceId(instance_id),
20342 generation: SessionGeneration(1),
20343 },
20344 };
20345 let (overlay, materialization_guard) = shared
20346 .prepare_session_materialization(
20347 &address,
20348 &agent("claude"),
20349 SessionMode::Pty,
20350 Some(receipt),
20351 None,
20352 None,
20353 None,
20354 )
20355 .unwrap()
20356 .unwrap();
20357 let materialization_id = materialization_guard.id().unwrap().clone();
20358 let materialization_root = shared
20359 .materializations
20360 .lock()
20361 .unwrap_or_else(|poisoned| poisoned.into_inner())
20362 .get(&materialization_id)
20363 .unwrap()
20364 .root()
20365 .to_path_buf();
20366 let selection = shared
20367 .select_environment_profile(
20368 address.session.instance_id,
20369 &agent("claude"),
20370 SessionMode::Pty,
20371 Some(receipt),
20372 )
20373 .unwrap()
20374 .unwrap();
20375 assert!(shared
20376 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20377 .unwrap()
20378 .is_none());
20379 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20380 let record_id = shared
20381 .bind_spawn_session_with_materialization(
20382 &address,
20383 agent("claude"),
20384 SessionMode::Pty,
20385 policy,
20386 SpawnRecordPolicy::ProviderIdentityOnly,
20387 Some(receipt.clone()),
20388 None,
20389 None,
20390 Some(materialization_id.clone()),
20391 )
20392 .unwrap()
20393 .unwrap();
20394 selection.retain();
20395 materialization_guard.retain();
20396 (address, record_id, materialization_id, materialization_root)
20397 }
20398
20399 #[test]
20400 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20401 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20402 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20403 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20404 }));
20405 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20406 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20407 let mut spec = spawn_spec_fixture();
20408 spec.expected_profile_revision =
20409 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20410 assert!(matches!(
20411 &spec.overrides.environment_profile_id,
20412 crate::protocol::SpawnOverride::Inherit,
20413 ));
20414 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20415 .requires_child_environment_profile_capability());
20416 assert!(request_requires_child_environment_profile(
20417 &shared,
20418 &NodeRequest::SpawnSpec { spec: spec.clone() },
20419 ));
20420
20421 let address = terminal_address(1);
20422 shared.bind_session_with_policy(
20423 &address,
20424 ProviderRuntimePolicy::raw_pty(),
20425 Some(environment_profile.clone()),
20426 );
20427 assert!(request_requires_child_environment_profile(
20428 &shared,
20429 &NodeRequest::Input {
20430 session: address.clone(),
20431 text: "x".to_owned(),
20432 },
20433 ));
20434
20435 let mut record = record("claude", "profile-record");
20436 record.active_session = Some(address.clone());
20437 record.environment_profile = Some(environment_profile.clone());
20438 let record_id = record.record_id.clone();
20439 shared.insert_record(record.clone()).unwrap();
20440 assert!(request_requires_child_environment_profile(
20441 &shared,
20442 &NodeRequest::ResumeSessionRecord {
20443 record_id,
20444 terminal_size: gate4agent_types::TerminalSize {
20445 rows: 24,
20446 columns: 80,
20447 },
20448 initial_prompt: None,
20449 },
20450 ));
20451
20452 let snapshot = snapshot_for_wire(
20453 &shared, false, true, true, true, false, false, false, false, true,
20454 );
20455 assert_eq!(snapshot.session_records.len(), 1);
20456 assert!(snapshot.session_records[0].environment_profile.is_none());
20457 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20458 sequence: 1,
20459 event: NodeEvent::SessionRecordUpserted { record },
20460 });
20461 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20462 panic!("record projection changed the event kind");
20463 };
20464 assert!(record.environment_profile.is_none());
20465
20466 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20467 let mut reply = ResponseEnvelope {
20468 request_id: 1,
20469 result: Ok(NodeResponse::SpawnSpecAccepted {
20470 receipt: resolved.receipt_with_environment(
20471 shared.incarnation_id,
20472 address,
20473 Some(environment_profile),
20474 ),
20475 }),
20476 };
20477 project_response_without_child_environment_profile(&mut reply);
20478 assert_eq!(
20479 reply.result.unwrap_err().code,
20480 NodeFailureCode::UnsupportedCapability,
20481 );
20482 }
20483
20484 #[test]
20485 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20486 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20487 let snapshot = shared.snapshot();
20488 let profiles = snapshot
20489 .launch_inventory
20490 .as_ref()
20491 .and_then(|inventory| inventory.spawn_profiles.as_ref())
20492 .unwrap();
20493 assert_eq!(profiles.len(), 1);
20494 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20495 assert!(snapshot.requires_child_environment_profile_capability());
20496
20497 let mut legacy = snapshot.clone();
20498 clear_snapshot_child_environment_profiles(&mut legacy);
20499 assert!(legacy
20500 .launch_inventory
20501 .as_ref()
20502 .unwrap()
20503 .spawn_profiles
20504 .as_ref()
20505 .unwrap()[0]
20506 .environment_profile
20507 .is_none());
20508 assert!(!legacy.requires_child_environment_profile_capability());
20509
20510 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20511 let bindings = shared
20512 .environment_profiles
20513 .read()
20514 .unwrap_or_else(|poisoned| poisoned.into_inner())
20515 .clone();
20516 let mut provider_mismatch = bindings.clone();
20517 provider_mismatch
20518 .values_mut()
20519 .next()
20520 .unwrap()
20521 .provider = agent("codex");
20522 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20523 let mut mode_mismatch = profile;
20524 mode_mismatch.mode = SessionMode::Inline;
20525 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20526
20527 shared
20528 .environment_profiles
20529 .write()
20530 .unwrap_or_else(|poisoned| poisoned.into_inner())
20531 .clear();
20532 assert!(shared
20533 .snapshot()
20534 .launch_inventory
20535 .unwrap()
20536 .spawn_profiles
20537 .unwrap()
20538 .is_empty());
20539 }
20540
20541 #[test]
20542 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20543 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20544 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20545 }));
20546 let shared = terminal_test_shared();
20547 let bundle = ResolvedBundleReceipt {
20548 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20549 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20550 digest: crate::protocol::SpawnBundleDigest::new(format!(
20551 "sha256:{}",
20552 "0".repeat(64),
20553 ))
20554 .unwrap(),
20555 };
20556 let mut bundled = record("claude", "bundle-record");
20557 bundled.bundle = Some(bundle.clone());
20558 let record_id = bundled.record_id.clone();
20559 shared.insert_record(bundled.clone()).unwrap();
20560 assert!(request_requires_session_bundle(
20561 &shared,
20562 &NodeRequest::ForgetSessionRecord {
20563 record_id: record_id.clone(),
20564 },
20565 ));
20566
20567 let snapshot = snapshot_for_wire(
20568 &shared, false, true, true, true, true, false, false, false, true,
20569 );
20570 assert!(snapshot.session_records[0].bundle.is_none());
20571 let launch = snapshot.launch_inventory.as_ref().unwrap();
20572 assert!(launch.spawn_profiles.is_some());
20573 assert!(launch.bundles.is_none());
20574 let event = project_event_without_session_bundle(NodeEventEnvelope {
20575 sequence: 1,
20576 event: NodeEvent::SessionRecordUpserted { record: bundled },
20577 });
20578 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20579 panic!("record projection changed the event kind");
20580 };
20581 assert!(record.bundle.is_none());
20582
20583 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20584 let mut reply = ResponseEnvelope {
20585 request_id: 1,
20586 result: Ok(NodeResponse::SpawnSpecAccepted {
20587 receipt: resolved.receipt_with_materialization(
20588 shared.incarnation_id,
20589 terminal_address(17),
20590 None,
20591 Some(bundle),
20592 None,
20593 ),
20594 }),
20595 };
20596 project_response_without_session_bundle(&mut reply);
20597 assert_eq!(
20598 reply.result.unwrap_err().code,
20599 NodeFailureCode::UnsupportedCapability,
20600 );
20601 }
20602
20603 #[test]
20604 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20605 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20606 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20607 }));
20608 let context_id = SpawnContextId::new("context-review").unwrap();
20609 let context = ResolvedContextPackReceipt {
20610 id: context_id.clone(),
20611 digest: crate::protocol::SpawnContextDigest::new(format!(
20612 "sha256:{}",
20613 "c".repeat(64),
20614 ))
20615 .unwrap(),
20616 lineage: ContextPackLineageReceipt {
20617 source_node_id: NodeId::new("node-source").unwrap(),
20618 source_session: terminal_address(1),
20619 source_provider: agent("kimi"),
20620 },
20621 source_message_count: 2,
20622 retained_message_count: 2,
20623 byte_len: 256,
20624 truncated: false,
20625 };
20626 assert!(context.is_valid());
20627 let mut bound_record = record("codex", "context-record");
20628 bound_record.context_id = Some(context_id);
20629 bound_record.context = Some(context.clone());
20630 let session = gate4agent_types::SessionSnapshot {
20631 instance_id: AgentInstanceId(91),
20632 agent_id: agent("kimi"),
20633 transport: TransportKind::Pty,
20634 generation: SessionGeneration(3),
20635 status: SessionStatus::Running,
20636 pending_operation: None,
20637 pending_input: None,
20638 process_id: Some(9001),
20639 terminal_size: None,
20640 terminal_frame: None,
20641 terminal_stale: None,
20642 session_options: None,
20643 capabilities: gate4agent_types::CapabilitySnapshot::default(),
20644 history: gate4agent_types::HistorySnapshot {
20645 pending: None,
20646 candidates: vec![HistoryCandidateSummary {
20647 id: "candidate-opaque".to_owned(),
20648 session_id_hint: "session-hint".to_owned(),
20649 modified_at_unix_ms: Some(1),
20650 }],
20651 loaded_candidate_id: Some("candidate-opaque".to_owned()),
20652 loaded: Some(HistorySessionRecord {
20653 session_id: "private-vendor-session".to_owned(),
20654 title: Some("private title".to_owned()),
20655 cwd: Some(r"C:\private\history-root".to_owned()),
20656 model: Some("private-model".to_owned()),
20657 message_count: 2,
20658 completed_turn_count: None,
20659 total_tokens: 19,
20660 messages: vec![
20661 gate4agent_types::HistoryMessageRecord {
20662 role: gate4agent_types::HistoryMessageRole::User,
20663 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20664 },
20665 gate4agent_types::HistoryMessageRecord {
20666 role: gate4agent_types::HistoryMessageRole::Assistant,
20667 text: "private answer".to_owned(),
20668 },
20669 ],
20670 }),
20671 last_error: None,
20672 },
20673 resume: gate4agent_types::ResumeSnapshot::default(),
20674 foreground: gate4agent_types::ForegroundSnapshot::default(),
20675 provider: gate4agent_types::ProviderSnapshot::default(),
20676 screen_state: gate4agent_types::PtyScreenState::default(),
20677 };
20678 let original = NodeSnapshot {
20679 node_id: NodeId::new("node-terminal-test").unwrap(),
20680 enabled_providers: vec![agent("kimi"), agent("codex")],
20681 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20682 workspaces: vec![WorkspaceSnapshot {
20683 workspace_id: WorkspaceId::new("primary").unwrap(),
20684 canonical_root: opaque_windows_path(
20685 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20686 ),
20687 sessions: vec![session],
20688 worktree_service_mode: None,
20689 managed_worktree_profiles: None,
20690 }],
20691 session_records: vec![bound_record],
20692 managed_worktrees: Vec::new(),
20693 launch_inventory: None,
20694 agent_progress: Vec::new(),
20695 };
20696
20697 let mut negotiated = original.clone();
20698 project_snapshot_history_for_wire(&mut negotiated, true);
20699 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
20700 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
20701 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
20702 assert!(!serde_json::to_string(&negotiated)
20703 .unwrap()
20704 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
20705
20706 let mut legacy = original;
20707 project_snapshot_history_for_wire(&mut legacy, false);
20708 clear_snapshot_context_packs(&mut legacy);
20709 assert_eq!(
20710 legacy.workspaces[0].sessions[0].history,
20711 gate4agent_types::HistorySnapshot::default(),
20712 );
20713 assert!(legacy.session_records[0].context_id.is_none());
20714 assert!(legacy.session_records[0].context.is_none());
20715 let encoded = serde_json::to_string(&legacy).unwrap();
20716 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
20717 assert!(!encoded.contains("context-review"));
20718
20719 let mut reply = ResponseEnvelope {
20720 request_id: 7,
20721 result: Ok(NodeResponse::ContextPackExported { context }),
20722 };
20723 project_response_without_context_pack(&mut reply);
20724 assert_eq!(
20725 reply.result.unwrap_err().code,
20726 NodeFailureCode::UnsupportedCapability,
20727 );
20728 }
20729
20730 #[test]
20731 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
20732 let shared = terminal_test_shared();
20733 let history = HistorySessionRecord {
20734 session_id: "fixture-history".to_owned(),
20735 title: None,
20736 cwd: None,
20737 model: None,
20738 message_count: 1,
20739 completed_turn_count: None,
20740 total_tokens: 0,
20741 messages: vec![gate4agent_types::HistoryMessageRecord {
20742 role: gate4agent_types::HistoryMessageRole::User,
20743 text: "bounded handoff".to_owned(),
20744 }],
20745 };
20746 let pack = NodeContextPack::export(
20747 ContextPackLineageReceipt {
20748 source_node_id: shared.node_id.clone(),
20749 source_session: terminal_address(1),
20750 source_provider: agent("claude"),
20751 },
20752 &history,
20753 )
20754 .unwrap();
20755 let context = shared
20756 .context_catalog
20757 .write()
20758 .unwrap_or_else(|poisoned| poisoned.into_inner())
20759 .insert(pack)
20760 .unwrap();
20761 let mut retained = record("codex", "context-retained-record");
20762 retained.context_id = Some(context.id.clone());
20763 retained.context = Some(context.clone());
20764 let record_id = retained.record_id.clone();
20765 shared.insert_record(retained).unwrap();
20766 assert_eq!(
20767 shared.forget_context_pack(&context.id).unwrap_err().code,
20768 NodeFailureCode::ContextPackBusy,
20769 );
20770 assert!(shared
20771 .context_catalog
20772 .read()
20773 .unwrap_or_else(|poisoned| poisoned.into_inner())
20774 .get(&context.id)
20775 .is_some());
20776 shared
20777 .session_records
20778 .lock()
20779 .unwrap_or_else(|poisoned| poisoned.into_inner())
20780 .records
20781 .remove(&record_id);
20782
20783 let mut spec = spawn_spec_fixture();
20784 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
20785 value: context.id.clone(),
20786 };
20787 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20788 let receipt = resolved.receipt_with_materialization(
20789 shared.incarnation_id,
20790 terminal_address(2),
20791 None,
20792 None,
20793 Some(context.clone()),
20794 );
20795 assert!(receipt.context_binding_is_valid());
20796 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
20797
20798 shared.forget_context_pack(&context.id).unwrap();
20799 assert!(shared
20800 .context_catalog
20801 .read()
20802 .unwrap_or_else(|poisoned| poisoned.into_inner())
20803 .get(&context.id)
20804 .is_none());
20805 assert!(shared
20806 .replay_spawn_spec(&spec, Instant::now())
20807 .unwrap()
20808 .is_none());
20809 }
20810
20811 #[test]
20812 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
20813 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20814 let address = terminal_address(1);
20815 let selection = shared
20816 .select_environment_profile(
20817 address.session.instance_id,
20818 &agent("claude"),
20819 SessionMode::Pty,
20820 Some(&environment_profile),
20821 )
20822 .unwrap()
20823 .unwrap();
20824 shared.bind_session_with_policy(
20825 &address,
20826 ProviderRuntimePolicy::raw_pty(),
20827 Some(environment_profile.clone()),
20828 );
20829 selection.retain();
20830 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20831 assert!(matches!(
20832 runtime.remove_native_launch_profile(&native_id),
20833 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20834 ));
20835 assert!(shared.remove_binding(&address).is_some());
20836 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20837
20838 let mismatched = ResolvedEnvironmentProfileReceipt {
20839 profile_id: environment_profile.profile_id,
20840 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
20841 "local-claude-r2",
20842 )
20843 .unwrap(),
20844 network_allowlist: None,
20845 browser_profile_id: None,
20846 };
20847 let error = match shared.select_environment_profile(
20848 AgentInstanceId(42),
20849 &agent("claude"),
20850 SessionMode::Pty,
20851 Some(&mismatched),
20852 ) {
20853 Ok(_) => panic!("mismatched environment profile revision was selected"),
20854 Err(error) => error,
20855 };
20856 assert_eq!(
20857 error.code,
20858 NodeFailureCode::EnvironmentProfileBindingMismatch,
20859 );
20860 assert!(!shared
20861 .session_bindings
20862 .lock()
20863 .unwrap_or_else(|poisoned| poisoned.into_inner())
20864 .contains_key(&AgentInstanceId(42)));
20865 }
20866
20867 #[test]
20868 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
20869 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20870 let address = terminal_address(1);
20871 let selection = shared
20872 .select_environment_profile(
20873 address.session.instance_id,
20874 &agent("claude"),
20875 SessionMode::Pty,
20876 Some(&environment_profile),
20877 )
20878 .unwrap()
20879 .unwrap();
20880 let overlay = NativeInstanceLaunchOverlay::new(
20881 agent("claude"),
20882 TransportKind::Pty,
20883 vec![EnvMutation {
20884 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
20885 value: Some(OsString::from("fixture-secret")),
20886 }],
20887 vec![
20888 OsString::from("--plugin-dir"),
20889 std::env::current_dir().unwrap().into_os_string(),
20890 ],
20891 )
20892 .unwrap();
20893 let instance_overlay = shared
20894 .install_prepared_launch_overlay(
20895 address.session.instance_id,
20896 PreparedNativeLaunchOverlay::Instance(overlay),
20897 )
20898 .unwrap()
20899 .unwrap();
20900 let bundle = ResolvedBundleReceipt {
20901 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
20902 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
20903 digest: crate::protocol::SpawnBundleDigest::new(format!(
20904 "sha256:{}",
20905 "0".repeat(64),
20906 ))
20907 .unwrap(),
20908 };
20909 shared.bind_session_with_materialization(
20910 &address,
20911 ProviderRuntimePolicy::raw_pty(),
20912 Some(environment_profile),
20913 Some(bundle),
20914 None,
20915 None,
20916 );
20917 selection.retain();
20918 instance_overlay.retain();
20919
20920 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20921 assert!(matches!(
20922 runtime.remove_native_launch_profile(&native_id),
20923 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20924 ));
20925 assert!(shared.remove_binding(&address).is_some());
20926 assert!(!shared
20927 .native_launch_profile_control
20928 .as_ref()
20929 .unwrap()
20930 .clear_native_instance_launch_overlay(address.session.instance_id));
20931 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20932 }
20933
20934 #[test]
20935 fn session_environment_materialization_failure_is_pre_child_and_durable() {
20936 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20937 let address = SessionAddress {
20938 workspace_id: WorkspaceId::new("primary").unwrap(),
20939 session: SessionKey {
20940 instance_id: AgentInstanceId(201),
20941 generation: SessionGeneration(1),
20942 },
20943 };
20944 let error = match shared.prepare_session_materialization(
20945 &address,
20946 &agent("claude"),
20947 SessionMode::Pty,
20948 Some(&receipt),
20949 None,
20950 None,
20951 None,
20952 ) {
20953 Ok(_) => panic!("denied local reference unexpectedly materialized"),
20954 Err(error) => error,
20955 };
20956 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20957 assert!(shared.handle.snapshot().sessions.is_empty());
20958 assert!(shared
20959 .session_bindings
20960 .lock()
20961 .unwrap_or_else(|poisoned| poisoned.into_inner())
20962 .is_empty());
20963 assert!(shared
20964 .materializations
20965 .lock()
20966 .unwrap_or_else(|poisoned| poisoned.into_inner())
20967 .is_empty());
20968 drop(shared);
20969 drop(runtime);
20970 std::fs::remove_dir_all(root).unwrap();
20971 }
20972
20973 #[test]
20974 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
20975 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20976 let address = SessionAddress {
20977 workspace_id: WorkspaceId::new("primary").unwrap(),
20978 session: SessionKey {
20979 instance_id: AgentInstanceId(202),
20980 generation: SessionGeneration(1),
20981 },
20982 };
20983 let (overlay, materialization_guard) = shared
20984 .prepare_session_materialization(
20985 &address,
20986 &agent("claude"),
20987 SessionMode::Pty,
20988 Some(&receipt),
20989 None,
20990 None,
20991 None,
20992 )
20993 .unwrap()
20994 .unwrap();
20995 let materialization_id = materialization_guard.id().unwrap().clone();
20996 let materialization_root = shared
20997 .materializations
20998 .lock()
20999 .unwrap_or_else(|poisoned| poisoned.into_inner())
21000 .get(&materialization_id)
21001 .unwrap()
21002 .root()
21003 .to_path_buf();
21004 let selection = shared
21005 .select_environment_profile(
21006 address.session.instance_id,
21007 &agent("claude"),
21008 SessionMode::Pty,
21009 Some(&receipt),
21010 )
21011 .unwrap()
21012 .unwrap();
21013 assert!(shared
21014 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21015 .unwrap()
21016 .is_none());
21017 shared.bind_session_with_materialization(
21018 &address,
21019 ProviderRuntimePolicy::raw_pty(),
21020 Some(receipt),
21021 None,
21022 None,
21023 Some(materialization_id),
21024 );
21025 selection.retain();
21026 materialization_guard.retain();
21027
21028 assert!(materialization_root.is_dir());
21029 assert!(shared
21030 .session_bindings
21031 .lock()
21032 .unwrap_or_else(|poisoned| poisoned.into_inner())
21033 .contains_key(&address.session.instance_id));
21034
21035 let removed = shared.remove_binding(&address).unwrap();
21036 shared
21037 .cleanup_session_owned_materialization(&address, &removed)
21038 .unwrap();
21039 assert!(!materialization_root.exists());
21040 assert!(shared
21041 .materializations
21042 .lock()
21043 .unwrap_or_else(|poisoned| poisoned.into_inner())
21044 .is_empty());
21045 drop(shared);
21046 drop(runtime);
21047 std::fs::remove_dir_all(root).unwrap();
21048 }
21049
21050 #[tokio::test]
21051 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21052 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21053 let address = SessionAddress {
21054 workspace_id: WorkspaceId::new("primary").unwrap(),
21055 session: SessionKey {
21056 instance_id: AgentInstanceId(203),
21057 generation: SessionGeneration(1),
21058 },
21059 };
21060 let (overlay, materialization_guard) = shared
21061 .prepare_session_materialization(
21062 &address,
21063 &agent("claude"),
21064 SessionMode::Pty,
21065 Some(&receipt),
21066 None,
21067 None,
21068 None,
21069 )
21070 .unwrap()
21071 .unwrap();
21072 let materialization_id = materialization_guard.id().unwrap().clone();
21073 let materialization_root = shared
21074 .materializations
21075 .lock()
21076 .unwrap_or_else(|poisoned| poisoned.into_inner())
21077 .get(&materialization_id)
21078 .unwrap()
21079 .root()
21080 .to_path_buf();
21081 let selection = shared
21082 .select_environment_profile(
21083 address.session.instance_id,
21084 &agent("claude"),
21085 SessionMode::Pty,
21086 Some(&receipt),
21087 )
21088 .unwrap()
21089 .unwrap();
21090 assert!(shared
21091 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21092 .unwrap()
21093 .is_none());
21094 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21095 let record_id = shared
21096 .bind_spawn_session_with_materialization(
21097 &address,
21098 agent("claude"),
21099 SessionMode::Pty,
21100 policy,
21101 SpawnRecordPolicy::ProviderIdentityOnly,
21102 Some(receipt.clone()),
21103 None,
21104 None,
21105 Some(materialization_id.clone()),
21106 )
21107 .unwrap()
21108 .unwrap();
21109 selection.retain();
21110 materialization_guard.retain();
21111 assert_eq!(
21112 shared
21113 .materializations
21114 .lock()
21115 .unwrap_or_else(|poisoned| poisoned.into_inner())
21116 .get(&materialization_id)
21117 .unwrap()
21118 .owner(),
21119 &MaterializationOwner::Record {
21120 record_id: record_id.clone(),
21121 },
21122 );
21123
21124 let removed = shared.remove_binding(&address).unwrap();
21125 shared
21126 .cleanup_session_owned_materialization(&address, &removed)
21127 .unwrap();
21128 assert!(materialization_root.is_dir());
21129 assert!(shared
21130 .resolve_record_materialization(
21131 &record_id,
21132 &agent("claude"),
21133 SessionMode::Pty,
21134 Some(&receipt),
21135 None,
21136 None,
21137 )
21138 .unwrap()
21139 .is_some());
21140 {
21141 let mut records = shared
21142 .session_records
21143 .lock()
21144 .unwrap_or_else(|poisoned| poisoned.into_inner());
21145 let record = records.records.get_mut(&record_id).unwrap();
21146 record.active_session = None;
21147 record.state = ManagedSessionState::Unavailable;
21148 }
21149 shared.forget_session_record(&record_id).await.unwrap();
21150 assert!(!materialization_root.exists());
21151 assert!(shared
21152 .materializations
21153 .lock()
21154 .unwrap_or_else(|poisoned| poisoned.into_inner())
21155 .is_empty());
21156 drop(shared);
21157 drop(runtime);
21158 std::fs::remove_dir_all(root).unwrap();
21159 }
21160
21161 #[test]
21162 fn record_materialization_revalidation_failure_persists_recovery_required() {
21163 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21164 let state_path = root.join("node-state-v6.json");
21165 shared.state_path = Some(state_path.clone());
21166 let (_address, _record_id, materialization_id, materialization_root) =
21167 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21168 std::fs::write(
21169 materialization_root.join(".gate4agent-materialization-owner"),
21170 b"tampered",
21171 )
21172 .unwrap();
21173
21174 let error = match shared.resolve_record_materialization(
21175 &_record_id,
21176 &agent("claude"),
21177 SessionMode::Pty,
21178 Some(&receipt),
21179 None,
21180 None,
21181 ) {
21182 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21183 Err(error) => error,
21184 };
21185 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21186 assert_eq!(
21187 shared
21188 .materializations
21189 .lock()
21190 .unwrap_or_else(|poisoned| poisoned.into_inner())
21191 .get(&materialization_id)
21192 .unwrap()
21193 .state(),
21194 MaterializationState::RecoveryRequired,
21195 );
21196 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21197 assert_eq!(loaded.materializations.len(), 1);
21198 assert_eq!(
21199 loaded.materializations[0].state(),
21200 MaterializationState::RecoveryRequired,
21201 );
21202 drop(shared);
21203 drop(runtime);
21204 std::fs::remove_dir_all(root).unwrap();
21205 }
21206
21207 #[test]
21208 fn transient_record_materialization_resolution_failure_remains_ready() {
21209 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21210 let (_address, record_id, materialization_id, _materialization_root) =
21211 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21212 deny.store(true, Ordering::Release);
21213
21214 let error = match shared.resolve_record_materialization(
21215 &record_id,
21216 &agent("claude"),
21217 SessionMode::Pty,
21218 Some(&receipt),
21219 None,
21220 None,
21221 ) {
21222 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21223 Err(error) => error,
21224 };
21225 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21226 assert_eq!(
21227 shared
21228 .materializations
21229 .lock()
21230 .unwrap_or_else(|poisoned| poisoned.into_inner())
21231 .get(&materialization_id)
21232 .unwrap()
21233 .state(),
21234 MaterializationState::Ready,
21235 );
21236 drop(shared);
21237 drop(runtime);
21238 std::fs::remove_dir_all(root).unwrap();
21239 }
21240
21241 #[test]
21242 fn startup_missing_record_materialization_is_not_resumable() {
21243 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21244 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21245 shared
21246 .insert_record(ManagedSessionRecord {
21247 record_id: record_id.clone(),
21248 display_name: "missing materialization".to_owned(),
21249 provider: agent("claude"),
21250 mode: SessionMode::Pty,
21251 state: ManagedSessionState::Dormant,
21252 workspace_id: WorkspaceId::new("primary").unwrap(),
21253 canonical_root: opaque_windows_path(
21254 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21255 ),
21256 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21257 key: gate4agent_types::ProviderSessionKey::SessionId,
21258 id: "provider-session-missing-materialization".to_owned(),
21259 transcript_path: None,
21260 }),
21261 active_session: None,
21262 environment_profile: Some(receipt),
21263 bundle: None,
21264 context_id: None,
21265 context: None,
21266 exported_context: None,
21267 task_binding: None,
21268 created_at_unix_ms: 1,
21269 updated_at_unix_ms: 1,
21270 last_error: None,
21271 })
21272 .unwrap();
21273
21274 shared.reconcile_materializations();
21275 let record = shared.record(&record_id).unwrap();
21276 assert_eq!(record.state, ManagedSessionState::Unavailable);
21277 assert!(record.provider_session.is_none());
21278 assert_eq!(
21279 record.last_error.as_deref(),
21280 Some("environment-profile-unavailable"),
21281 );
21282 drop(shared);
21283 drop(runtime);
21284 std::fs::remove_dir_all(root).unwrap();
21285 }
21286
21287 #[tokio::test]
21288 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21289 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21290 let lease = install_managed_lease(&shared);
21291 let address = SessionAddress {
21292 workspace_id: WorkspaceId::new("primary").unwrap(),
21293 session: SessionKey {
21294 instance_id: AgentInstanceId(204),
21295 generation: SessionGeneration(1),
21296 },
21297 };
21298 let (overlay, materialization_guard) = shared
21299 .prepare_session_materialization(
21300 &address,
21301 &agent("claude"),
21302 SessionMode::Pty,
21303 Some(&receipt),
21304 None,
21305 None,
21306 Some(lease.lease_id.clone()),
21307 )
21308 .unwrap()
21309 .unwrap();
21310 let materialization_id = materialization_guard.id().unwrap().clone();
21311 let materialization_root = shared
21312 .materializations
21313 .lock()
21314 .unwrap_or_else(|poisoned| poisoned.into_inner())
21315 .get(&materialization_id)
21316 .unwrap()
21317 .root()
21318 .to_path_buf();
21319 let selection = shared
21320 .select_environment_profile(
21321 address.session.instance_id,
21322 &agent("claude"),
21323 SessionMode::Pty,
21324 Some(&receipt),
21325 )
21326 .unwrap()
21327 .unwrap();
21328 assert!(shared
21329 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21330 .unwrap()
21331 .is_none());
21332 shared.bind_session_with_materialization(
21333 &address,
21334 ProviderRuntimePolicy::raw_pty(),
21335 Some(receipt),
21336 None,
21337 None,
21338 Some(materialization_id),
21339 );
21340 selection.retain();
21341 materialization_guard.retain();
21342
21343 let blocked = shared
21344 .cleanup_managed_worktree(&lease.lease_id, false)
21345 .await
21346 .unwrap_err();
21347 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21348 assert!(materialization_root.is_dir());
21349 let removed = shared.remove_binding(&address).unwrap();
21350 shared
21351 .cleanup_session_owned_materialization(&address, &removed)
21352 .unwrap();
21353 assert!(!materialization_root.exists());
21354 drop(shared);
21355 drop(runtime);
21356 std::fs::remove_dir_all(root).unwrap();
21357 }
21358
21359 #[tokio::test]
21360 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21361 let git_root = temporary_workspace_root("raw-managed-inventory");
21362 let source = git_root.join("source");
21363 let allocation = git_root.join("allocation");
21364 std::fs::create_dir_all(&source).unwrap();
21365 std::fs::create_dir_all(&allocation).unwrap();
21366 run_test_git(&source, &["init"]);
21367 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21368 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21369 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21370 run_test_git(&source, &["add", "seed.txt"]);
21371 run_test_git(&source, &["commit", "-m", "seed"]);
21372 let profile = ManagedWorktreeProfile::new(
21373 WorktreeProfileId::new("default").unwrap(),
21374 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21375 &allocation,
21376 "gate4agent",
21377 "HEAD",
21378 ManagedWorktreeRetention::RemoveWhenReleased,
21379 )
21380 .unwrap();
21381 let workspace = WorkspaceConfig::new(
21382 WorkspaceId::new("primary").unwrap(),
21383 &source,
21384 )
21385 .unwrap()
21386 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21387 .with_managed_worktree_profile(profile.clone())
21388 .unwrap();
21389 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21390 materialization_test_shared_with_workspace(false, workspace);
21391 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21392 let source_text = source.to_string_lossy().into_owned();
21393 let target_text = target.to_string_lossy().into_owned();
21394 let created = create_git_worktree(
21395 &source_text,
21396 &target_text,
21397 "gate4agent/mw-raw-inventory",
21398 Some("HEAD"),
21399 )
21400 .await
21401 .unwrap();
21402 let lease = ManagedWorktreeLeaseRecord {
21403 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21404 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21405 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21406 profile_id: profile.profile_id().clone(),
21407 profile_revision: profile.revision().clone(),
21408 target_root: created.path.clone(),
21409 branch: "gate4agent/mw-raw-inventory".to_owned(),
21410 base_commit: created.head.clone(),
21411 expected_head: Some(created.head.clone()),
21412 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21413 state: ManagedWorktreeLeaseState::Ready,
21414 session_holders: Vec::new(),
21415 record_holders: Vec::new(),
21416 cleanup_failure: None,
21417 created_at_unix_ms: 1,
21418 updated_at_unix_ms: 1,
21419 };
21420 shared
21421 .workspaces
21422 .write()
21423 .unwrap_or_else(|poisoned| poisoned.into_inner())
21424 .insert(lease.workspace_id.clone(), created.path.clone());
21425 *shared
21426 .managed_worktrees
21427 .lock()
21428 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21429 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21430 let address = SessionAddress {
21431 workspace_id: lease.workspace_id.clone(),
21432 session: SessionKey {
21433 instance_id: AgentInstanceId(205),
21434 generation: SessionGeneration(1),
21435 },
21436 };
21437 let (overlay, materialization_guard) = shared
21438 .prepare_session_materialization(
21439 &address,
21440 &agent("claude"),
21441 SessionMode::Pty,
21442 Some(&receipt),
21443 None,
21444 None,
21445 Some(lease.lease_id.clone()),
21446 )
21447 .unwrap()
21448 .unwrap();
21449 let materialization_id = materialization_guard.id().unwrap().clone();
21450 let materialization_root = shared
21451 .materializations
21452 .lock()
21453 .unwrap_or_else(|poisoned| poisoned.into_inner())
21454 .get(&materialization_id)
21455 .unwrap()
21456 .root()
21457 .to_path_buf();
21458 let selection = shared
21459 .select_environment_profile(
21460 address.session.instance_id,
21461 &agent("claude"),
21462 SessionMode::Pty,
21463 Some(&receipt),
21464 )
21465 .unwrap()
21466 .unwrap();
21467 assert!(shared
21468 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21469 .unwrap()
21470 .is_none());
21471 let record_id = shared
21472 .bind_spawn_session_with_materialization(
21473 &address,
21474 agent("claude"),
21475 SessionMode::Pty,
21476 ProviderRuntimePolicy::raw_pty(),
21477 SpawnRecordPolicy::Always,
21478 Some(receipt.clone()),
21479 None,
21480 None,
21481 Some(materialization_id.clone()),
21482 )
21483 .unwrap()
21484 .unwrap();
21485 selection.retain();
21486 materialization_guard.retain();
21487 let record = shared.record(&record_id).unwrap();
21488 assert_eq!(record.state, ManagedSessionState::Live);
21489 assert!(record.provider_session.is_none());
21490 assert!(record.environment_profile.is_none());
21491 assert!(record.bundle.is_none());
21492 assert!(record.context_id.is_none());
21493 assert!(record.context.is_none());
21494 assert_eq!(record.active_session.as_ref(), Some(&address));
21495 assert_eq!(
21496 shared
21497 .materializations
21498 .lock()
21499 .unwrap_or_else(|poisoned| poisoned.into_inner())
21500 .get(&materialization_id)
21501 .unwrap()
21502 .owner(),
21503 &MaterializationOwner::Session {
21504 incarnation_id: shared.incarnation_id,
21505 instance_id: address.session.instance_id,
21506 generation: address.session.generation,
21507 },
21508 );
21509 {
21510 let mut bindings = shared
21511 .session_bindings
21512 .lock()
21513 .unwrap_or_else(|poisoned| poisoned.into_inner());
21514 bindings
21515 .get_mut(&address.session.instance_id)
21516 .unwrap()
21517 .managed_worktree_lease_id = Some(lease.lease_id.clone());
21518 }
21519 let lease_snapshot = shared
21520 .managed_worktrees
21521 .lock()
21522 .unwrap_or_else(|poisoned| poisoned.into_inner())
21523 .bind_session(
21524 &lease.lease_id,
21525 ManagedWorktreeSessionHolder {
21526 incarnation_id: shared.incarnation_id,
21527 instance_id: address.session.instance_id,
21528 generation: address.session.generation,
21529 },
21530 None,
21531 unix_time_ms(),
21532 )
21533 .unwrap();
21534 assert_eq!(lease_snapshot.active_session_count, 1);
21535 assert_eq!(lease_snapshot.managed_record_count, 0);
21536 shared.persist_state().unwrap();
21537 assert!(shared
21538 .managed_worktrees
21539 .lock()
21540 .unwrap_or_else(|poisoned| poisoned.into_inner())
21541 .get(&lease.lease_id)
21542 .unwrap()
21543 .record_holders
21544 .is_empty());
21545 shared
21546 .handle
21547 .dispatch(shared.prepare_command(ControlCommand::Register {
21548 instance_id: address.session.instance_id,
21549 agent_id: agent("claude"),
21550 transport: TransportKind::Pty,
21551 }))
21552 .unwrap();
21553 runtime.tick().await;
21554 shared.reconcile_managed_record(
21555 &record_id,
21556 &address,
21557 &ControlEvent {
21558 sequence: 1,
21559 command_id: None,
21560 instance_id: address.session.instance_id,
21561 generation: address.session.generation,
21562 event: ControlEventKind::Removed,
21563 },
21564 false,
21565 false,
21566 );
21567 let remove = shared.remove_session(&address);
21568 let drive_runtime = async {
21569 runtime.tick().await;
21570 };
21571 let (removed, ()) = tokio::join!(remove, drive_runtime);
21572 removed.unwrap();
21573
21574 assert!(!materialization_root.exists());
21575 assert!(!Path::new(&created.path).exists());
21576 assert_eq!(
21577 shared
21578 .managed_worktrees
21579 .lock()
21580 .unwrap_or_else(|poisoned| poisoned.into_inner())
21581 .get(&lease.lease_id)
21582 .unwrap()
21583 .state,
21584 ManagedWorktreeLeaseState::Removed,
21585 );
21586 let renamed = shared
21587 .rename_session_record(&record_id, "raw managed inventory".to_owned())
21588 .unwrap();
21589 assert_eq!(renamed.record_id, record_id);
21590 assert_eq!(renamed.display_name, "raw managed inventory");
21591 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21592 assert!(renamed.active_session.is_none());
21593
21594 drop(shared);
21595 drop(runtime);
21596 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21597 std::fs::remove_dir_all(git_root).unwrap();
21598 }
21599
21600 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21601 let root = std::env::current_dir().unwrap()
21602 .join("managed-test-target")
21603 .to_string_lossy()
21604 .into_owned();
21605 let lease = ManagedWorktreeLeaseRecord {
21606 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21607 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21608 workspace_id: WorkspaceId::new("managed-test").unwrap(),
21609 profile_id: WorktreeProfileId::new("default").unwrap(),
21610 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21611 target_root: root.clone(),
21612 branch: "gate4agent/mw-test".to_owned(),
21613 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21614 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21615 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21616 state: ManagedWorktreeLeaseState::Ready,
21617 session_holders: Vec::new(),
21618 record_holders: Vec::new(),
21619 cleanup_failure: None,
21620 created_at_unix_ms: 1,
21621 updated_at_unix_ms: 1,
21622 };
21623 shared.workspaces.write()
21624 .unwrap_or_else(|poisoned| poisoned.into_inner())
21625 .insert(lease.workspace_id.clone(), root);
21626 *shared.managed_worktrees.lock()
21627 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21628 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21629 lease
21630 }
21631
21632 #[cfg(feature = "fixture")]
21633 #[test]
21634 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21635 let mut shared = terminal_test_shared();
21636 shared.fixture_semantic_hook_policy = true;
21637 let lease = install_managed_lease(&shared);
21638 let address = SessionAddress {
21639 workspace_id: lease.workspace_id.clone(),
21640 session: SessionKey {
21641 instance_id: AgentInstanceId(206),
21642 generation: SessionGeneration(1),
21643 },
21644 };
21645 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21646 let effective_runtime_policy =
21647 shared.effective_spawn_runtime_policy(pre_admission_policy);
21648 assert_ne!(effective_runtime_policy, pre_admission_policy);
21649 assert!(effective_runtime_policy.provider_session_identity);
21650
21651 let record_id = shared
21652 .bind_spawn_session(
21653 &address,
21654 agent("claude"),
21655 SessionMode::Pty,
21656 effective_runtime_policy,
21657 SpawnRecordPolicy::Always,
21658 None,
21659 )
21660 .unwrap()
21661 .unwrap();
21662 assert_eq!(
21663 shared.record(&record_id).unwrap().state,
21664 ManagedSessionState::IdentityPending,
21665 );
21666 let (record_holder, raw_inventory_record_id) = {
21667 let mut bindings = shared
21668 .session_bindings
21669 .lock()
21670 .unwrap_or_else(|poisoned| poisoned.into_inner());
21671 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
21672 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
21673 managed_spawn_record_ownership(binding, effective_runtime_policy)
21674 };
21675 assert_eq!(record_holder.as_ref(), Some(&record_id));
21676 assert!(raw_inventory_record_id.is_none());
21677 let snapshot = shared
21678 .managed_worktrees
21679 .lock()
21680 .unwrap_or_else(|poisoned| poisoned.into_inner())
21681 .bind_session(
21682 &lease.lease_id,
21683 ManagedWorktreeSessionHolder {
21684 incarnation_id: shared.incarnation_id,
21685 instance_id: address.session.instance_id,
21686 generation: address.session.generation,
21687 },
21688 record_holder,
21689 unix_time_ms(),
21690 )
21691 .unwrap();
21692 assert_eq!(snapshot.managed_record_count, 1);
21693 assert_eq!(
21694 shared
21695 .managed_worktrees
21696 .lock()
21697 .unwrap_or_else(|poisoned| poisoned.into_inner())
21698 .get(&lease.lease_id)
21699 .unwrap()
21700 .record_holders,
21701 vec![record_id.clone()],
21702 );
21703
21704 let identity = ProviderSessionIdentity {
21705 key: ProviderSessionKey::SessionId,
21706 id: "monitoring-fixture-managed-session".to_owned(),
21707 transcript_path: None,
21708 };
21709 shared.publish_control(ControlEvent {
21710 sequence: 1,
21711 command_id: None,
21712 instance_id: address.session.instance_id,
21713 generation: address.session.generation,
21714 event: ControlEventKind::ProviderEvent {
21715 sequence: 1,
21716 source: gate4agent_types::ProviderSource {
21717 family: AdapterFamily::Hook,
21718 binding: AdapterBinding::new(
21719 gate4agent_types::AdapterId::new("claude-code").unwrap(),
21720 "fixture/v1",
21721 gate4agent_types::AdapterVerification::SyntheticFixture,
21722 )
21723 .unwrap(),
21724 },
21725 source_sequence: 1,
21726 event: ProviderEvent::SessionIdentityObserved {
21727 identity: identity.clone(),
21728 },
21729 },
21730 });
21731
21732 let live = shared.record(&record_id).unwrap();
21733 assert_eq!(live.state, ManagedSessionState::Live);
21734 assert_eq!(live.provider_session.as_ref(), Some(&identity));
21735 assert_eq!(live.active_session.as_ref(), Some(&address));
21736 assert_eq!(
21737 shared
21738 .managed_worktrees
21739 .lock()
21740 .unwrap_or_else(|poisoned| poisoned.into_inner())
21741 .get(&lease.lease_id)
21742 .unwrap()
21743 .record_holders,
21744 vec![record_id],
21745 );
21746 }
21747
21748 fn managed_v2_test_shared(
21749 allocation_root: &Path,
21750 state_path: Option<PathBuf>,
21751 profile_revision: &str,
21752 ) -> NodeShared {
21753 let profile = ManagedWorktreeProfile::new(
21754 WorktreeProfileId::new("default").unwrap(),
21755 WorktreeProfileRevision::new(profile_revision).unwrap(),
21756 allocation_root,
21757 "gate4agent",
21758 "HEAD",
21759 ManagedWorktreeRetention::RemoveWhenReleased,
21760 )
21761 .unwrap();
21762 let workspace = WorkspaceConfig::new(
21763 WorkspaceId::new("primary").unwrap(),
21764 std::env::current_dir().unwrap(),
21765 )
21766 .unwrap()
21767 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21768 .with_managed_worktree_profile(profile)
21769 .unwrap();
21770 let catalog = active_registry().unwrap();
21771 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21772 let mut shared = NodeShared::new(
21773 handle,
21774 "fixture-token".to_owned(),
21775 NodeId::new("node-terminal-test").unwrap(),
21776 vec![workspace],
21777 vec![agent("claude")],
21778 );
21779 shared.state_path = state_path;
21780 shared
21781 }
21782
21783 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
21784 let mut spawn_spec = spawn_spec_fixture();
21785 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
21786 ManagedWorktreeSpawnRequestV2 {
21787 spawn_spec,
21788 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21789 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
21790 }
21791 }
21792
21793 #[test]
21794 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
21795 let shared = terminal_test_shared();
21796 let mut lease = install_managed_lease(&shared);
21797 let session = SessionAddress {
21798 workspace_id: lease.workspace_id.clone(),
21799 session: SessionKey {
21800 instance_id: AgentInstanceId(73),
21801 generation: SessionGeneration(4),
21802 },
21803 };
21804 lease.state = ManagedWorktreeLeaseState::InUse;
21805 lease.session_holders.push(ManagedWorktreeSessionHolder {
21806 incarnation_id: shared.incarnation_id,
21807 instance_id: session.session.instance_id,
21808 generation: session.session.generation,
21809 });
21810 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21811 assert!(resolved.target.worktree_id.is_none());
21812
21813 let receipt = managed_spawn_receipt(
21814 &resolved,
21815 shared.incarnation_id,
21816 session.clone(),
21817 lease.snapshot(),
21818 None,
21819 None,
21820 None,
21821 );
21822 assert!(resolved.target.worktree_id.is_none());
21823 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
21824 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
21825 assert_eq!(receipt.spawn.session, session);
21826 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
21827 let encoded = serde_json::to_vec(&receipt).unwrap();
21828 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
21829 assert_eq!(decoded, receipt);
21830 }
21831
21832 #[tokio::test]
21833 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
21834 let root = temporary_workspace_root("managed-v2-revision");
21835 let allocation = root.join("allocation");
21836 std::fs::create_dir_all(&allocation).unwrap();
21837 let shared = managed_v2_test_shared(
21838 &allocation,
21839 Some(root.join("node-state.json")),
21840 "v1",
21841 );
21842 let mut request = managed_v2_request("managed-v2-revision");
21843 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
21844
21845 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
21846
21847 assert_eq!(
21848 failure.code,
21849 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21850 );
21851 assert!(shared
21852 .managed_worktrees
21853 .lock()
21854 .unwrap_or_else(|poisoned| poisoned.into_inner())
21855 .records()
21856 .is_empty());
21857 assert!(shared
21858 .managed_spawn_replays
21859 .lock()
21860 .unwrap_or_else(|poisoned| poisoned.into_inner())
21861 .is_empty());
21862 std::fs::remove_dir_all(root).unwrap();
21863 }
21864
21865 #[test]
21866 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
21867 let root = temporary_workspace_root("managed-v2-concurrent");
21868 let allocation = root.join("allocation");
21869 std::fs::create_dir_all(&allocation).unwrap();
21870 let shared = Arc::new(managed_v2_test_shared(
21871 &allocation,
21872 Some(root.join("node-state.json")),
21873 "v1",
21874 ));
21875 let request = managed_v2_request("managed-v2-concurrent");
21876 let barrier = Arc::new(std::sync::Barrier::new(3));
21877 let mut joins = Vec::new();
21878 for _ in 0..2 {
21879 let shared = Arc::clone(&shared);
21880 let request = request.clone();
21881 let barrier = Arc::clone(&barrier);
21882 joins.push(std::thread::spawn(move || {
21883 let digest = managed_spawn_request_digest_v2(&request).unwrap();
21884 barrier.wait();
21885 shared.acquire_durable_managed_spawn_v2(&request, digest)
21886 }));
21887 }
21888 barrier.wait();
21889 let results = joins
21890 .into_iter()
21891 .map(|join| join.join().unwrap())
21892 .collect::<Vec<_>>();
21893 assert_eq!(
21894 results
21895 .iter()
21896 .filter(|result| {
21897 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
21898 })
21899 .count(),
21900 1,
21901 );
21902 assert_eq!(
21903 results
21904 .iter()
21905 .filter(|result| {
21906 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
21907 })
21908 .count(),
21909 1,
21910 );
21911 assert_eq!(
21912 shared
21913 .managed_spawn_replays
21914 .lock()
21915 .unwrap_or_else(|poisoned| poisoned.into_inner())
21916 .len(),
21917 1,
21918 );
21919 assert!(shared
21920 .managed_worktrees
21921 .lock()
21922 .unwrap_or_else(|poisoned| poisoned.into_inner())
21923 .records()
21924 .is_empty());
21925 drop(shared);
21926 std::fs::remove_dir_all(root).unwrap();
21927 }
21928
21929 #[tokio::test]
21930 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
21931 let root = temporary_workspace_root("managed-v2-linked-pending");
21932 let allocation = root.join("allocation");
21933 std::fs::create_dir_all(&allocation).unwrap();
21934 let state_path = root.join("node-state.json");
21935 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21936 let lease = install_managed_lease(&shared);
21937 let request = managed_v2_request("managed-v2-linked-pending");
21938 assert!(matches!(
21939 shared
21940 .acquire_durable_managed_spawn_v2(
21941 &request,
21942 managed_spawn_request_digest_v2(&request).unwrap(),
21943 )
21944 .unwrap(),
21945 DurableManagedSpawnReplayDecision::Reserved,
21946 ));
21947 {
21948 let _transaction = shared
21949 .state_transaction
21950 .lock()
21951 .unwrap_or_else(|poisoned| poisoned.into_inner());
21952 shared
21953 .link_durable_managed_spawn_v2_lease_locked(
21954 &request.spawn_spec.idempotency_key,
21955 &lease.lease_id,
21956 )
21957 .unwrap();
21958 shared.persist_state_locked().unwrap();
21959 }
21960 drop(shared);
21961
21962 let mut loaded = session_registry::load(
21963 Some(&state_path),
21964 &NodeId::new("node-terminal-test").unwrap(),
21965 )
21966 .unwrap();
21967 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21968 *reopened
21969 .managed_worktrees
21970 .lock()
21971 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21972 ManagedWorktreeRegistry::from_records(
21973 std::mem::take(&mut loaded.managed_worktrees),
21974 std::mem::take(&mut loaded.managed_worktree_tombstones),
21975 )
21976 .unwrap();
21977 *reopened
21978 .managed_spawn_replays
21979 .lock()
21980 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21981 .managed_spawn_replays
21982 .into_iter()
21983 .map(|record| (record.idempotency_key.clone(), record))
21984 .collect();
21985 let lease_count = reopened
21986 .managed_worktrees
21987 .lock()
21988 .unwrap_or_else(|poisoned| poisoned.into_inner())
21989 .records()
21990 .len();
21991
21992 let failure = reopened
21993 .spawn_managed_worktree_v2(request)
21994 .await
21995 .unwrap_err();
21996
21997 assert_eq!(
21998 failure.code,
21999 NodeFailureCode::ManagedWorktreeRecoveryRequired,
22000 );
22001 assert_eq!(
22002 reopened
22003 .managed_worktrees
22004 .lock()
22005 .unwrap_or_else(|poisoned| poisoned.into_inner())
22006 .records()
22007 .len(),
22008 lease_count,
22009 );
22010 std::fs::remove_dir_all(root).unwrap();
22011 }
22012
22013 #[tokio::test]
22014 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22015 let root = temporary_workspace_root("managed-v2-reopen");
22016 let allocation = root.join("allocation");
22017 std::fs::create_dir_all(&allocation).unwrap();
22018 let state_path = root.join("node-state.json");
22019 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22020 let lease = install_managed_lease(&shared);
22021 let request = managed_v2_request("managed-v2-reopen");
22022 let session = SessionAddress {
22023 workspace_id: lease.workspace_id.clone(),
22024 session: SessionKey {
22025 instance_id: AgentInstanceId(91),
22026 generation: SessionGeneration(1),
22027 },
22028 };
22029 let snapshot = {
22030 let mut registry = shared
22031 .managed_worktrees
22032 .lock()
22033 .unwrap_or_else(|poisoned| poisoned.into_inner());
22034 let current = registry.get_mut(&lease.lease_id).unwrap();
22035 current.state = ManagedWorktreeLeaseState::InUse;
22036 current.session_holders.push(ManagedWorktreeSessionHolder {
22037 incarnation_id: shared.incarnation_id,
22038 instance_id: session.session.instance_id,
22039 generation: session.session.generation,
22040 });
22041 current.snapshot()
22042 };
22043 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22044 let receipt = managed_spawn_receipt(
22045 &resolved,
22046 shared.incarnation_id,
22047 session,
22048 snapshot,
22049 None,
22050 None,
22051 None,
22052 );
22053 assert!(matches!(
22054 shared
22055 .acquire_durable_managed_spawn_v2(
22056 &request,
22057 managed_spawn_request_digest_v2(&request).unwrap(),
22058 )
22059 .unwrap(),
22060 DurableManagedSpawnReplayDecision::Reserved,
22061 ));
22062 {
22063 let _transaction = shared
22064 .state_transaction
22065 .lock()
22066 .unwrap_or_else(|poisoned| poisoned.into_inner());
22067 shared
22068 .link_durable_managed_spawn_v2_lease_locked(
22069 &request.spawn_spec.idempotency_key,
22070 &lease.lease_id,
22071 )
22072 .unwrap();
22073 shared.persist_state_locked().unwrap();
22074 }
22075 shared
22076 .commit_durable_managed_spawn_v2(&request, receipt.clone())
22077 .unwrap();
22078 let legacy_same_key = ManagedWorktreeSpawnRequest {
22079 spawn_spec: request.spawn_spec.clone(),
22080 worktree_profile_id: request.worktree_profile_id.clone(),
22081 };
22082 shared.remember_managed_spawn_attempt(
22083 Some(&request.spawn_spec.idempotency_key),
22084 legacy_same_key.clone(),
22085 Ok(receipt.clone()),
22086 Instant::now(),
22087 );
22088 assert!(shared
22089 .replay_managed_spawn(&legacy_same_key, Instant::now())
22090 .unwrap()
22091 .is_none());
22092 drop(shared);
22093
22094 let mut loaded = session_registry::load(
22095 Some(&state_path),
22096 &NodeId::new("node-terminal-test").unwrap(),
22097 )
22098 .unwrap();
22099 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22100 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22101 *reopened
22102 .managed_worktrees
22103 .lock()
22104 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22105 ManagedWorktreeRegistry::from_records(
22106 std::mem::take(&mut loaded.managed_worktrees),
22107 std::mem::take(&mut loaded.managed_worktree_tombstones),
22108 )
22109 .unwrap();
22110 *reopened
22111 .managed_spawn_replays
22112 .lock()
22113 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22114 .managed_spawn_replays
22115 .into_iter()
22116 .map(|record| (record.idempotency_key.clone(), record))
22117 .collect();
22118 let lease_count_before = reopened
22119 .managed_worktrees
22120 .lock()
22121 .unwrap_or_else(|poisoned| poisoned.into_inner())
22122 .records()
22123 .len();
22124
22125 assert_eq!(
22126 reopened
22127 .spawn_managed_worktree_v2(request.clone())
22128 .await
22129 .unwrap(),
22130 receipt,
22131 );
22132 assert_eq!(
22133 reopened
22134 .managed_worktrees
22135 .lock()
22136 .unwrap_or_else(|poisoned| poisoned.into_inner())
22137 .records()
22138 .len(),
22139 lease_count_before,
22140 );
22141 let mut changed = request;
22142 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22143 let failure = reopened
22144 .spawn_managed_worktree_v2(changed)
22145 .await
22146 .unwrap_err();
22147 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22148 assert_eq!(
22149 reopened
22150 .managed_worktrees
22151 .lock()
22152 .unwrap_or_else(|poisoned| poisoned.into_inner())
22153 .records()
22154 .len(),
22155 lease_count_before,
22156 );
22157 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22158 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22159 let failure = reopened
22160 .spawn_managed_worktree_v2(new_key)
22161 .await
22162 .unwrap_err();
22163 assert_eq!(
22164 failure.code,
22165 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22166 );
22167 assert_eq!(
22168 reopened
22169 .managed_worktrees
22170 .lock()
22171 .unwrap_or_else(|poisoned| poisoned.into_inner())
22172 .records()
22173 .len(),
22174 lease_count_before,
22175 );
22176 assert_eq!(
22177 reopened
22178 .managed_spawn_replays
22179 .lock()
22180 .unwrap_or_else(|poisoned| poisoned.into_inner())
22181 .len(),
22182 1,
22183 );
22184 std::fs::remove_dir_all(root).unwrap();
22185 }
22186
22187 #[test]
22188 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22189 let shared = terminal_test_shared();
22190 let request = ManagedWorktreeSpawnRequest {
22191 spawn_spec: spawn_spec_fixture(),
22192 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22193 };
22194 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22195 shared.remember_managed_spawn(
22196 request.clone(),
22197 Err(expected.clone()),
22198 Instant::now(),
22199 );
22200
22201 assert_eq!(
22202 shared
22203 .replay_managed_spawn(&request, Instant::now())
22204 .unwrap()
22205 .unwrap(),
22206 Err(expected),
22207 );
22208 assert!(shared
22209 .managed_spawn_replays
22210 .lock()
22211 .unwrap_or_else(|poisoned| poisoned.into_inner())
22212 .is_empty());
22213 }
22214
22215 #[tokio::test]
22216 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22217 let shared = terminal_test_shared();
22218 let lease = install_managed_lease(&shared);
22219 let raw = shared.spawn_session(
22220 lease.workspace_id.clone(),
22221 agent("claude"),
22222 SessionMode::Pty,
22223 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22224 None,
22225 ).await.unwrap_err();
22226 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22227 let mut spec = spawn_spec_fixture();
22228 spec.target.workspace_id = lease.workspace_id.clone();
22229 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22230 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22231 let mut selected = spawn_spec_fixture();
22232 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22233 selected.target.worktree_id = Some(lease.workspace_id);
22234 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22235 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22236 assert!(shared.handle.snapshot().sessions.is_empty());
22237 }
22238
22239 #[test]
22240 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22241 let shared = terminal_test_shared();
22242 let lease = install_managed_lease(&shared);
22243 let snapshot = snapshot_for_wire(
22244 &shared, true, true, true, false, true, true, false, false, true,
22245 );
22246 assert!(snapshot.managed_worktrees.is_empty());
22247 assert!(snapshot.workspaces.iter().all(|workspace| {
22248 workspace.managed_worktree_profiles.is_none()
22249 }));
22250 let mut reply = ResponseEnvelope {
22251 request_id: 1,
22252 result: Ok(NodeResponse::Resync {
22253 event_sequence: 1,
22254 oldest_available_sequence: 1,
22255 snapshot: shared.snapshot(),
22256 events: vec![NodeEventEnvelope {
22257 sequence: 1,
22258 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22259 }],
22260 }),
22261 };
22262 project_response_without_managed_worktrees(&mut reply);
22263 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22264 panic!("projection changed the response kind");
22265 };
22266 assert!(snapshot.managed_worktrees.is_empty());
22267 assert!(events.is_empty());
22268 let mut git = git_snapshot_for_parser();
22269 git.branch = Some(lease.branch.clone());
22270 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22271 lease_id: lease.lease_id.clone(),
22272 source_workspace_id: lease.source_workspace_id.clone(),
22273 branch: lease.branch.clone(),
22274 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22275 active_session_count: 1,
22276 managed_record_count: 0,
22277 });
22278 let mut inspection_reply = ResponseEnvelope {
22279 request_id: 2,
22280 result: Ok(NodeResponse::WorkspaceInspected {
22281 inspection: WorkspaceInspection {
22282 workspace_id: lease.workspace_id.clone(),
22283 entries: Vec::new(),
22284 tree_truncated: false,
22285 git,
22286 truncation: None,
22287 },
22288 }),
22289 };
22290 project_response_without_managed_worktrees(&mut inspection_reply);
22291 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22292 panic!("projection changed the inspection response kind");
22293 };
22294 assert!(inspection.git.managed_worktree.is_none());
22295 let failure = managed_git_worktree_failure(GitWorktreeError {
22296 kind: GitWorktreeErrorKind::Failed,
22297 message: r"git failed at C:\private\managed-target".to_owned(),
22298 });
22299 assert!(!failure.message.contains("private"));
22300 assert!(!failure.message.contains("managed-target"));
22301 }
22302
22303 #[tokio::test]
22304 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22305 let root = temporary_workspace_root("managed-restart");
22306 let source = root.join("source");
22307 let allocation = root.join("allocation");
22308 std::fs::create_dir_all(&source).unwrap();
22309 std::fs::create_dir_all(&allocation).unwrap();
22310 run_test_git(&source, &["init"]);
22311 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22312 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22313 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22314 run_test_git(&source, &["add", "seed.txt"]);
22315 run_test_git(&source, &["commit", "-m", "seed"]);
22316 let profile = ManagedWorktreeProfile::new(
22317 WorktreeProfileId::new("default").unwrap(),
22318 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22319 &allocation,
22320 "gate4agent",
22321 "HEAD",
22322 ManagedWorktreeRetention::Retain,
22323 ).unwrap();
22324 let workspace = WorkspaceConfig::new(
22325 WorkspaceId::new("primary").unwrap(),
22326 &source,
22327 ).unwrap()
22328 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22329 .with_managed_worktree_profile(profile.clone()).unwrap();
22330 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22331 let source_text = workspace.canonical_root().to_owned();
22332 let target_text = target.to_string_lossy().into_owned();
22333 let created = create_git_worktree(
22334 &source_text,
22335 &target_text,
22336 "gate4agent/mw-restart",
22337 Some("HEAD"),
22338 ).await.unwrap();
22339 let lease = ManagedWorktreeLeaseRecord {
22340 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22341 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22342 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22343 profile_id: profile.profile_id().clone(),
22344 profile_revision: profile.revision().clone(),
22345 target_root: created.path.clone(),
22346 branch: "gate4agent/mw-restart".to_owned(),
22347 base_commit: created.head.clone(),
22348 expected_head: None,
22349 retention: ManagedWorktreeRetention::Retain,
22350 state: ManagedWorktreeLeaseState::Allocating,
22351 session_holders: Vec::new(),
22352 record_holders: Vec::new(),
22353 cleanup_failure: None,
22354 created_at_unix_ms: 1,
22355 updated_at_unix_ms: 1,
22356 };
22357 let catalog = active_registry().unwrap();
22358 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22359 let shared = NodeShared::new_with_incarnation(
22360 handle,
22361 "fixture-token".to_owned(),
22362 NodeId::new("node-managed-restart").unwrap(),
22363 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22364 vec![workspace],
22365 vec![agent("claude")],
22366 ProviderRuntimeStatuses::default(),
22367 None,
22368 Vec::new(),
22369 Vec::new(),
22370 SpawnProfileRegistry::default(),
22371 None,
22372 None,
22373 None,
22374 None,
22375 Vec::new(),
22376 vec![lease.clone()],
22377 Vec::new(),
22378 Vec::new(),
22379 None,
22380 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22381 );
22382 shared.reconcile_managed_worktrees().await;
22383 let retained = shared.managed_worktrees.lock()
22384 .unwrap_or_else(|poisoned| poisoned.into_inner())
22385 .get(&lease.lease_id).unwrap().clone();
22386 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22387 assert!(shared.workspaces.read()
22388 .unwrap_or_else(|poisoned| poisoned.into_inner())
22389 .contains_key(&lease.workspace_id));
22390 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22391 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22392 assert!(!Path::new(&created.path).exists());
22393 std::fs::remove_dir_all(root).unwrap();
22394 }
22395
22396 fn run_test_git(root: &Path, arguments: &[&str]) {
22397 let output = std::process::Command::new("git")
22398 .arg("-C").arg(root).args(arguments).output().unwrap();
22399 assert!(
22400 output.status.success(),
22401 "git {:?} failed: {}",
22402 arguments,
22403 String::from_utf8_lossy(&output.stderr),
22404 );
22405 }
22406
22407 #[tokio::test]
22408 async fn spawn_spec_validation_fails_before_session_mutation() {
22409 let shared = terminal_test_shared();
22410 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22411 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22412 }));
22413 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22414 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22415 }));
22416 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22417 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22418 }));
22419
22420 let mut target_mismatch = spawn_spec_fixture();
22421 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22422 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22423 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22424
22425 let mut unknown_profile = spawn_spec_fixture();
22426 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22427 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22428 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22429 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22430
22431 let mut revision_mismatch = spawn_spec_fixture();
22432 revision_mismatch.idempotency_key =
22433 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22434 revision_mismatch.expected_profile_revision =
22435 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22436 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22437 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22438
22439 let mut unsupported_materializer = spawn_spec_fixture();
22440 unsupported_materializer.idempotency_key =
22441 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22442 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22443 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22444 };
22445 let failure = shared
22446 .spawn_from_spec(unsupported_materializer)
22447 .await
22448 .unwrap_err();
22449 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22450
22451 let failure = shared
22452 .spawn_session_with_deadline(
22453 WorkspaceId::new("primary").unwrap(),
22454 agent("claude"),
22455 SessionMode::Pty,
22456 gate4agent_types::TerminalSize {
22457 rows: 24,
22458 columns: 80,
22459 },
22460 None,
22461 None,
22462 None,
22463 None,
22464 None,
22465 None,
22466 SpawnRecordPolicy::ProviderIdentityOnly,
22467 Some(Instant::now()),
22468 &[],
22469 None,
22470 ApprovalLevel::default(),
22471 None,
22472 None,
22473 )
22474 .await
22475 .unwrap_err();
22476 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22477 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22478 assert!(shared.handle.snapshot().sessions.is_empty());
22479 }
22480
22481 #[test]
22482 fn worktree_selection_requires_its_separate_negotiated_capability() {
22483 let mut spec = spawn_spec_fixture();
22484 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22485 let request = NodeRequest::SpawnSpec { spec };
22486 let spawn_spec_capability =
22487 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22488 assert!(request_uses_unnegotiated_capability(
22489 &request,
22490 &[spawn_spec_capability.clone()],
22491 ));
22492 let worktree_capability =
22493 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22494 assert!(request_uses_unnegotiated_capability(
22495 &request,
22496 &[spawn_spec_capability.clone(), worktree_capability.clone()],
22497 ));
22498 let profile_revision_capability =
22499 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22500 assert!(request_uses_unnegotiated_capability(
22501 &request,
22502 &[
22503 spawn_spec_capability.clone(),
22504 worktree_capability.clone(),
22505 profile_revision_capability.clone(),
22506 ],
22507 ));
22508 let bundle_materialization_capability =
22515 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22516 assert!(request_uses_unnegotiated_capability(
22517 &request,
22518 &[
22519 spawn_spec_capability.clone(),
22520 worktree_capability.clone(),
22521 profile_revision_capability.clone(),
22522 bundle_materialization_capability.clone(),
22523 ],
22524 ));
22525 let history_context_pack_capability =
22526 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22527 assert!(!request_uses_unnegotiated_capability(
22528 &request,
22529 &[
22530 spawn_spec_capability,
22531 worktree_capability,
22532 profile_revision_capability,
22533 bundle_materialization_capability,
22534 history_context_pack_capability,
22535 ],
22536 ));
22537 }
22538
22539 #[tokio::test]
22540 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22541 let mut shared = terminal_test_shared();
22542 let spec = spawn_spec_fixture();
22543 let receipt = shared
22544 .resolve_spawn_spec(&spec)
22545 .unwrap()
22546 .receipt(shared.incarnation_id, terminal_address(1));
22547 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22548
22549 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22550 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22551 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22552
22553 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22554
22555 let mut revision_conflicting = spec.clone();
22556 revision_conflicting.expected_profile_revision =
22557 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22558 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22559 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22560
22561 let mut stale_cache_miss = spec.clone();
22562 stale_cache_miss.idempotency_key =
22563 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22564 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22565 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22566
22567 let mut conflicting = spec;
22568 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22569 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22570 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22571 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22572 assert!(shared.handle.snapshot().sessions.is_empty());
22573 }
22574
22575 #[tokio::test]
22576 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22577 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22578 let mut spec = spawn_spec_fixture();
22579 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22580 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22581 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22582
22583 let failure = shared
22584 .create_worktree(
22585 WorkspaceId::new("primary").unwrap(),
22586 WorkspaceId::new("new-worktree").unwrap(),
22587 "not-an-absolute-path".to_owned(),
22588 "invalid branch".to_owned(),
22589 None,
22590 )
22591 .await
22592 .unwrap_err();
22593 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22594
22595 let failure = shared
22596 .remove_worktree(
22597 WorkspaceId::new("primary").unwrap(),
22598 "not-an-absolute-path".to_owned(),
22599 )
22600 .await
22601 .unwrap_err();
22602 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22603 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22604 assert!(shared.handle.snapshot().sessions.is_empty());
22605 }
22606
22607 #[tokio::test]
22608 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22609 let root = temporary_workspace_root("manual-worktree-compensation");
22610 let source = root.join("source");
22611 let target = root.join("target");
22612 let invalid_state_parent = root.join("state-parent-is-a-file");
22613 let invalid_state_path = invalid_state_parent.join("state.json");
22614 std::fs::create_dir_all(&source).unwrap();
22615 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22616 run_test_git(&source, &["init"]);
22617 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22618 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22619 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22620 run_test_git(&source, &["add", "seed.txt"]);
22621 run_test_git(&source, &["commit", "-m", "seed"]);
22622
22623 let catalog = active_registry().unwrap();
22624 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22625 let workspace = WorkspaceConfig::new(
22626 WorkspaceId::new("primary").unwrap(),
22627 &source,
22628 ).unwrap();
22629 let mut shared = NodeShared::new(
22630 handle,
22631 "fixture-token".to_owned(),
22632 NodeId::new("node-worktree-compensation").unwrap(),
22633 vec![workspace],
22634 vec![agent("claude")],
22635 );
22636 shared.state_path = Some(invalid_state_path);
22637
22638 let error = shared.create_worktree(
22639 WorkspaceId::new("primary").unwrap(),
22640 WorkspaceId::new("compensated").unwrap(),
22641 target.to_string_lossy().into_owned(),
22642 "gate4agent/compensated".to_owned(),
22643 Some("HEAD".to_owned()),
22644 ).await.unwrap_err();
22645
22646 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22647 assert!(!target.exists());
22648 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22649 .iter()
22650 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22651 std::fs::remove_dir_all(root).unwrap();
22652 }
22653
22654 #[test]
22655 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22656 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22657 NativeGitWorktreeSnapshot {
22658 path: path.to_owned(),
22659 head: "0123456789abcdef".to_owned(),
22660 branch: Some("refs/heads/test".to_owned()),
22661 is_bare,
22662 is_main,
22663 locked: false,
22664 lock_reason: None,
22665 prunable: false,
22666 prunable_reason: None,
22667 workspace_id: None,
22668 }
22669 }
22670
22671 let source = r"C:\repo";
22672 let selected = r"C:\repo-worktree";
22673 let listed = vec![
22674 worktree(source, true, false),
22675 worktree(selected, false, false),
22676 ];
22677 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
22678
22679 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
22680 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
22681
22682 let bare = vec![
22683 worktree(source, true, false),
22684 worktree(selected, false, true),
22685 ];
22686 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
22687 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
22688
22689 let foreign_failure =
22690 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
22691 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
22692 }
22693
22694 fn terminal_frame(sequence: u64) -> TerminalFrame {
22695 TerminalFrame {
22696 sequence,
22697 size: gate4agent_types::TerminalSize {
22698 rows: 24,
22699 columns: 80,
22700 },
22701 cursor_row: 2,
22702 cursor_column: 3,
22703 contents: format!("frame-{sequence}"),
22704 formatted: vec![1, 2, 3],
22705 scrollback_formatted: Vec::new(),
22706 alternate_screen: false,
22707 mouse_protocol_enabled: false,
22708 mouse_protocol_encoding: Default::default(),
22709 produced_at_unix_ms: 0,
22710 screen_state: gate4agent_types::PtyScreenState::default(),
22711 bracketed_paste: None,
22712 }
22713 }
22714
22715 #[test]
22716 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
22717 let shared = terminal_test_shared();
22718 let mut receiver = shared.terminal_event_tx.subscribe();
22719 let original = terminal_address(1);
22720
22721 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22722 let first = receiver.try_recv().unwrap();
22723 assert_eq!(first.len(), 1);
22724 assert!(matches!(
22725 &first[0].event,
22726 NodeEvent::TerminalFrame { address, frame }
22727 if address == &original && frame.sequence == 7
22728 ));
22729
22730 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22731 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
22732 assert!(matches!(
22733 receiver.try_recv(),
22734 Err(broadcast::error::TryRecvError::Empty)
22735 ));
22736
22737 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
22738 let advanced = receiver.try_recv().unwrap();
22739 assert_eq!(advanced[0].sequence, 2);
22740
22741 let resumed = terminal_address(2);
22742 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
22743 let reset = receiver.try_recv().unwrap();
22744 assert_eq!(reset[0].sequence, 3);
22745 assert!(matches!(
22746 &reset[0].event,
22747 NodeEvent::TerminalFrame { address, frame }
22748 if address == &resumed && frame.sequence == 1
22749 ));
22750 shared.clear_terminal_frame_watermark(&resumed);
22751 assert!(!shared
22752 .terminal_frame_watermarks
22753 .lock()
22754 .unwrap_or_else(|poisoned| poisoned.into_inner())
22755 .contains_key(&resumed.session.instance_id));
22756 }
22757
22758 #[test]
22759 fn terminal_frame_batches_replace_without_entering_node_event_history() {
22760 let shared = terminal_test_shared();
22761 let mut receiver = shared.terminal_event_tx.subscribe();
22762 let address = terminal_address(1);
22763 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
22764
22765 for sequence in 1..=count {
22766 shared.publish_terminal_frame_candidates(vec![(
22767 address.clone(),
22768 terminal_frame(sequence),
22769 )]);
22770 }
22771
22772 let history = shared
22773 .history
22774 .lock()
22775 .unwrap_or_else(|poisoned| poisoned.into_inner());
22776 assert!(history.events.is_empty());
22777 assert_eq!(history.last_sequence, count);
22778 assert_eq!(history.replay_floor_sequence, 1);
22779 drop(history);
22780 let NodeResponse::Resync {
22781 event_sequence,
22782 oldest_available_sequence,
22783 events,
22784 ..
22785 } = shared.resync(0) else {
22786 unreachable!("resync helper returned another response")
22787 };
22788 assert_eq!(event_sequence, count);
22789 assert_eq!(oldest_available_sequence, 1);
22790 assert!(events.is_empty());
22791 assert!(matches!(
22792 resync_required_event(&shared).event,
22793 NodeEvent::ResyncRequired {
22794 oldest_available_sequence: 1,
22795 }
22796 ));
22797 assert!(matches!(
22798 receiver.try_recv(),
22799 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
22800 ));
22801 let latest = receiver.try_recv().unwrap();
22802 assert_eq!(latest.len(), 1);
22803 assert_eq!(latest[0].sequence, count);
22804 }
22805
22806 #[test]
22807 fn real_durable_eviction_advances_authoritative_replay_floor() {
22808 let shared = terminal_test_shared();
22809 for _ in 0..=NODE_EVENT_HISTORY_MAX {
22810 shared.publish(NodeEvent::ControllerChanged { controller: None });
22811 }
22812
22813 let NodeResponse::Resync {
22814 event_sequence,
22815 oldest_available_sequence,
22816 events,
22817 ..
22818 } = shared.resync(0) else {
22819 unreachable!("resync helper returned another response")
22820 };
22821 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
22822 assert_eq!(oldest_available_sequence, 2);
22823 assert_eq!(events.first().map(|event| event.sequence), Some(2));
22824 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
22825 assert!(matches!(
22826 resync_required_event(&shared).event,
22827 NodeEvent::ResyncRequired {
22828 oldest_available_sequence: 2,
22829 }
22830 ));
22831 }
22832
22833 #[test]
22834 fn legacy_terminal_frame_connection_has_no_live_subscription() {
22835 let shared = terminal_test_shared();
22836 let without_capability = terminal_event_subscription(&shared, false);
22837 let with_capability = terminal_event_subscription(&shared, true);
22838 assert!(without_capability.is_none());
22839 assert!(with_capability.is_some());
22840 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22841 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
22842 }));
22843 }
22844
22845 #[test]
22846 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
22847 let mut pending = Vec::new();
22848 let mut event_sequence = 0_u64;
22849 for frame_sequence in 1..=4 {
22850 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
22851 event_sequence += 1;
22852 let mut address = terminal_address(1);
22853 address.session.instance_id = AgentInstanceId(instance);
22854 queue_connection_event(
22855 &mut pending,
22856 NodeEventEnvelope {
22857 sequence: event_sequence,
22858 event: NodeEvent::TerminalFrame {
22859 address,
22860 frame: terminal_frame(frame_sequence),
22861 },
22862 },
22863 0,
22864 );
22865 }
22866 }
22867 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
22868 pending.sort_unstable_by_key(|event| event.sequence);
22869
22870 let mut uninterrupted = pending.clone();
22871 let first_burst = connection_event_burst_len(uninterrupted.len());
22872 uninterrupted.drain(..first_burst);
22873 let second_burst = connection_event_burst_len(uninterrupted.len());
22874 uninterrupted.drain(..second_burst);
22875 assert!(uninterrupted.is_empty());
22876
22877 let (frame_tx, mut frame_rx) =
22878 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
22879 frame_tx
22880 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
22881 request_id: 77,
22882 request: NodeRequest::Snapshot,
22883 })))
22884 .unwrap();
22885 let burst_len = connection_event_burst_len(pending.len());
22886 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
22887 pending.drain(..burst_len);
22888 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
22889 assert!(matches!(
22890 frame_rx.try_recv(),
22891 Ok(Ok(ClientFrame::Request(RequestEnvelope {
22892 request_id: 77,
22893 request: NodeRequest::Snapshot,
22894 })))
22895 ));
22896 }
22897
22898 #[test]
22899 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
22900 let mut pending = Vec::new();
22901 for sequence in [7_u64, 8] {
22902 let mut address = terminal_address(1);
22903 address.session.instance_id = AgentInstanceId(sequence);
22904 queue_connection_event(
22905 &mut pending,
22906 NodeEventEnvelope {
22907 sequence,
22908 event: NodeEvent::TerminalFrame {
22909 address,
22910 frame: terminal_frame(sequence),
22911 },
22912 },
22913 0,
22914 );
22915 }
22916 let mut discard_through = 0;
22917 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
22918 assert!(pending.is_empty());
22919
22920 let replayed = NodeEventEnvelope {
22921 sequence: 8,
22922 event: NodeEvent::TerminalFrame {
22923 address: terminal_address(1),
22924 frame: terminal_frame(8),
22925 },
22926 };
22927 let advanced = NodeEventEnvelope {
22928 sequence: 9,
22929 event: NodeEvent::TerminalFrame {
22930 address: terminal_address(2),
22931 frame: terminal_frame(1),
22932 },
22933 };
22934 queue_connection_event_batch(
22935 &mut pending,
22936 &[replayed, advanced.clone()],
22937 discard_through,
22938 );
22939 assert_eq!(pending, vec![advanced]);
22940 }
22941
22942 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
22943 ManagedSessionRecord {
22944 record_id: SessionRecordId::new(record_id).unwrap(),
22945 display_name: format!("{provider} record"),
22946 provider: agent(provider),
22947 mode: SessionMode::Pty,
22948 state: ManagedSessionState::Unavailable,
22949 workspace_id: WorkspaceId::new("primary").unwrap(),
22950 canonical_root: opaque_windows_path(
22951 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22952 ),
22953 provider_session: None,
22954 active_session: None,
22955 environment_profile: None,
22956 bundle: None,
22957 context_id: None,
22958 context: None,
22959 exported_context: None,
22960 task_binding: None,
22961 created_at_unix_ms: 1,
22962 updated_at_unix_ms: 1,
22963 last_error: None,
22964 }
22965 }
22966
22967 fn retention_test_record(
22968 workspace_id: &str,
22969 record_id: &str,
22970 state: ManagedSessionState,
22971 updated_at_unix_ms: u64,
22972 ) -> ManagedSessionRecord {
22973 ManagedSessionRecord {
22974 record_id: SessionRecordId::new(record_id).unwrap(),
22975 display_name: format!("{record_id} display"),
22976 provider: agent("claude"),
22977 mode: SessionMode::Pty,
22978 state,
22979 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
22980 canonical_root: opaque_windows_path(
22981 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22982 ),
22983 provider_session: None,
22984 active_session: None,
22985 environment_profile: None,
22986 bundle: None,
22987 context_id: None,
22988 context: None,
22989 exported_context: None,
22990 task_binding: None,
22991 created_at_unix_ms: updated_at_unix_ms,
22992 updated_at_unix_ms,
22993 last_error: None,
22994 }
22995 }
22996
22997 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23005 vec![
23006 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23007 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23008 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23009 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23010 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23011 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23012 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23013 retention_test_record(
23014 "workspace-a",
23015 "decoy-pending",
23016 ManagedSessionState::IdentityPending,
23017 now - 500_000,
23018 ),
23019 retention_test_record(
23020 "workspace-a",
23021 "decoy-dormant",
23022 ManagedSessionState::Dormant,
23023 now - 500_000,
23024 ),
23025 ]
23026 }
23027
23028 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23029 let root = temporary_workspace_root("session-record-retention");
23030 std::fs::create_dir_all(&root).unwrap();
23031 let catalog = active_registry().unwrap();
23032 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23033 let workspace_a =
23034 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23035 let workspace_b =
23036 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23037 let mut shared = NodeShared::new(
23038 handle,
23039 "fixture-token".to_owned(),
23040 NodeId::new("node-retention-test").unwrap(),
23041 vec![workspace_a, workspace_b],
23042 vec![agent("claude")],
23043 );
23044 shared.state_path = Some(root.join("node-state-v10.json"));
23045 let now = unix_time_ms();
23046 for record in retention_fixture_records(now) {
23047 shared.insert_record(record).unwrap();
23048 }
23049 shared
23050 .persist_state_locked()
23051 .expect("fixture-only, empty-history persist against a fresh temp state path");
23052 (shared, runtime, root, now)
23053 }
23054
23055 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23056 shared
23057 .history
23058 .lock()
23059 .unwrap_or_else(|poisoned| poisoned.into_inner())
23060 .events
23061 .iter()
23062 .filter_map(|envelope| match &envelope.event {
23063 NodeEvent::SessionRecordRemoved { record_id } => {
23064 Some(record_id.as_str().to_owned())
23065 }
23066 _ => None,
23067 })
23068 .collect()
23069 }
23070
23071 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23072 shared
23073 .session_records
23074 .lock()
23075 .unwrap_or_else(|poisoned| poisoned.into_inner())
23076 .records
23077 .keys()
23078 .map(|record_id| record_id.as_str().to_owned())
23079 .collect()
23080 }
23081
23082 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23083 values.iter().map(|value| (*value).to_owned()).collect()
23084 }
23085
23086 #[tokio::test]
23087 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23088 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23089 shared.session_record_retention = SessionRecordRetentionConfig {
23090 age_ms: 0,
23091 keep_per_workspace: 1,
23092 };
23093 shared.retire_unavailable_session_records().await;
23094
23095 assert_eq!(
23096 retention_remaining_record_ids(&shared),
23097 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23098 );
23099 assert_eq!(
23100 retention_removed_record_ids(&shared),
23101 owned(&["a1", "a2", "b1", "b2"]),
23102 );
23103 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23104
23105 drop(shared);
23106 drop(runtime);
23107 std::fs::remove_dir_all(root).unwrap();
23108 }
23109
23110 #[tokio::test]
23116 async fn session_record_retention_both_set_retires_only_the_intersection() {
23117 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23118 shared.session_record_retention = SessionRecordRetentionConfig {
23119 age_ms: 250_000,
23120 keep_per_workspace: 1,
23121 };
23122 shared.retire_unavailable_session_records().await;
23123
23124 assert_eq!(
23125 retention_remaining_record_ids(&shared),
23126 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23127 );
23128 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23129 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23130
23131 drop(shared);
23132 drop(runtime);
23133 std::fs::remove_dir_all(root).unwrap();
23134 }
23135
23136 #[tokio::test]
23137 async fn session_record_retention_both_disabled_is_a_no_op() {
23138 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23139 shared.session_record_retention = SessionRecordRetentionConfig::default();
23140 shared.retire_unavailable_session_records().await;
23141
23142 assert_eq!(
23143 retention_remaining_record_ids(&shared),
23144 owned(&[
23145 "a1", "a2", "a3", "b1", "b2", "b3",
23146 "decoy-live", "decoy-pending", "decoy-dormant",
23147 ]),
23148 );
23149 assert!(retention_removed_record_ids(&shared).is_empty());
23150 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23151
23152 drop(shared);
23153 drop(runtime);
23154 std::fs::remove_dir_all(root).unwrap();
23155 }
23156
23157 #[test]
23158 fn session_record_context_export_selects_one_exact_history_identity() {
23159 let candidates = vec![
23160 HistoryCandidateSummary {
23161 id: "candidate-other".to_owned(),
23162 session_id_hint: "provider-other".to_owned(),
23163 modified_at_unix_ms: Some(1),
23164 },
23165 HistoryCandidateSummary {
23166 id: "candidate-exact".to_owned(),
23167 session_id_hint: "provider-exact".to_owned(),
23168 modified_at_unix_ms: Some(2),
23169 },
23170 ];
23171 assert_eq!(
23172 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23173 "candidate-exact",
23174 );
23175 }
23176
23177 #[test]
23178 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23179 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23180 id: id.to_owned(),
23181 session_id_hint: hint.to_owned(),
23182 modified_at_unix_ms: None,
23183 };
23184 let missing = select_history_candidate(
23185 &[candidate("candidate-other", "provider-other")],
23186 Some("provider-exact"),
23187 )
23188 .unwrap_err();
23189 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23190 let ambiguous = select_history_candidate(
23191 &[
23192 candidate("candidate-a", "provider-exact"),
23193 candidate("candidate-b", "provider-exact"),
23194 ],
23195 Some("provider-exact"),
23196 )
23197 .unwrap_err();
23198 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23199 }
23200
23201 #[test]
23209 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23210 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23211 id: id.to_owned(),
23212 session_id_hint: hint.to_owned(),
23213 modified_at_unix_ms: None,
23214 };
23215 assert_eq!(
23216 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23217 .unwrap(),
23218 "candidate-sole",
23219 );
23220 let missing = select_history_candidate(&[], None).unwrap_err();
23221 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23222 let ambiguous = select_history_candidate(
23223 &[
23224 candidate("candidate-a", "sidecar-session-a"),
23225 candidate("candidate-b", "sidecar-session-b"),
23226 ],
23227 None,
23228 )
23229 .unwrap_err();
23230 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23231 }
23232
23233 #[test]
23234 fn session_record_context_export_revalidation_rejects_record_change() {
23235 let session = terminal_address(1);
23236 let identity = ProviderSessionIdentity {
23237 key: ProviderSessionKey::SessionId,
23238 id: "provider-exact".to_owned(),
23239 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23240 };
23241 let mut expected = record("claude", "record-context-export");
23242 expected.state = ManagedSessionState::Live;
23243 expected.provider_session = Some(identity.clone());
23244 expected.active_session = Some(session.clone());
23245 let binding = SessionBinding {
23246 workspace_id: session.workspace_id.clone(),
23247 generation: session.session.generation,
23248 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23249 pending_resume: None,
23250 record_id: Some(expected.record_id.clone()),
23251 managed_worktree_lease_id: None,
23252 environment_profile: None,
23253 bundle: None,
23254 context: None,
23255 materialization_id: None,
23256 };
23257 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23258 assert!(session_record_context_export_binding_is_exact(
23259 &expected,
23260 &expected,
23261 Some(&identity),
23262 &session,
23263 &binding,
23264 &expected.provider,
23265 ¤t_root,
23266 false,
23267 ));
23268 let mut changed = expected.clone();
23269 changed.updated_at_unix_ms += 1;
23270 assert!(!session_record_context_export_binding_is_exact(
23271 &expected,
23272 &changed,
23273 Some(&identity),
23274 &session,
23275 &binding,
23276 &expected.provider,
23277 ¤t_root,
23278 false,
23279 ));
23280 assert!(!session_record_context_export_binding_is_exact(
23285 &expected,
23286 &changed,
23287 Some(&identity),
23288 &session,
23289 &binding,
23290 &expected.provider,
23291 ¤t_root,
23292 true,
23293 ));
23294 let mut detached = expected.clone();
23295 detached.state = ManagedSessionState::Dormant;
23296 detached.active_session = None;
23297 detached.updated_at_unix_ms += 1;
23298 assert!(!session_record_context_export_binding_is_exact(
23299 &expected,
23300 &detached,
23301 Some(&identity),
23302 &session,
23303 &binding,
23304 &expected.provider,
23305 ¤t_root,
23306 false,
23307 ));
23308 assert!(session_record_context_export_binding_is_exact(
23309 &expected,
23310 &detached,
23311 Some(&identity),
23312 &session,
23313 &binding,
23314 &expected.provider,
23315 ¤t_root,
23316 true,
23317 ));
23318 }
23319
23320 #[test]
23327 fn session_record_context_export_binding_is_exact_without_identity() {
23328 let session = terminal_address(1);
23329 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23330 expected.state = ManagedSessionState::Live;
23331 expected.active_session = Some(session.clone());
23332 let binding = SessionBinding {
23333 workspace_id: session.workspace_id.clone(),
23334 generation: session.session.generation,
23335 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23336 pending_resume: None,
23337 record_id: Some(expected.record_id.clone()),
23338 managed_worktree_lease_id: None,
23339 environment_profile: None,
23340 bundle: None,
23341 context: None,
23342 materialization_id: None,
23343 };
23344 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23345 assert!(session_record_context_export_binding_is_exact(
23346 &expected,
23347 &expected,
23348 None,
23349 &session,
23350 &binding,
23351 &expected.provider,
23352 ¤t_root,
23353 false,
23354 ));
23355 let mut drifted = expected.clone();
23359 drifted.provider_session = Some(ProviderSessionIdentity {
23360 key: ProviderSessionKey::SessionId,
23361 id: "unexpected-identity".to_owned(),
23362 transcript_path: None,
23363 });
23364 assert!(!session_record_context_export_binding_is_exact(
23365 &expected,
23366 &drifted,
23367 None,
23368 &session,
23369 &binding,
23370 &expected.provider,
23371 ¤t_root,
23372 false,
23373 ));
23374 }
23375
23376 #[test]
23377 fn session_record_context_export_preflight_requires_live_usable_source() {
23378 let mut source = record("claude", "record-context-export-preflight");
23379 source.state = ManagedSessionState::Live;
23380 assert!(session_record_context_export_source_is_usable(
23381 &source,
23382 &SessionStatus::Running,
23383 false,
23384 ));
23385 assert!(session_record_context_export_source_is_usable(
23386 &source,
23387 &SessionStatus::Exited { exit_code: Some(0) },
23388 false,
23389 ));
23390
23391 source.state = ManagedSessionState::Dormant;
23392 assert!(!session_record_context_export_source_is_usable(
23393 &source,
23394 &SessionStatus::Running,
23395 false,
23396 ));
23397 assert!(session_record_context_export_source_is_usable(
23401 &source,
23402 &SessionStatus::Exited { exit_code: Some(0) },
23403 true,
23404 ));
23405 assert!(!session_record_context_export_source_is_usable(
23406 &source,
23407 &SessionStatus::Exited { exit_code: Some(0) },
23408 false,
23409 ));
23410 source.state = ManagedSessionState::Live;
23411 assert!(!session_record_context_export_source_is_usable(
23412 &source,
23413 &SessionStatus::Starting,
23414 false,
23415 ));
23416 }
23417
23418 #[test]
23419 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23420 let catalog = active_registry().unwrap();
23421 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23422 let workspace = WorkspaceConfig::new(
23423 WorkspaceId::new("primary").unwrap(),
23424 std::env::current_dir().unwrap(),
23425 )
23426 .unwrap();
23427 let shared = NodeShared::new(
23428 handle,
23429 "fixture-token".to_owned(),
23430 NodeId::new("node-context-export-conflict").unwrap(),
23431 vec![workspace],
23432 vec![agent("claude")],
23433 );
23434 let history = HistorySessionRecord {
23435 session_id: "provider-exact".to_owned(),
23436 title: None,
23437 cwd: None,
23438 model: None,
23439 message_count: 1,
23440 completed_turn_count: None,
23441 total_tokens: 0,
23442 messages: vec![gate4agent_types::HistoryMessageRecord {
23443 role: gate4agent_types::HistoryMessageRole::User,
23444 text: "bounded context".to_owned(),
23445 }],
23446 };
23447 let pack = NodeContextPack::export(
23448 ContextPackLineageReceipt {
23449 source_node_id: shared.node_id.clone(),
23450 source_session: terminal_address(1),
23451 source_provider: agent("claude"),
23452 },
23453 &history,
23454 )
23455 .unwrap();
23456 let context_id = pack.receipt().id.clone();
23457 let session = terminal_address(1);
23458 let identity = ProviderSessionIdentity {
23459 key: ProviderSessionKey::SessionId,
23460 id: history.session_id.clone(),
23461 transcript_path: None,
23462 };
23463 let mut expected = record("claude", "record-context-export-final-conflict");
23464 expected.state = ManagedSessionState::Live;
23465 expected.provider_session = Some(identity.clone());
23466 expected.active_session = Some(session.clone());
23467 let mut changed = expected.clone();
23468 changed.updated_at_unix_ms += 1;
23469 shared.insert_record(changed).unwrap();
23470
23471 let error = shared
23472 .commit_context_pack_for_session_record(
23473 &expected,
23474 Some(&identity),
23475 &session,
23476 "candidate-exact",
23477 &history,
23478 pack,
23479 false,
23480 )
23481 .unwrap_err();
23482
23483 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23484 assert!(shared
23485 .context_catalog
23486 .read()
23487 .unwrap_or_else(|poisoned| poisoned.into_inner())
23488 .get(&context_id)
23489 .is_none());
23490 }
23491
23492 #[tokio::test]
23493 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23494 let root = temporary_workspace_root("session-task-binding");
23495 std::fs::create_dir_all(&root).unwrap();
23496 let state_path = root.join("node-state.json");
23497 let catalog = active_registry().unwrap();
23498 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23499 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23500 let node_id = NodeId::new("node-1").unwrap();
23501 let mut shared = NodeShared::new(
23502 handle,
23503 "fixture-token".to_owned(),
23504 node_id.clone(),
23505 vec![workspace],
23506 vec![agent("claude")],
23507 );
23508 shared.state_path = Some(state_path.clone());
23509 let record = record("claude", "sr-task");
23510 let record_id = record.record_id.clone();
23511 shared.insert_record(record).unwrap();
23512 shared.persist_state().unwrap();
23513 shared
23514 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23515 .unwrap();
23516
23517 let minted = process_request_inner(
23518 &shared,
23519 77,
23520 ClientRole::Operator,
23521 NodeRequest::SetSessionTask {
23522 record_id: record_id.clone(),
23523 expected_revision: 0,
23524 target: SessionTaskTargetV1::New,
23525 },
23526 )
23527 .await
23528 .unwrap();
23529 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23530 panic!("session task mutation returned another response");
23531 };
23532 let minted_binding = minted.task_binding.clone().unwrap();
23533 let minted_task_id = minted_binding.task_id.clone().unwrap();
23534 assert_eq!(minted_binding.revision, 1);
23535 assert!(minted_task_id.as_str().starts_with("task-"));
23536 assert_eq!(minted_task_id.as_str().len(), 29);
23537
23538 let event_count = shared.current_sequence();
23539 let repeated = process_request_inner(
23540 &shared,
23541 77,
23542 ClientRole::Operator,
23543 NodeRequest::SetSessionTask {
23544 record_id: record_id.clone(),
23545 expected_revision: 1,
23546 target: SessionTaskTargetV1::Existing {
23547 task_id: minted_task_id.clone(),
23548 },
23549 },
23550 )
23551 .await
23552 .unwrap();
23553 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23554 assert_eq!(shared.current_sequence(), event_count);
23555
23556 let stale = process_request_inner(
23557 &shared,
23558 77,
23559 ClientRole::Operator,
23560 NodeRequest::SetSessionTask {
23561 record_id: record_id.clone(),
23562 expected_revision: 0,
23563 target: SessionTaskTargetV1::Existing {
23564 task_id: minted_task_id,
23565 },
23566 },
23567 )
23568 .await
23569 .unwrap_err();
23570 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23571
23572 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23573 let assigned = process_request_inner(
23574 &shared,
23575 77,
23576 ClientRole::Operator,
23577 NodeRequest::SetSessionTask {
23578 record_id: record_id.clone(),
23579 expected_revision: 1,
23580 target: SessionTaskTargetV1::Existing {
23581 task_id: assigned_id.clone(),
23582 },
23583 },
23584 )
23585 .await
23586 .unwrap();
23587 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23588 panic!("session task assignment returned another response");
23589 };
23590 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23591 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23592
23593 let cleared = process_request_inner(
23594 &shared,
23595 77,
23596 ClientRole::Operator,
23597 NodeRequest::SetSessionTask {
23598 record_id: record_id.clone(),
23599 expected_revision: 2,
23600 target: SessionTaskTargetV1::Clear,
23601 },
23602 )
23603 .await
23604 .unwrap();
23605 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23606 panic!("session task clear returned another response");
23607 };
23608 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23609 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23610 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23611 assert_eq!(
23612 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23613 cleared.updated_at_unix_ms,
23614 );
23615
23616 let persisted = std::fs::read(&state_path).unwrap();
23617 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23618 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23619 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23620 std::fs::remove_dir_all(root).unwrap();
23621 }
23622
23623 #[test]
23624 fn session_task_capability_strips_snapshots_events_and_replies() {
23625 let mut bound = record("claude", "sr-task-projection");
23626 bound.updated_at_unix_ms = 2;
23627 bound.task_binding = Some(SessionTaskBindingV1 {
23628 revision: 1,
23629 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23630 changed_at_unix_ms: 2,
23631 });
23632 let request = NodeRequest::SetSessionTask {
23633 record_id: bound.record_id.clone(),
23634 expected_revision: 1,
23635 target: SessionTaskTargetV1::Clear,
23636 };
23637 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23638 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23639 }));
23640 assert!(!request_uses_unnegotiated_capability(
23641 &request,
23642 &baseline_capabilities().unwrap(),
23643 ));
23644 assert!(request_uses_unnegotiated_capability(&request, &[]));
23645
23646 let mut snapshot = terminal_test_shared().snapshot();
23647 snapshot.session_records.push(bound.clone());
23648 let mut reply = ResponseEnvelope {
23649 request_id: 1,
23650 result: Ok(NodeResponse::Resync {
23651 event_sequence: 1,
23652 oldest_available_sequence: 1,
23653 snapshot,
23654 events: vec![NodeEventEnvelope {
23655 sequence: 1,
23656 event: NodeEvent::SessionRecordUpserted {
23657 record: bound.clone(),
23658 },
23659 }],
23660 }),
23661 };
23662 project_response_without_session_task_binding(&mut reply);
23663 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23664 panic!("task projection changed the response kind");
23665 };
23666 assert!(snapshot.session_records[0].task_binding.is_none());
23667 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
23668 panic!("task projection changed the event kind");
23669 };
23670 assert!(record.task_binding.is_none());
23671
23672 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
23673 sequence: 2,
23674 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
23675 });
23676 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
23677 panic!("task event projection changed the event kind");
23678 };
23679 assert!(record.task_binding.is_none());
23680
23681 let mut direct = ResponseEnvelope {
23682 request_id: 2,
23683 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
23684 };
23685 project_response_without_session_task_binding(&mut direct);
23686 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
23687 panic!("task reply projection changed the response kind");
23688 };
23689 assert!(record.task_binding.is_none());
23690 }
23691
23692 #[test]
23693 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
23694 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
23695 let workspace = WorkspaceConfig::new(
23696 WorkspaceId::new("primary").unwrap(),
23697 std::env::current_dir().unwrap(),
23698 )
23699 .unwrap();
23700 let config = NodeServerConfig::new(
23701 DEFAULT_NODE_ENDPOINT,
23702 "fixture-token",
23703 NodeId::new("node-1").unwrap(),
23704 [workspace],
23705 )
23706 .unwrap();
23707 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
23708 }
23709
23710 #[cfg(all(feature = "fixture", windows))]
23711 #[test]
23712 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
23713 let root = temporary_workspace_root("provider-bundle-argv-hold");
23714 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
23715 std::fs::create_dir_all(&root).unwrap();
23716 std::fs::create_dir_all(&outside).unwrap();
23717 let proof_path = root.join("provider.proof");
23718 let release_signal = root.join("release.signal");
23719 let config = NodeServerConfig::new(
23720 format!(
23721 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
23722 std::process::id(),
23723 ),
23724 "fixture-token",
23725 NodeId::new("provider-bundle-hold-node").unwrap(),
23726 [WorkspaceConfig::new(
23727 WorkspaceId::new("primary").unwrap(),
23728 std::env::current_dir().unwrap(),
23729 )
23730 .unwrap()],
23731 )
23732 .unwrap()
23733 .with_spawn_profiles(SpawnProfileRegistry::new([
23734 crate::protocol::SpawnProfileDefaults {
23735 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
23736 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
23737 .unwrap(),
23738 provider: agent("kimi"),
23739 mode: SessionMode::Pty,
23740 terminal_size: gate4agent_types::TerminalSize {
23741 rows: 24,
23742 columns: 80,
23743 },
23744 prompt: None,
23745 bundle_id: None,
23746 context_id: None,
23747 environment_profile_id: None,
23748 },
23749 ])
23750 .unwrap());
23751 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
23752 config,
23753 agent("kimi"),
23754 proof_path.clone(),
23755 release_signal.clone(),
23756 )
23757 .unwrap();
23758 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
23759
23760 let spec = NodeServer::provider_bundle_argv_fixture_spec(
23761 agent("kimi"),
23762 proof_path.clone(),
23763 Some(release_signal.clone()),
23764 )
23765 .unwrap();
23766 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
23767 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
23768 let release_wait = wrapper
23769 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
23770 .unwrap();
23771 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
23772 assert!(proof_write < release_wait);
23773 assert!(release_wait < fixture_ready);
23774 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
23775 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
23776 assert_eq!(
23777 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
23778 &[
23779 proof_path.to_string_lossy().into_owned(),
23780 release_signal.to_string_lossy().into_owned(),
23781 ],
23782 );
23783
23784 let error = NodeServer::provider_bundle_argv_fixture_spec(
23785 agent("kimi"),
23786 proof_path,
23787 Some(outside.join("release.signal")),
23788 )
23789 .unwrap_err();
23790 assert!(matches!(
23791 error,
23792 NodeServerError::Registry(message)
23793 if message == "provider bundle argv release path must share the proof parent"
23794 ));
23795 drop(server);
23796 std::fs::remove_dir_all(root).unwrap();
23797 std::fs::remove_dir_all(outside).unwrap();
23798 }
23799
23800 #[cfg(feature = "fixture")]
23801 #[test]
23802 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
23803 let root = temporary_workspace_root("context-fixture-registry");
23804 std::fs::create_dir_all(&root).unwrap();
23805 let proof_path = root.join("context.proof");
23806 let config = NodeServerConfig::new(
23807 format!(
23808 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
23809 std::process::id(),
23810 ),
23811 "fixture-token",
23812 NodeId::new("context-fixture-node").unwrap(),
23813 [WorkspaceConfig::new(
23814 WorkspaceId::new("primary").unwrap(),
23815 std::env::current_dir().unwrap(),
23816 )
23817 .unwrap()],
23818 )
23819 .unwrap();
23820 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
23821 let mut enabled = server
23822 .shared
23823 .enabled_providers
23824 .iter()
23825 .map(AgentId::as_str)
23826 .collect::<Vec<_>>();
23827 enabled.sort_unstable();
23828 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
23829 for provider in &server.shared.enabled_providers {
23830 assert!(server
23831 .shared
23832 .provider_adapter_contracts
23833 .iter()
23834 .any(|contract| {
23835 &contract.provider == provider
23836 && contract.family == AdapterFamily::History
23837 }));
23838 }
23839 drop(server);
23840 std::fs::remove_dir_all(root).unwrap();
23841 }
23842
23843 #[cfg(feature = "fixture")]
23844 #[test]
23845 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
23846 let root = temporary_workspace_root("monitoring-context-fixture");
23847 std::fs::create_dir_all(&root).unwrap();
23848 let proof_path = root.join("context.proof");
23849 let standard = NodeServer::context_pack_fixture_catalog(
23850 proof_path.clone(),
23851 None,
23852 false,
23853 ).unwrap();
23854 let monitoring = NodeServer::context_pack_fixture_catalog(
23855 proof_path.clone(),
23856 None,
23857 true,
23858 ).unwrap();
23859 let claude = AgentId::new("claude").unwrap();
23860 let standard_claude = standard.get(&claude).unwrap();
23861 let monitoring_claude = monitoring.get(&claude).unwrap();
23862 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
23866 assert_eq!(
23867 monitoring_claude.capabilities.adapters.history,
23868 standard_claude.capabilities.adapters.history,
23869 );
23870 assert_ne!(monitoring_claude.launch, standard_claude.launch);
23871 let codex = AgentId::new("codex").unwrap();
23872 let standard_codex = standard.get(&codex).unwrap();
23873 let monitoring_codex = monitoring.get(&codex).unwrap();
23874 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
23875 assert_eq!(
23876 monitoring_codex.capabilities.adapters.history,
23877 standard_codex.capabilities.adapters.history,
23878 );
23879 assert_ne!(monitoring_codex.launch, standard_codex.launch);
23880 let codex_validation = monitoring_codex.launch.fixed_args.iter()
23881 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
23882 .unwrap();
23883 for required in [
23884 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
23885 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
23886 "@('role', 'text')",
23887 "g4a-private-provider-session-canary",
23888 "private-provider-login-identity",
23889 "$rawProof",
23890 "$env:GATE4AGENT_HOOK_URL",
23891 "g4a-private-provider-session-canary",
23892 ] {
23893 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
23894 }
23895 for provider in ["grok", "kimi"] {
23896 let provider = AgentId::new(provider).unwrap();
23897 assert_eq!(monitoring.get(&provider), standard.get(&provider));
23898 }
23899
23900 let config = NodeServerConfig::new(
23901 format!(
23902 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
23903 std::process::id(),
23904 ),
23905 "fixture-token",
23906 NodeId::new("monitoring-context-fixture-node").unwrap(),
23907 [WorkspaceConfig::new(
23908 WorkspaceId::new("primary").unwrap(),
23909 std::env::current_dir().unwrap(),
23910 )
23911 .unwrap()],
23912 )
23913 .unwrap();
23914 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
23915 assert!(server.shared.fixture_semantic_hook_policy);
23916 drop(server);
23917 std::fs::remove_dir_all(root).unwrap();
23918 }
23919
23920 #[cfg(feature = "fixture")]
23921 #[test]
23922 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
23923 let root = temporary_workspace_root("kimi-context-only-fixture");
23924 std::fs::create_dir_all(&root).unwrap();
23925 let proof_path = root.join("context.proof");
23926 let standard = NodeServer::context_pack_fixture_catalog(
23927 proof_path.clone(),
23928 None,
23929 false,
23930 ).unwrap();
23931 let kimi = AgentId::new("kimi").unwrap();
23932 let context_only = NodeServer::context_pack_fixture_catalog(
23933 proof_path,
23934 Some(&kimi),
23935 false,
23936 )
23937 .unwrap();
23938
23939 let codex = AgentId::new("codex").unwrap();
23940 assert_eq!(
23941 standard.get(&codex).unwrap().launch.fixed_args,
23942 context_only.get(&codex).unwrap().launch.fixed_args,
23943 );
23944 let script = context_only
23945 .get(&kimi)
23946 .unwrap()
23947 .launch
23948 .fixed_args
23949 .iter()
23950 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
23951 .expect("Kimi context-only validation script is missing");
23952 for required in [
23953 "$bundleArgs.Count -ne 0",
23954 "GATE4AGENT_CONTEXT_ROOT",
23955 "context-pack.json",
23956 "$entries.Count -ne 1",
23957 "g4a-context-pack-v1",
23958 "$document.PSObject.Properties['cwd']",
23959 "'claude', 'codex', 'grok', 'kimi'",
23960 "$hasUser",
23961 "$hasAssistant",
23962 "SHA256",
23963 "context-only",
23964 ] {
23965 assert!(script.contains(required), "missing validation: {required}");
23966 }
23967 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
23968 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
23969 }
23970 assert!(!standard
23971 .get(&kimi)
23972 .unwrap()
23973 .launch
23974 .fixed_args
23975 .iter()
23976 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
23977
23978 std::fs::remove_dir_all(root).unwrap();
23979 }
23980
23981 #[cfg(all(feature = "fixture", windows))]
23982 #[tokio::test]
23983 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
23984 let root = temporary_workspace_root("kimi-context-only-spawn");
23985 let workspace_root = root.join("workspace");
23986 std::fs::create_dir_all(&workspace_root).unwrap();
23987 let materialization_root = root.join("private-materializations");
23988 let proof_path = root.join("context.proof");
23989 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
23990 let workspace_id = WorkspaceId::new("primary").unwrap();
23991 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
23992 let endpoint = format!(
23993 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
23994 std::process::id(),
23995 unix_time_ms(),
23996 );
23997 let spawn_profiles = SpawnProfileRegistry::new([
23998 crate::protocol::SpawnProfileDefaults {
23999 profile_id: profile_id.clone(),
24000 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24001 provider: agent("kimi"),
24002 mode: SessionMode::Pty,
24003 terminal_size: gate4agent_types::TerminalSize {
24004 rows: 30,
24005 columns: 120,
24006 },
24007 prompt: None,
24008 bundle_id: None,
24009 context_id: None,
24010 environment_profile_id: None,
24011 },
24012 ])
24013 .unwrap();
24014 let mut config = NodeServerConfig::new(
24015 &endpoint,
24016 "fixture-token",
24017 node_id.clone(),
24018 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24019 )
24020 .unwrap()
24021 .with_spawn_profiles(spawn_profiles)
24022 .with_session_environment_materialization(
24023 materialization_root,
24024 Arc::new(FixtureSecretResolver {
24025 deny: Arc::new(AtomicBool::new(false)),
24026 }),
24027 )
24028 .unwrap();
24029 config.fixture_raw_pty_runtime = true;
24030 let server = NodeServer::new_context_only_proof_fixture(
24031 config,
24032 agent("kimi"),
24033 proof_path.clone(),
24034 )
24035 .unwrap();
24036 let shared = Arc::clone(&server.shared);
24037 let shutdown = server.shutdown_handle();
24038
24039 let history = HistorySessionRecord {
24040 session_id: "context-only-source-history".to_owned(),
24041 title: None,
24042 cwd: None,
24043 model: None,
24044 message_count: 2,
24045 completed_turn_count: None,
24046 total_tokens: 0,
24047 messages: vec![
24048 gate4agent_types::HistoryMessageRecord {
24049 role: gate4agent_types::HistoryMessageRole::User,
24050 text: "continue the bounded parent run through native C2".to_owned(),
24051 },
24052 gate4agent_types::HistoryMessageRecord {
24053 role: gate4agent_types::HistoryMessageRole::Assistant,
24054 text: "the exact parent context is ready for the child run".to_owned(),
24055 },
24056 ],
24057 };
24058 let pack = NodeContextPack::export(
24059 ContextPackLineageReceipt {
24060 source_node_id: node_id.clone(),
24061 source_session: SessionAddress {
24062 workspace_id: workspace_id.clone(),
24063 session: SessionKey {
24064 instance_id: AgentInstanceId(71),
24065 generation: SessionGeneration(1),
24066 },
24067 },
24068 source_provider: agent("codex"),
24069 },
24070 &history,
24071 )
24072 .unwrap();
24073 let expected_context_hash = digest(&SHA256, pack.bytes())
24074 .as_ref()
24075 .iter()
24076 .map(|byte| format!("{byte:02x}"))
24077 .collect::<String>();
24078 let context = shared
24079 .context_catalog
24080 .write()
24081 .unwrap_or_else(|poisoned| poisoned.into_inner())
24082 .insert(pack)
24083 .unwrap();
24084 let spec = SpawnSpec {
24085 target: crate::protocol::SpawnTarget {
24086 node_id,
24087 workspace_id: workspace_id.clone(),
24088 worktree_id: None,
24089 },
24090 profile_id,
24091 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24092 "target-kimi-r1",
24093 ).unwrap(),
24094 overrides: crate::protocol::SpawnOverrides {
24095 context_id: crate::protocol::SpawnOverride::Set {
24096 value: context.id.clone(),
24097 },
24098 ..crate::protocol::SpawnOverrides::default()
24099 },
24100 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24101 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24102 required_capabilities: SpawnRequiredCapabilities::default(),
24103 };
24104 let resolved = match shared.resolve_spawn_spec(&spec) {
24105 Ok(resolved) => resolved,
24106 Err(error) => panic!(
24107 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24108 error.code,
24109 error.message,
24110 ),
24111 };
24112 let resolved_context = match shared.resolve_context(&resolved) {
24113 Ok(Some(resolved_context)) => resolved_context,
24114 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24115 Err(error) => panic!(
24116 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24117 error.code,
24118 error.message,
24119 ),
24120 };
24121 assert_eq!(resolved_context, context);
24122 let runtime_policy = match shared
24123 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24124 .await
24125 {
24126 Ok(runtime_policy) => runtime_policy,
24127 Err(error) => panic!(
24128 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24129 error.code,
24130 error.message,
24131 ),
24132 };
24133 assert!(runtime_policy.raw_pty_lifecycle);
24134
24135 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24136 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24137 agent("kimi"),
24138 TransportKind::Pty,
24139 vec![EnvMutation {
24140 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24141 value: Some(OsString::from("must-remain-rejected")),
24142 }],
24143 )
24144 .unwrap();
24145 assert_eq!(
24146 launch_control
24147 .install_native_launch_environment_overlay(
24148 AgentInstanceId(72),
24149 generic_overlay,
24150 )
24151 .unwrap_err(),
24152 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24153 );
24154
24155 let diagnostic_address = SessionAddress {
24156 workspace_id: workspace_id.clone(),
24157 session: SessionKey {
24158 instance_id: AgentInstanceId(73),
24159 generation: SessionGeneration(1),
24160 },
24161 };
24162 let (diagnostic_overlay, diagnostic_materialization) = match shared
24163 .prepare_session_materialization(
24164 &diagnostic_address,
24165 &agent("kimi"),
24166 SessionMode::Pty,
24167 None,
24168 None,
24169 Some(&resolved_context),
24170 None,
24171 )
24172 {
24173 Ok(Some(prepared)) => prepared,
24174 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24175 Err(error) => panic!(
24176 "Kimi context-only materialization rejected: exact_code={:?} message={}",
24177 error.code,
24178 error.message,
24179 ),
24180 };
24181 let diagnostic_overlay = diagnostic_overlay
24182 .expect("Kimi context-only materialization omitted its environment overlay");
24183 assert!(matches!(
24184 &diagnostic_overlay,
24185 PreparedNativeLaunchOverlay::Environment(_),
24186 ));
24187 match shared.install_prepared_launch_overlay(
24188 diagnostic_address.session.instance_id,
24189 diagnostic_overlay,
24190 ) {
24191 Ok(None) => {}
24192 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24193 Err(error) => panic!(
24194 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24195 error.code,
24196 error.message,
24197 ),
24198 }
24199 assert!(launch_control.clear_native_instance_launch_overlay(
24200 diagnostic_address.session.instance_id,
24201 ));
24202 drop(diagnostic_materialization);
24203 assert!(shared
24204 .materializations
24205 .lock()
24206 .unwrap_or_else(|poisoned| poisoned.into_inner())
24207 .is_empty());
24208
24209 let node_task = tokio::spawn(server.run());
24210 let ready_client = timeout(Duration::from_secs(5), async {
24211 loop {
24212 match gate4agent_node_wire::LocalNodeClient::connect(
24213 &endpoint,
24214 &shared.node_id,
24215 ClientRole::Observer,
24216 "fixture-token",
24217 )
24218 .await
24219 {
24220 Ok(client) => break client,
24221 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24222 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24223 }
24224 }
24225 })
24226 .await
24227 .expect("Kimi context-only Node did not become ready");
24228 assert_eq!(
24229 ready_client
24230 .hello()
24231 .snapshot
24232 .provider_runtime_statuses
24233 .iter()
24234 .find(|status| status.provider().as_str() == "kimi")
24235 .expect("Kimi runtime status is missing")
24236 .mode(),
24237 crate::protocol::ProviderRuntimeMode::RawPassthrough,
24238 );
24239 drop(ready_client);
24240
24241 let receipt = match shared.spawn_from_spec(spec).await {
24242 Ok(receipt) => receipt,
24243 Err(error) => panic!(
24244 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24245 error.code,
24246 error.message,
24247 ),
24248 };
24249 assert_eq!(receipt.provider, agent("kimi"));
24250 assert_eq!(receipt.context.as_ref(), Some(&context));
24251 assert!(receipt.context_binding_is_valid());
24252 timeout(Duration::from_secs(5), async {
24253 while !proof_path.is_file() {
24254 sleep(Duration::from_millis(10)).await;
24255 }
24256 })
24257 .await
24258 .expect("Kimi context-only fixture did not write its proof");
24259
24260 let proof = std::fs::read_to_string(&proof_path).unwrap();
24261 let lines = proof.lines().collect::<Vec<_>>();
24262 assert_eq!(lines.len(), 7);
24263 assert_eq!(lines[0], "context-only");
24264 assert!(Path::new(lines[1]).is_absolute());
24265 assert_ne!(
24266 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24267 std::fs::canonicalize(&workspace_root).unwrap(),
24268 );
24269 assert_eq!(
24270 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24271 std::fs::canonicalize(&workspace_root).unwrap(),
24272 );
24273 assert_eq!(lines[3], expected_context_hash);
24274 assert_eq!(lines[4], "g4a-context-pack-v1");
24275 assert_eq!(lines[5], "codex");
24276 assert_eq!(lines[6], "2");
24277 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24278 session.instance_id == receipt.session.session.instance_id
24279 && session.agent_id.as_str() == "kimi"
24280 }));
24281
24282 shutdown.request_shutdown().await.unwrap();
24283 timeout(Duration::from_secs(5), node_task)
24284 .await
24285 .unwrap()
24286 .unwrap()
24287 .unwrap();
24288 drop(shutdown);
24289 drop(shared);
24290 std::fs::remove_dir_all(root).unwrap();
24291 }
24292
24293 #[cfg(feature = "fixture")]
24294 #[test]
24295 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24296 let root = temporary_workspace_root("clean-exit-constructor");
24297 std::fs::create_dir_all(&root).unwrap();
24298 let config = construction_test_config("clean-exit-constructor")
24299 .with_state_path(root.join("node-state.json"))
24300 .unwrap();
24301 let server = NodeServer::new_clean_exit_fixture(
24302 config,
24303 root.clone(),
24304 root.join("started.marker"),
24305 root.join("release.signal"),
24306 )
24307 .unwrap();
24308 assert_eq!(
24309 server
24310 .shared
24311 .enabled_providers
24312 .iter()
24313 .map(AgentId::as_str)
24314 .collect::<Vec<_>>(),
24315 ["claude"],
24316 );
24317 drop(server);
24318 std::fs::remove_dir_all(root).unwrap();
24319 }
24320
24321 #[test]
24322 fn production_registry_provider_contract_manifest_is_exact() {
24323 let registry = active_registry().unwrap();
24324 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24325 assert_eq!(
24326 providers
24327 .iter()
24328 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24329 .collect::<Vec<_>>(),
24330 vec![
24331 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24332 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24333 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24334 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24335 ]
24336 );
24337 assert_eq!(
24338 adapters
24339 .iter()
24340 .map(|contract| (
24341 contract.provider.as_str(),
24342 contract.family,
24343 contract.adapter_id.as_str(),
24344 contract.revision.as_str(),
24345 ))
24346 .collect::<Vec<_>>(),
24347 vec![
24348 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24349 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24350 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24351 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24352 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24353 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24354 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24355 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24356 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24357 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24358 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24359 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24360 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24361 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24362 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24363 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24364 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24365 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24366 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24367 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24368 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24369 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24370 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24371 ]
24372 );
24373 }
24374
24375 fn construction_test_config(name: &str) -> NodeServerConfig {
24376 let workspace = WorkspaceConfig::new(
24377 WorkspaceId::new("primary").unwrap(),
24378 std::env::current_dir().unwrap(),
24379 )
24380 .unwrap();
24381 NodeServerConfig::new(
24382 format!(r"\\.\pipe\gate4agent-{name}"),
24383 "fixture-token",
24384 NodeId::new(name).unwrap(),
24385 [workspace],
24386 )
24387 .unwrap()
24388 }
24389
24390 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24391 let adapter = AdapterBinding::new(
24392 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24393 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24394 gate4agent_types::AdapterVerification::SyntheticFixture,
24395 )
24396 .unwrap();
24397 let agent_ids = ["claude", "codex", "kimi"]
24398 .into_iter()
24399 .map(|agent_id| AgentId::new(agent_id).unwrap())
24400 .collect::<Vec<_>>();
24401 let adapters = gate4agent_catalog::AdapterRegistry::new(
24402 [
24403 AdapterFamily::PtySemantic,
24404 AdapterFamily::Pipe,
24405 AdapterFamily::OneShot,
24406 AdapterFamily::Acp,
24407 AdapterFamily::Hook,
24408 AdapterFamily::ManagedHook,
24409 AdapterFamily::History,
24410 AdapterFamily::Resume,
24411 AdapterFamily::SessionOptions,
24412 AdapterFamily::CapabilityProbe,
24413 ]
24414 .into_iter()
24415 .map(|family| gate4agent_catalog::AdapterDescriptor {
24416 family,
24417 binding: adapter.clone(),
24418 agents: agent_ids.clone(),
24419 }),
24420 )
24421 .unwrap();
24422 let specs = agent_ids
24423 .iter()
24424 .map(|agent_id| {
24425 let agent_id = agent_id.as_str();
24426 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24427 spec.revision =
24428 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24429 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24430 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24431 adapter: adapter.clone(),
24432 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24433 launch_override: None,
24434 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24435 });
24436 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24437 adapter: adapter.clone(),
24438 launch_override: None,
24439 });
24440 spec.capabilities.adapters.hook = Some(adapter.clone());
24441 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24442 spec.capabilities.adapters.one_shot = Some(adapter.clone());
24443 spec.capabilities.adapters.history = Some(adapter.clone());
24444 spec.capabilities.adapters.resume = Some(adapter.clone());
24445 spec.capabilities.adapters.session_options = Some(adapter.clone());
24446 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24447 spec
24448 })
24449 .collect::<Vec<_>>();
24450 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24451 }
24452
24453 #[test]
24461 fn node_server_construction_accepts_the_production_manifest() {
24462 let registry = active_registry().unwrap();
24463 let (providers, _) = provider_contract_manifest(®istry).unwrap();
24464 assert_eq!(providers.len(), registry.iter().count());
24465 assert!(!providers.is_empty());
24466 let server = NodeServer::new_with_registry(
24467 construction_test_config("manifest-production-bound"),
24468 registry,
24469 )
24470 .unwrap();
24471 drop(server);
24472 }
24473
24474 #[test]
24475 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24476 let registry = maximum_size_provider_manifest_registry();
24477 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24478 assert_eq!(providers.len(), 3);
24479 assert_eq!(adapters.len(), 30);
24480 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24481 let error = match NodeServer::new_with_registry(
24482 construction_test_config("manifest-handshake-overflow"),
24483 registry,
24484 ) {
24485 Ok(_) => panic!("oversized negotiated manifest was accepted"),
24486 Err(error) => error,
24487 };
24488 assert!(matches!(
24489 error,
24490 NodeServerError::ProviderContractManifest(message)
24491 if message.contains("authentication binding length")
24492 && message.contains("8192-byte limit")
24493 ));
24494 }
24495
24496 #[test]
24497 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24498 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24499 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24500 let registry = AgentRegistry::new([spec]).unwrap();
24501 assert!(matches!(
24502 provider_contract_manifest(®istry),
24503 Err(NodeServerError::ProviderContractManifest(message))
24504 if message.contains("exceeds the 128-byte limit")
24505 ));
24506 }
24507
24508 #[test]
24509 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24510 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24511 spec.revision = "invalid revision".to_owned();
24512 let registry = AgentRegistry::new([spec]).unwrap();
24513 assert!(matches!(
24514 provider_contract_manifest(®istry),
24515 Err(NodeServerError::ProviderContractManifest(message))
24516 if message.contains("bounded lowercase ASCII")
24517 ));
24518 }
24519
24520 #[test]
24521 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24522 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24523 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24524 let conflicting_binding = builtin_registry()
24525 .get_by_id("codex")
24526 .unwrap()
24527 .capabilities
24528 .adapters
24529 .one_shot
24530 .clone()
24531 .unwrap();
24532 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24533 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24534 pipe.adapter = transport_binding;
24535 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24536 let registry = AgentRegistry::new([spec]).unwrap();
24537 assert!(matches!(
24538 provider_contract_manifest(®istry),
24539 Err(NodeServerError::ProviderContractManifest(message))
24540 if message.contains("conflicting OneShot adapter bindings")
24541 ));
24542 }
24543
24544 #[test]
24545 fn public_node_failures_expose_only_fixed_categories() {
24546 let provider = failure(
24547 NodeFailureCode::BackendOperationFailed,
24548 r"provider bearer-secret failed at C:\private\session.jsonl",
24549 );
24550 assert_eq!(provider.message, "backend-operation-failed");
24551 assert!(!provider.message.contains("bearer-secret"));
24552 assert!(!provider.message.contains(r"C:\private"));
24553 let persistence = persistence_failure(io::Error::new(
24554 io::ErrorKind::Other,
24555 r"state write failed at C:\private\state-v1.json",
24556 ));
24557 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24558 assert!(!persistence.message.contains(r"C:\private"));
24559
24560 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24561 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24562 assert_eq!(
24563 generic.to_string(),
24564 "node durable state failed: durable-state-load-failed",
24565 );
24566 }
24567 }
24568
24569 #[test]
24570 fn terminal_byte_sequences_are_bounded_before_dispatch() {
24571 let sequence = b"\x1b[1;5D".to_vec();
24572 assert_eq!(
24573 terminal_bytes_action(sequence.clone()).unwrap(),
24574 InputAction::TerminalBytes(sequence),
24575 );
24576 assert_eq!(
24577 terminal_bytes_action(Vec::new()).unwrap_err().code,
24578 NodeFailureCode::InvalidRequest,
24579 );
24580 assert_eq!(
24581 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24582 .unwrap_err()
24583 .code,
24584 NodeFailureCode::InvalidRequest,
24585 );
24586 }
24587
24588 #[test]
24589 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24590 let root = temporary_workspace_root("exclusive-state-owner");
24591 std::fs::create_dir_all(&root).unwrap();
24592 let workspace = WorkspaceConfig::new(
24593 WorkspaceId::new("primary").unwrap(),
24594 &root,
24595 )
24596 .unwrap();
24597 let state_path = root.join("state-v1.json");
24598 let endpoint = format!(
24599 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24600 std::process::id(),
24601 std::time::SystemTime::now()
24602 .duration_since(std::time::UNIX_EPOCH)
24603 .unwrap()
24604 .as_nanos(),
24605 );
24606 let config = NodeServerConfig::new(
24607 endpoint,
24608 "fixture-token",
24609 NodeId::new("node-1").unwrap(),
24610 [workspace],
24611 )
24612 .unwrap()
24613 .with_state_path(&state_path)
24614 .unwrap();
24615
24616 let first = NodeServer::new(config.clone()).unwrap();
24617 let error = match NodeServer::new(config.clone()) {
24618 Ok(_) => panic!("a second node server acquired the same durable state path"),
24619 Err(error) => error,
24620 };
24621 assert!(matches!(
24622 &error,
24623 NodeServerError::DurableState(error)
24624 if error.kind() == io::ErrorKind::WouldBlock
24625 ));
24626 assert_eq!(
24627 error.to_string(),
24628 "node durable state failed: durable-state-lock-failed",
24629 );
24630 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24631 drop(first);
24632 let second = NodeServer::new(config).unwrap();
24633 drop(second);
24634 std::fs::remove_dir_all(root).unwrap();
24635 }
24636
24637 #[test]
24638 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24639 let catalog = active_registry().unwrap();
24640 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24641 let workspace_id = WorkspaceId::new("primary").unwrap();
24642 let workspace = WorkspaceConfig::new(
24643 workspace_id.clone(),
24644 std::env::current_dir().unwrap(),
24645 )
24646 .unwrap();
24647 let shared = NodeShared::new(
24648 handle,
24649 "fixture-token".to_owned(),
24650 NodeId::new("node-1").unwrap(),
24651 vec![workspace.clone()],
24652 vec![agent("claude"), agent("codex"), agent("kimi")],
24653 );
24654 let instance_id = AgentInstanceId(17);
24655 let generation = SessionGeneration(3);
24656 assert!(shared.address_for(instance_id, generation).is_none());
24657 shared.bind_session(instance_id, workspace_id.clone(), generation);
24658 let address = SessionAddress {
24659 workspace_id,
24660 session: SessionKey { instance_id, generation },
24661 };
24662 assert_eq!(
24663 shared.workspace_root(&address.workspace_id).unwrap(),
24664 workspace.canonical_root,
24665 );
24666 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
24667 shared.remove_binding(&address);
24668 assert!(shared.address_for(instance_id, generation).is_none());
24669 }
24670
24671 #[test]
24672 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
24673 let address = SessionAddress {
24674 workspace_id: WorkspaceId::new("primary").unwrap(),
24675 session: SessionKey {
24676 instance_id: AgentInstanceId(7),
24677 generation: SessionGeneration(2),
24678 },
24679 };
24680 let record_id = SessionRecordId::new("record-a").unwrap();
24681 let size = gate4agent_types::TerminalSize {
24682 rows: 24,
24683 columns: 80,
24684 };
24685 let address_requests = [
24686 NodeRequest::Resume {
24687 session: address.clone(),
24688 terminal_size: size,
24689 initial_prompt: None,
24690 },
24691 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
24692 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
24693 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
24694 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
24695 NodeRequest::TerminalControl {
24696 session: address.clone(),
24697 control: TerminalControl::Enter,
24698 },
24699 NodeRequest::Resize { session: address.clone(), size },
24700 NodeRequest::Interrupt { session: address.clone() },
24701 NodeRequest::Stop { session: address.clone(), force: false },
24702 NodeRequest::Remove { session: address },
24703 ];
24704 for request in &address_requests {
24705 assert!(request_requires_open_provider_ids_with(
24706 request,
24707 |_| Some(agent("third-party-agent")),
24708 |_| Some(agent("claude")),
24709 ));
24710 assert!(!request_requires_open_provider_ids_with(
24711 request,
24712 |_| Some(agent("claude")),
24713 |_| Some(agent("claude")),
24714 ));
24715 assert!(request_requires_open_provider_ids_with(
24716 request,
24717 |_| None,
24718 |_| Some(agent("claude")),
24719 ));
24720 }
24721
24722 let record_requests = [
24723 NodeRequest::RenameSessionRecord {
24724 record_id: record_id.clone(),
24725 display_name: "renamed".to_owned(),
24726 },
24727 NodeRequest::ResumeSessionRecord {
24728 record_id: record_id.clone(),
24729 terminal_size: size,
24730 initial_prompt: None,
24731 },
24732 NodeRequest::ForgetSessionRecord { record_id },
24733 ];
24734 for request in &record_requests {
24735 assert!(request_requires_open_provider_ids_with(
24736 request,
24737 |_| Some(agent("claude")),
24738 |_| Some(agent("grok")),
24739 ));
24740 assert!(!request_requires_open_provider_ids_with(
24741 request,
24742 |_| Some(agent("claude")),
24743 |_| Some(agent("kimi")),
24744 ));
24745 assert!(request_requires_open_provider_ids_with(
24746 request,
24747 |_| Some(agent("claude")),
24748 |_| None,
24749 ));
24750 }
24751
24752 assert!(request_requires_open_provider_ids_with(
24753 &NodeRequest::Spawn {
24754 workspace_id: WorkspaceId::new("primary").unwrap(),
24755 provider: agent("third-party-agent"),
24756 mode: SessionMode::Pty,
24757 terminal_size: size,
24758 initial_prompt: None,
24759 },
24760 |_| None,
24761 |_| None,
24762 ));
24763 assert!(!request_requires_open_provider_ids_with(
24764 &NodeRequest::Snapshot,
24765 |_| None,
24766 |_| None,
24767 ));
24768 assert!(request_requires_open_provider_ids_with(
24769 &NodeRequest::ForgetContextPack {
24770 context_id: SpawnContextId::new("ctx-forget").unwrap(),
24771 },
24772 |_| None,
24773 |_| None,
24774 ));
24775 }
24776
24777 #[test]
24778 fn legacy_projection_filters_open_record_replies_and_event_history() {
24779 let catalog = active_registry().unwrap();
24780 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24781 let workspace = WorkspaceConfig::new(
24782 WorkspaceId::new("primary").unwrap(),
24783 std::env::current_dir().unwrap(),
24784 )
24785 .unwrap();
24786 let shared = NodeShared::new(
24787 handle,
24788 "fixture-token".to_owned(),
24789 NodeId::new("node-1").unwrap(),
24790 vec![workspace],
24791 vec![agent("claude"), agent("grok")],
24792 );
24793 let legacy = record("claude", "legacy-record");
24794 let open = record("grok", "open-record");
24795 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24796 record: legacy.clone(),
24797 });
24798 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24799 record: open.clone(),
24800 });
24801 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24802 record_id: legacy.record_id.clone(),
24803 });
24804 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24805 record_id: open.record_id.clone(),
24806 });
24807 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
24808 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
24809 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
24810 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
24811
24812 let mut record_reply = ResponseEnvelope {
24813 request_id: 1,
24814 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
24815 };
24816 project_response_legacy_provider_ids(&shared, &mut record_reply);
24817 assert!(matches!(
24818 record_reply.result,
24819 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
24820 ));
24821
24822 let mut resync = ResponseEnvelope {
24823 request_id: 2,
24824 result: Ok(NodeResponse::Resync {
24825 event_sequence: open_removed.sequence,
24826 oldest_available_sequence: 1,
24827 snapshot: shared.snapshot(),
24828 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
24829 }),
24830 };
24831 project_response_legacy_provider_ids(&shared, &mut resync);
24832 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
24833 panic!("legacy projection changed the resync response kind");
24834 };
24835 assert!(snapshot.session_records.iter().all(|item| {
24836 provider_id_is_legacy(&item.provider)
24837 }));
24838 assert_eq!(events.len(), 2);
24839 assert!(events.iter().all(|event| matches!(
24840 &event.event,
24841 NodeEvent::SessionRecordUpserted { record }
24842 if provider_id_is_legacy(&record.provider)
24843 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
24844 }
24845
24846 #[test]
24847 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
24848 let catalog = active_registry().unwrap();
24849 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24850 let workspace_id = WorkspaceId::new("primary").unwrap();
24851 let workspace = WorkspaceConfig::new(
24852 workspace_id.clone(),
24853 std::env::current_dir().unwrap(),
24854 )
24855 .unwrap();
24856 let shared = NodeShared::new(
24857 handle,
24858 "fixture-token".to_owned(),
24859 NodeId::new("node-1").unwrap(),
24860 vec![workspace],
24861 vec![agent("claude")],
24862 );
24863 for index in 0..CONTROL_SESSIONS_MAX {
24864 shared.bind_session(
24865 AgentInstanceId((index + 1) as u64),
24866 workspace_id.clone(),
24867 SessionGeneration(1),
24868 );
24869 }
24870 let error = shared.ensure_binding_capacity().unwrap_err();
24871 assert_eq!(error.code, NodeFailureCode::BackendBusy);
24872 }
24873
24874 #[tokio::test]
24875 async fn provider_spawn_admission_rejects_before_session_mutation() {
24876 let catalog = active_registry().unwrap();
24877 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24878 let workspace = WorkspaceConfig::new(
24879 WorkspaceId::new("primary").unwrap(),
24880 std::env::current_dir().unwrap(),
24881 )
24882 .unwrap();
24883 let mut shared = NodeShared::new(
24884 handle,
24885 "fixture-token".to_owned(),
24886 NodeId::new("node-provider-admission").unwrap(),
24887 vec![workspace],
24888 vec![agent("claude")],
24889 );
24890 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24891 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
24892 ])
24893 .unwrap();
24894 let terminal_size = gate4agent_types::TerminalSize {
24895 rows: 24,
24896 columns: 80,
24897 };
24898
24899 let unavailable = shared
24900 .spawn_session(
24901 WorkspaceId::new("primary").unwrap(),
24902 agent("claude"),
24903 SessionMode::Pty,
24904 terminal_size,
24905 None,
24906 )
24907 .await
24908 .unwrap_err();
24909 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
24910 assert_eq!(unavailable.message, "backend-operation-failed");
24911
24912 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24913 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
24914 agent("claude"),
24915 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
24916 ),
24917 ])
24918 .unwrap();
24919 let semantic = shared
24920 .spawn_session(
24921 WorkspaceId::new("primary").unwrap(),
24922 agent("claude"),
24923 SessionMode::Inline,
24924 terminal_size,
24925 None,
24926 )
24927 .await
24928 .unwrap_err();
24929 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
24930 assert_eq!(semantic.message, "unsupported-capability");
24931
24932 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
24933 assert!(shared
24934 .session_bindings
24935 .lock()
24936 .unwrap_or_else(|poisoned| poisoned.into_inner())
24937 .is_empty());
24938 assert!(shared
24939 .session_records
24940 .lock()
24941 .unwrap_or_else(|poisoned| poisoned.into_inner())
24942 .records
24943 .is_empty());
24944 assert!(shared.handle.snapshot().sessions.is_empty());
24945 }
24946
24947 #[tokio::test]
24948 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
24949 let catalog = active_registry().unwrap();
24950 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24951 let workspace_id = WorkspaceId::new("primary").unwrap();
24952 let workspace = WorkspaceConfig::new(
24953 workspace_id.clone(),
24954 std::env::current_dir().unwrap(),
24955 )
24956 .unwrap();
24957 let shared = NodeShared::new(
24958 handle,
24959 "fixture-token".to_owned(),
24960 NodeId::new("node-raw-spawn-record").unwrap(),
24961 vec![workspace],
24962 vec![agent("claude")],
24963 );
24964 let address = SessionAddress {
24965 workspace_id,
24966 session: SessionKey {
24967 instance_id: AgentInstanceId(71),
24968 generation: SessionGeneration::default(),
24969 },
24970 };
24971
24972 let record_id = shared
24973 .bind_spawn_session(
24974 &address,
24975 agent("claude"),
24976 SessionMode::Pty,
24977 ProviderRuntimePolicy::raw_pty(),
24978 SpawnRecordPolicy::Always,
24979 None,
24980 )
24981 .unwrap()
24982 .unwrap();
24983 shared
24984 .handle
24985 .dispatch(shared.prepare_command(ControlCommand::Register {
24986 instance_id: address.session.instance_id,
24987 agent_id: agent("claude"),
24988 transport: TransportKind::Pty,
24989 }))
24990 .unwrap();
24991 runtime.tick().await;
24992
24993 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24994 session.instance_id == address.session.instance_id
24995 && session.generation == address.session.generation
24996 }));
24997 let record = shared.record(&record_id).unwrap();
24998 assert_eq!(record.display_name, "claude #1");
24999 assert_eq!(record.state, ManagedSessionState::Live);
25000 assert_eq!(record.active_session.as_ref(), Some(&address));
25001 assert!(record.provider_session.is_none());
25002 let binding_record_id = shared
25003 .session_bindings
25004 .lock()
25005 .unwrap_or_else(|poisoned| poisoned.into_inner())
25006 .get(&address.session.instance_id)
25007 .and_then(|binding| binding.record_id.clone());
25008 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25009
25010 let renamed = shared
25011 .rename_session_record(&record_id, "raw review".to_owned())
25012 .unwrap();
25013 assert_eq!(renamed.record_id, record_id);
25014 assert_eq!(renamed.display_name, "raw review");
25015 shared.reconcile_managed_record(
25016 &record_id,
25017 &address,
25018 &ControlEvent {
25019 sequence: 1,
25020 command_id: None,
25021 instance_id: address.session.instance_id,
25022 generation: address.session.generation,
25023 event: ControlEventKind::Running { process_id: None },
25024 },
25025 false,
25026 false,
25027 );
25028 let running = shared.record(&record_id).unwrap();
25029 assert_eq!(running.state, ManagedSessionState::Live);
25030 assert_eq!(running.active_session.as_ref(), Some(&address));
25031 assert_eq!(running.display_name, "raw review");
25032
25033 shared.reconcile_managed_record(
25034 &record_id,
25035 &address,
25036 &ControlEvent {
25037 sequence: 2,
25038 command_id: None,
25039 instance_id: address.session.instance_id,
25040 generation: address.session.generation,
25041 event: ControlEventKind::Exited {
25042 exit_code: Some(0),
25043 forced: false,
25044 },
25045 },
25046 false,
25047 false,
25048 );
25049 let detached = shared.record(&record_id).unwrap();
25050 assert_eq!(detached.state, ManagedSessionState::Unavailable);
25051 assert!(detached.active_session.is_none());
25052 assert_eq!(detached.display_name, "raw review");
25053 let renamed_detached = shared
25054 .rename_session_record(&record_id, "raw review detached".to_owned())
25055 .unwrap();
25056 assert_eq!(renamed_detached.record_id, record_id);
25057 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25058 assert!(renamed_detached.active_session.is_none());
25059 assert_eq!(renamed_detached.display_name, "raw review detached");
25060 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25061 }
25062
25063 #[test]
25064 fn legacy_raw_spawn_binding_remains_untracked() {
25065 let catalog = active_registry().unwrap();
25066 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25067 let workspace_id = WorkspaceId::new("primary").unwrap();
25068 let workspace = WorkspaceConfig::new(
25069 workspace_id.clone(),
25070 std::env::current_dir().unwrap(),
25071 )
25072 .unwrap();
25073 let shared = NodeShared::new(
25074 handle,
25075 "fixture-token".to_owned(),
25076 NodeId::new("node-legacy-raw-spawn").unwrap(),
25077 vec![workspace],
25078 vec![agent("claude")],
25079 );
25080 let address = SessionAddress {
25081 workspace_id,
25082 session: SessionKey {
25083 instance_id: AgentInstanceId(72),
25084 generation: SessionGeneration::default(),
25085 },
25086 };
25087
25088 let record_id = shared
25089 .bind_spawn_session(
25090 &address,
25091 agent("claude"),
25092 SessionMode::Pty,
25093 ProviderRuntimePolicy::raw_pty(),
25094 SpawnRecordPolicy::ProviderIdentityOnly,
25095 None,
25096 )
25097 .unwrap();
25098
25099 assert!(record_id.is_none());
25100 let binding_record_id = shared
25101 .session_bindings
25102 .lock()
25103 .unwrap_or_else(|poisoned| poisoned.into_inner())
25104 .get(&address.session.instance_id)
25105 .and_then(|binding| binding.record_id.clone());
25106 assert!(binding_record_id.is_none());
25107 assert!(shared.snapshot().session_records.is_empty());
25108 }
25109
25110 #[tokio::test]
25111 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25112 let catalog = active_registry().unwrap();
25113 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25114 let workspace_id = WorkspaceId::new("primary").unwrap();
25115 let workspace = WorkspaceConfig::new(
25116 workspace_id.clone(),
25117 std::env::current_dir().unwrap(),
25118 )
25119 .unwrap();
25120 let mut shared = NodeShared::new(
25121 handle,
25122 "fixture-token".to_owned(),
25123 NodeId::new("node-raw-semantic-gate").unwrap(),
25124 vec![workspace],
25125 vec![agent("claude")],
25126 );
25127 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25128 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25129 ])
25130 .unwrap();
25131 shared
25132 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25133 .unwrap();
25134 let address = SessionAddress {
25135 workspace_id,
25136 session: SessionKey {
25137 instance_id: AgentInstanceId(72),
25138 generation: SessionGeneration::default(),
25139 },
25140 };
25141 shared.bind_session_with_policy(
25142 &address,
25143 ProviderRuntimePolicy::raw_pty(),
25144 None,
25145 );
25146 shared
25147 .handle
25148 .dispatch(shared.prepare_command(ControlCommand::Register {
25149 instance_id: address.session.instance_id,
25150 agent_id: agent("claude"),
25151 transport: TransportKind::Pty,
25152 }))
25153 .unwrap();
25154 runtime.tick().await;
25155 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25156 let revision_before = shared.handle.snapshot().revision;
25157 let history_sequence_before = shared.current_sequence();
25158 let terminal_size = gate4agent_types::TerminalSize {
25159 rows: 24,
25160 columns: 80,
25161 };
25162
25163 for request in [
25164 NodeRequest::Prompt {
25165 session: address.clone(),
25166 text: "prompt".to_owned(),
25167 },
25168 NodeRequest::Paste {
25169 session: address.clone(),
25170 text: "paste".to_owned(),
25171 },
25172 NodeRequest::Resume {
25178 session: address.clone(),
25179 terminal_size,
25180 initial_prompt: Some("resume".to_owned()),
25181 },
25182 ] {
25183 let error = process_request_inner(
25184 &shared,
25185 77,
25186 ClientRole::Operator,
25187 request,
25188 )
25189 .await
25190 .unwrap_err();
25191 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25192 }
25193
25194 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25195 assert_eq!(shared.handle.snapshot().revision, revision_before);
25196 assert_eq!(shared.current_sequence(), history_sequence_before);
25197 let bindings = shared
25198 .session_bindings
25199 .lock()
25200 .unwrap_or_else(|poisoned| poisoned.into_inner());
25201 let binding = bindings.get(&address.session.instance_id).unwrap();
25202 assert_eq!(binding.generation, address.session.generation);
25203 assert!(binding.pending_resume.is_none());
25204 assert!(binding.record_id.is_none());
25205 }
25206
25207 #[tokio::test]
25208 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25209 let catalog = active_registry().unwrap();
25210 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25211 let primary = WorkspaceConfig::new(
25212 WorkspaceId::new("primary").unwrap(),
25213 std::env::current_dir().unwrap(),
25214 )
25215 .unwrap();
25216 let shared = NodeShared::new(
25217 handle,
25218 "fixture-token".to_owned(),
25219 NodeId::new("node-1").unwrap(),
25220 vec![primary],
25221 vec![agent("claude")],
25222 );
25223 shared
25224 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25225 .unwrap();
25226 let before = shared.snapshot();
25227 let error = process_request_inner(
25228 &shared,
25229 77,
25230 ClientRole::Operator,
25231 NodeRequest::RegisterWorkspace {
25232 workspace_id: WorkspaceId::new("foreign").unwrap(),
25233 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25234 },
25235 )
25236 .await
25237 .unwrap_err();
25238 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25239 assert_eq!(error.message, "invalid-request");
25240 assert_eq!(shared.snapshot().workspaces, before.workspaces);
25241 }
25242
25243 #[tokio::test]
25244 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25245 let catalog = active_registry().unwrap();
25246 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25247 let primary_id = WorkspaceId::new("primary").unwrap();
25248 let primary = WorkspaceConfig::new(
25249 primary_id.clone(),
25250 std::env::current_dir().unwrap(),
25251 )
25252 .unwrap();
25253 let shared = NodeShared::new(
25254 handle,
25255 "fixture-token".to_owned(),
25256 NodeId::new("node-1").unwrap(),
25257 vec![primary.clone()],
25258 vec![agent("claude")],
25259 );
25260 let secondary_id = WorkspaceId::new("secondary").unwrap();
25261 let secondary = shared
25262 .register_workspace(
25263 secondary_id.clone(),
25264 std::env::temp_dir().to_string_lossy().into_owned(),
25265 )
25266 .await
25267 .unwrap();
25268 assert_eq!(secondary.workspace_id, secondary_id);
25269 assert!(secondary.sessions.is_empty());
25270 assert_eq!(shared.snapshot().workspaces.len(), 2);
25271
25272 let duplicate_id = shared
25273 .register_workspace(
25274 secondary_id.clone(),
25275 primary.canonical_root().to_owned(),
25276 )
25277 .await
25278 .unwrap_err();
25279 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25280 let duplicate_root = shared
25281 .register_workspace(
25282 WorkspaceId::new("duplicate-root").unwrap(),
25283 windows_path_text(&secondary.canonical_root).to_owned(),
25284 )
25285 .await
25286 .unwrap_err();
25287 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25288
25289 let address = SessionAddress {
25290 workspace_id: secondary_id.clone(),
25291 session: SessionKey {
25292 instance_id: AgentInstanceId(41),
25293 generation: SessionGeneration(1),
25294 },
25295 };
25296 shared.bind_session(
25297 address.session.instance_id,
25298 secondary_id.clone(),
25299 address.session.generation,
25300 );
25301 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25302 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25303 shared.remove_binding(&address);
25304 shared.unregister_workspace(&secondary_id).unwrap();
25305 assert_eq!(shared.snapshot().workspaces.len(), 1);
25306
25307 let last = shared.unregister_workspace(&primary_id).unwrap_err();
25308 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25309 let history = shared
25310 .history
25311 .lock()
25312 .unwrap_or_else(|poisoned| poisoned.into_inner());
25313 assert!(matches!(
25314 &history.events[0],
25315 NodeEventEnvelope {
25316 sequence: 1,
25317 event: NodeEvent::WorkspaceAdded { workspace },
25318 } if workspace.workspace_id == secondary_id
25319 ));
25320 assert!(matches!(
25321 &history.events[1],
25322 NodeEventEnvelope {
25323 sequence: 2,
25324 event: NodeEvent::WorkspaceRemoved { workspace_id },
25325 } if workspace_id == &secondary_id
25326 ));
25327 }
25328
25329 #[tokio::test]
25330 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25331 let secondary_root = temporary_workspace_root("provider-session-reference-index");
25332 std::fs::create_dir_all(&secondary_root).unwrap();
25333 let catalog = active_registry().unwrap();
25334 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25335 let primary_id = WorkspaceId::new("primary").unwrap();
25336 let secondary_id = WorkspaceId::new("secondary").unwrap();
25337 let shared = NodeShared::new(
25338 handle,
25339 "fixture-token".to_owned(),
25340 NodeId::new("node-session-reference-index").unwrap(),
25341 vec![
25342 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25343 .unwrap(),
25344 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25345 ],
25346 vec![agent("claude"), agent("codex")],
25347 );
25348 let identity = ProviderSessionIdentity {
25349 key: gate4agent_types::ProviderSessionKey::SessionId,
25350 id: "provider-session-42".to_owned(),
25351 transcript_path: None,
25352 };
25353 let index_request = |workspace_id: WorkspaceId,
25354 provider: AgentId,
25355 identity: ProviderSessionIdentity,
25356 display_name: &str| {
25357 NodeRequest::IndexProviderSession {
25358 workspace_id,
25359 provider,
25360 identity,
25361 display_name: display_name.to_owned(),
25362 }
25363 };
25364 let request = index_request(
25365 primary_id.clone(),
25366 agent("claude"),
25367 identity.clone(),
25368 "release shepherd",
25369 );
25370 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25371 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25372 }));
25373 let observer = process_request_inner(
25374 &shared,
25375 77,
25376 ClientRole::Observer,
25377 request.clone(),
25378 )
25379 .await
25380 .unwrap_err();
25381 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25382 let uncontrolled = process_request_inner(
25383 &shared,
25384 77,
25385 ClientRole::Operator,
25386 request.clone(),
25387 )
25388 .await
25389 .unwrap_err();
25390 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25391 shared
25392 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25393 .unwrap();
25394
25395 let indexed = process_request_inner(
25396 &shared,
25397 77,
25398 ClientRole::Operator,
25399 request.clone(),
25400 )
25401 .await
25402 .unwrap();
25403 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25404 panic!("provider session reference index returned another response");
25405 };
25406 assert_eq!(record.state, ManagedSessionState::Dormant);
25407 assert_eq!(record.mode, SessionMode::Pty);
25408 assert_eq!(record.workspace_id, primary_id);
25409 assert_eq!(record.provider_session.as_ref(), Some(&identity));
25410 assert!(record.active_session.is_none());
25411 assert!(record.environment_profile.is_none());
25412 assert!(record.bundle.is_none());
25413 assert!(record.context.is_none());
25414
25415 let repeated = process_request_inner(
25416 &shared,
25417 77,
25418 ClientRole::Operator,
25419 index_request(
25420 primary_id.clone(),
25421 agent("claude"),
25422 identity.clone(),
25423 "ignored replacement",
25424 ),
25425 )
25426 .await
25427 .unwrap();
25428 assert!(matches!(
25429 repeated,
25430 NodeResponse::ProviderSessionIndexed { record: ref existing }
25431 if existing.record_id == record.record_id
25432 && existing.display_name == "release shepherd"
25433 ));
25434 let conflicting_workspace = process_request_inner(
25435 &shared,
25436 77,
25437 ClientRole::Operator,
25438 index_request(
25439 secondary_id,
25440 agent("claude"),
25441 identity.clone(),
25442 "release shepherd",
25443 ),
25444 )
25445 .await
25446 .unwrap_err();
25447 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25448 let conflicting_provider = process_request_inner(
25449 &shared,
25450 77,
25451 ClientRole::Operator,
25452 index_request(
25453 primary_id.clone(),
25454 agent("codex"),
25455 identity.clone(),
25456 "release shepherd",
25457 ),
25458 )
25459 .await
25460 .unwrap_err();
25461 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25462 let transcript_rejected = process_request_inner(
25463 &shared,
25464 77,
25465 ClientRole::Operator,
25466 index_request(
25467 primary_id,
25468 agent("claude"),
25469 ProviderSessionIdentity {
25470 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25471 ..identity
25472 },
25473 "release shepherd",
25474 ),
25475 )
25476 .await
25477 .unwrap_err();
25478 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25479 let upserts = shared
25480 .history
25481 .lock()
25482 .unwrap_or_else(|poisoned| poisoned.into_inner())
25483 .events
25484 .iter()
25485 .filter(|event| matches!(
25486 &event.event,
25487 NodeEvent::SessionRecordUpserted { record: published }
25488 if published.record_id == record.record_id
25489 ))
25490 .count();
25491 assert_eq!(upserts, 1);
25492 std::fs::remove_dir_all(secondary_root).unwrap();
25493 }
25494
25495 #[tokio::test]
25496 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25497 let fixture_root = temporary_workspace_root("standalone-independent");
25498 let target = fixture_root.join("independent");
25499 std::fs::create_dir_all(&fixture_root).unwrap();
25500 let catalog = active_registry().unwrap();
25501 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25502 let selected = WorkspaceConfig::new(
25503 WorkspaceId::new("primary").unwrap(),
25504 std::env::current_dir().unwrap(),
25505 )
25506 .unwrap();
25507 let selected_root = selected.canonical_root().to_owned();
25508 let shared = NodeShared::new(
25509 handle,
25510 "fixture-token".to_owned(),
25511 NodeId::new("node-standalone").unwrap(),
25512 vec![selected],
25513 vec![agent("claude")],
25514 );
25515 let workspace_id = WorkspaceId::new("independent").unwrap();
25516 let request = NodeRequest::CreateStandaloneWorkspace {
25517 workspace_id: workspace_id.clone(),
25518 root: opaque_windows_path(target.to_string_lossy().into_owned()),
25519 initial_branch: Some("main".to_owned()),
25520 };
25521 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25522 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25523 }));
25524 assert!(!request_uses_unnegotiated_capability(
25525 &request,
25526 &baseline_capabilities().unwrap(),
25527 ));
25528 assert!(request_uses_unnegotiated_capability(&request, &[]));
25529
25530 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25531 .await
25532 .unwrap_err();
25533 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25534 assert!(!target.exists());
25535 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25536 .await
25537 .unwrap_err();
25538 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25539 assert!(!target.exists());
25540 shared
25541 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25542 .unwrap();
25543
25544 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25545 .await
25546 .unwrap();
25547 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25548 panic!("standalone workspace creation returned another response");
25549 };
25550 assert_eq!(workspace.workspace_id, workspace_id);
25551 assert!(target.join(".git").is_dir());
25552 let branch = run_git_bounded(
25553 target.to_str().unwrap(),
25554 &["branch", "--show-current"],
25555 GIT_OUTPUT_MAX_BYTES,
25556 )
25557 .await
25558 .unwrap();
25559 assert!(branch.success);
25560 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25561 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25562 assert_eq!(worktrees.len(), 1);
25563 let listed_root = WorkspaceConfig::new(
25564 WorkspaceId::new("listed-independent").unwrap(),
25565 &worktrees[0].path,
25566 )
25567 .unwrap();
25568 assert!(platform::roots_equal(
25569 listed_root.canonical_root(),
25570 windows_path_text(&workspace.canonical_root),
25571 ));
25572 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25573 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25574 snapshot.workspace_id == workspace_id
25575 && snapshot.canonical_root == workspace.canonical_root
25576 }));
25577 assert!(shared
25578 .history
25579 .lock()
25580 .unwrap_or_else(|poisoned| poisoned.into_inner())
25581 .events
25582 .iter()
25583 .any(|event| matches!(
25584 &event.event,
25585 NodeEvent::WorkspaceAdded { workspace }
25586 if workspace.workspace_id == workspace_id
25587 )));
25588 std::fs::remove_dir_all(fixture_root).unwrap();
25589 }
25590
25591 #[tokio::test]
25592 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25593 let fixture_root = temporary_workspace_root("standalone-compensation");
25594 let created_target = fixture_root.join("created-target");
25595 let existing_target = fixture_root.join("existing-target");
25596 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25597 let invalid_state_path = invalid_state_parent.join("state.json");
25598 std::fs::create_dir_all(&existing_target).unwrap();
25599 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25600 let catalog = active_registry().unwrap();
25601 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25602 let primary = WorkspaceConfig::new(
25603 WorkspaceId::new("primary").unwrap(),
25604 std::env::current_dir().unwrap(),
25605 )
25606 .unwrap();
25607 let mut shared = NodeShared::new(
25608 handle,
25609 "fixture-token".to_owned(),
25610 NodeId::new("node-standalone-compensation").unwrap(),
25611 vec![primary],
25612 vec![agent("claude")],
25613 );
25614 shared.state_path = Some(invalid_state_path);
25615
25616 let created_recovery = shared
25617 .create_standalone_workspace(
25618 WorkspaceId::new("compensated").unwrap(),
25619 created_target.to_string_lossy().into_owned(),
25620 Some("main".to_owned()),
25621 )
25622 .await
25623 .unwrap_err();
25624 assert_eq!(
25625 created_recovery.code,
25626 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25627 );
25628 assert!(created_target.join(".git").is_dir());
25629 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25630 workspace.workspace_id.as_str() != "compensated"
25631 }));
25632
25633 let existing_recovery = shared
25634 .create_standalone_workspace(
25635 WorkspaceId::new("restored").unwrap(),
25636 existing_target.to_string_lossy().into_owned(),
25637 None,
25638 )
25639 .await
25640 .unwrap_err();
25641 assert_eq!(
25642 existing_recovery.code,
25643 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25644 );
25645 assert!(existing_target.is_dir());
25646 assert!(existing_target.join(".git").is_dir());
25647 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25648 workspace.workspace_id.as_str() != "restored"
25649 }));
25650
25651 let registered_empty = fixture_root.join("registered-empty");
25652 std::fs::create_dir(®istered_empty).unwrap();
25653 let catalog = active_registry().unwrap();
25654 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25655 let registered = WorkspaceConfig::new(
25656 WorkspaceId::new("registered-empty").unwrap(),
25657 ®istered_empty,
25658 )
25659 .unwrap();
25660 let duplicate_shared = NodeShared::new(
25661 handle,
25662 "fixture-token".to_owned(),
25663 NodeId::new("node-standalone-duplicate-root").unwrap(),
25664 vec![registered],
25665 vec![agent("claude")],
25666 );
25667 let duplicate = duplicate_shared
25668 .create_standalone_workspace(
25669 WorkspaceId::new("duplicate-root").unwrap(),
25670 registered_empty.to_string_lossy().into_owned(),
25671 None,
25672 )
25673 .await
25674 .unwrap_err();
25675 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
25676 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
25677 std::fs::remove_dir_all(fixture_root).unwrap();
25678 }
25679
25680 #[test]
25681 fn managed_record_id_collision_preserves_the_existing_record() {
25682 let catalog = active_registry().unwrap();
25683 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25684 let workspace = WorkspaceConfig::new(
25685 WorkspaceId::new("primary").unwrap(),
25686 std::env::current_dir().unwrap(),
25687 )
25688 .unwrap();
25689 let shared = NodeShared::new(
25690 handle,
25691 "fixture-token".to_owned(),
25692 NodeId::new("node-1").unwrap(),
25693 vec![workspace.clone()],
25694 vec![agent("claude")],
25695 );
25696 let record_id = SessionRecordId::new("sr-collision").unwrap();
25697 let original = ManagedSessionRecord {
25698 record_id: record_id.clone(),
25699 display_name: "original".to_owned(),
25700 provider: agent("claude"),
25701 mode: SessionMode::Pty,
25702 state: ManagedSessionState::Dormant,
25703 workspace_id: workspace.workspace_id,
25704 canonical_root: opaque_windows_path(workspace.canonical_root),
25705 provider_session: None,
25706 active_session: None,
25707 environment_profile: None,
25708 bundle: None,
25709 context_id: None,
25710 context: None,
25711 exported_context: None,
25712 task_binding: None,
25713 created_at_unix_ms: 1,
25714 updated_at_unix_ms: 1,
25715 last_error: None,
25716 };
25717 shared.insert_record(original.clone()).unwrap();
25718 let mut collision = original.clone();
25719 collision.display_name = "replacement".to_owned();
25720 let error = shared.insert_record(collision).unwrap_err();
25721 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
25722 assert_eq!(shared.record(&record_id).unwrap(), original);
25723 }
25724
25725 #[test]
25726 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
25727 let catalog = active_registry().unwrap();
25728 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25729 let workspace = WorkspaceConfig::new(
25730 WorkspaceId::new("primary").unwrap(),
25731 std::env::current_dir().unwrap(),
25732 )
25733 .unwrap();
25734 let shared = NodeShared::new(
25735 handle,
25736 "fixture-token".to_owned(),
25737 NodeId::new("node-1").unwrap(),
25738 vec![workspace.clone()],
25739 vec![agent("claude")],
25740 );
25741 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
25742 shared
25743 .insert_record(ManagedSessionRecord {
25744 record_id: record_id.clone(),
25745 display_name: "runtime error".to_owned(),
25746 provider: agent("claude"),
25747 mode: SessionMode::Pty,
25748 state: ManagedSessionState::IdentityPending,
25749 workspace_id: workspace.workspace_id,
25750 canonical_root: opaque_windows_path(workspace.canonical_root),
25751 provider_session: None,
25752 active_session: None,
25753 environment_profile: None,
25754 bundle: None,
25755 context_id: None,
25756 context: None,
25757 exported_context: None,
25758 task_binding: None,
25759 created_at_unix_ms: 1,
25760 updated_at_unix_ms: 1,
25761 last_error: None,
25762 })
25763 .unwrap();
25764
25765 shared
25766 .mark_record_error(
25767 &record_id,
25768 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
25769 )
25770 .unwrap();
25771 let record = shared.record(&record_id).unwrap();
25772 assert_eq!(record.state, ManagedSessionState::Unavailable);
25773 assert_eq!(
25774 record.last_error.as_deref(),
25775 Some("provider-runtime-failed"),
25776 );
25777 let snapshot = shared.snapshot();
25778 assert_eq!(snapshot.session_records, vec![record]);
25779 }
25780
25781 #[test]
25790 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
25791 let catalog = active_registry().unwrap();
25792 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25793 let workspace = WorkspaceConfig::new(
25794 WorkspaceId::new("primary").unwrap(),
25795 std::env::current_dir().unwrap(),
25796 )
25797 .unwrap();
25798 let shared = NodeShared::new(
25799 handle,
25800 "fixture-token".to_owned(),
25801 NodeId::new("node-1").unwrap(),
25802 vec![workspace],
25803 vec![agent("claude")],
25804 );
25805
25806 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25807
25808 shared.record_rejected_command(
25809 CommandId(1),
25810 "agent 'claude' does not support transport Acp".to_owned(),
25811 true,
25812 );
25813 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
25815
25816 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
25817 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
25818 assert!(reason.unsupported_transport);
25819
25820 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25822
25823 shared.record_rejected_command(
25828 CommandId(3),
25829 "backend busy".to_owned(),
25830 false,
25831 );
25832 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
25833 assert!(!unclassified.unsupported_transport);
25834 }
25835
25836 #[test]
25837 fn provider_identity_update_is_ignored_without_exact_session_policy() {
25838 let catalog = active_registry().unwrap();
25839 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25840 let workspace = WorkspaceConfig::new(
25841 WorkspaceId::new("primary").unwrap(),
25842 std::env::current_dir().unwrap(),
25843 )
25844 .unwrap();
25845 let shared = NodeShared::new(
25846 handle,
25847 "fixture-token".to_owned(),
25848 NodeId::new("node-identity-policy").unwrap(),
25849 vec![workspace.clone()],
25850 vec![agent("claude")],
25851 );
25852 let address = SessionAddress {
25853 workspace_id: workspace.workspace_id.clone(),
25854 session: SessionKey {
25855 instance_id: AgentInstanceId(90),
25856 generation: SessionGeneration(1),
25857 },
25858 };
25859 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
25860 shared
25861 .insert_record(ManagedSessionRecord {
25862 record_id: record_id.clone(),
25863 display_name: "policy denied".to_owned(),
25864 provider: agent("claude"),
25865 mode: SessionMode::Pty,
25866 state: ManagedSessionState::IdentityPending,
25867 workspace_id: workspace.workspace_id,
25868 canonical_root: opaque_windows_path(workspace.canonical_root),
25869 provider_session: None,
25870 active_session: Some(address.clone()),
25871 environment_profile: None,
25872 bundle: None,
25873 context_id: None,
25874 context: None,
25875 exported_context: None,
25876 task_binding: None,
25877 created_at_unix_ms: 1,
25878 updated_at_unix_ms: 1,
25879 last_error: None,
25880 })
25881 .unwrap();
25882 let event = ControlEvent {
25883 sequence: 1,
25884 command_id: None,
25885 instance_id: address.session.instance_id,
25886 generation: address.session.generation,
25887 event: ControlEventKind::ProviderEvent {
25888 sequence: 1,
25889 source: gate4agent_types::ProviderSource {
25890 family: AdapterFamily::PtySemantic,
25891 binding: AdapterBinding::new(
25892 gate4agent_types::AdapterId::new("claude").unwrap(),
25893 "fixture/v1",
25894 gate4agent_types::AdapterVerification::SyntheticFixture,
25895 )
25896 .unwrap(),
25897 },
25898 source_sequence: 1,
25899 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25900 identity: gate4agent_types::ProviderSessionIdentity {
25901 key: gate4agent_types::ProviderSessionKey::SessionId,
25902 id: "provider-session".to_owned(),
25903 transcript_path: None,
25904 },
25905 },
25906 },
25907 };
25908
25909 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
25910
25911 let record = shared.record(&record_id).unwrap();
25912 assert!(record.provider_session.is_none());
25913 assert_eq!(record.state, ManagedSessionState::IdentityPending);
25914 assert_eq!(record.active_session.as_ref(), Some(&address));
25915 }
25916
25917 #[test]
25918 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
25919 let secondary_root = temporary_workspace_root("identity-scope");
25920 std::fs::create_dir_all(&secondary_root).unwrap();
25921 let catalog = active_registry().unwrap();
25922 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25923 let primary = WorkspaceConfig::new(
25924 WorkspaceId::new("primary").unwrap(),
25925 std::env::current_dir().unwrap(),
25926 )
25927 .unwrap();
25928 let secondary = WorkspaceConfig::new(
25929 WorkspaceId::new("secondary").unwrap(),
25930 &secondary_root,
25931 )
25932 .unwrap();
25933 let shared = NodeShared::new(
25934 handle,
25935 "fixture-token".to_owned(),
25936 NodeId::new("node-1").unwrap(),
25937 vec![primary.clone(), secondary.clone()],
25938 vec![agent("claude")],
25939 );
25940 let identity = gate4agent_types::ProviderSessionIdentity {
25941 key: gate4agent_types::ProviderSessionKey::SessionId,
25942 id: "provider-session".to_owned(),
25943 transcript_path: None,
25944 };
25945 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
25946 shared
25947 .insert_record(ManagedSessionRecord {
25948 record_id: dormant_id.clone(),
25949 display_name: "dormant".to_owned(),
25950 provider: agent("claude"),
25951 mode: SessionMode::Pty,
25952 state: ManagedSessionState::Dormant,
25953 workspace_id: primary.workspace_id,
25954 canonical_root: opaque_windows_path(primary.canonical_root),
25955 provider_session: Some(identity.clone()),
25956 active_session: None,
25957 environment_profile: None,
25958 bundle: None,
25959 context_id: None,
25960 context: None,
25961 exported_context: None,
25962 task_binding: None,
25963 created_at_unix_ms: 1,
25964 updated_at_unix_ms: 1,
25965 last_error: None,
25966 })
25967 .unwrap();
25968 let address = SessionAddress {
25969 workspace_id: secondary.workspace_id.clone(),
25970 session: SessionKey {
25971 instance_id: AgentInstanceId(91),
25972 generation: SessionGeneration(1),
25973 },
25974 };
25975 let current_id = SessionRecordId::new("sr-current").unwrap();
25976 shared
25977 .insert_record(ManagedSessionRecord {
25978 record_id: current_id.clone(),
25979 display_name: "current".to_owned(),
25980 provider: agent("claude"),
25981 mode: SessionMode::Pty,
25982 state: ManagedSessionState::IdentityPending,
25983 workspace_id: secondary.workspace_id,
25984 canonical_root: opaque_windows_path(secondary.canonical_root),
25985 provider_session: None,
25986 active_session: Some(address.clone()),
25987 environment_profile: None,
25988 bundle: None,
25989 context_id: None,
25990 context: None,
25991 exported_context: None,
25992 task_binding: None,
25993 created_at_unix_ms: 2,
25994 updated_at_unix_ms: 2,
25995 last_error: None,
25996 })
25997 .unwrap();
25998 shared.bind_managed_session(
25999 &address,
26000 current_id.clone(),
26001 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26002 None,
26003 );
26004 let event = ControlEvent {
26005 sequence: 1,
26006 command_id: None,
26007 instance_id: address.session.instance_id,
26008 generation: address.session.generation,
26009 event: ControlEventKind::ProviderEvent {
26010 sequence: 1,
26011 source: gate4agent_types::ProviderSource {
26012 family: gate4agent_types::AdapterFamily::PtySemantic,
26013 binding: gate4agent_types::AdapterBinding::new(
26014 gate4agent_types::AdapterId::new("claude").unwrap(),
26015 "fixture/v1",
26016 gate4agent_types::AdapterVerification::SyntheticFixture,
26017 )
26018 .unwrap(),
26019 },
26020 source_sequence: 1,
26021 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26022 identity,
26023 },
26024 },
26025 };
26026 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
26027
26028 let binding = shared
26029 .session_bindings
26030 .lock()
26031 .unwrap_or_else(|poisoned| poisoned.into_inner())
26032 .get(&address.session.instance_id)
26033 .cloned()
26034 .unwrap();
26035 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
26036 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26037 let current = shared.record(¤t_id).unwrap();
26038 assert_eq!(current.state, ManagedSessionState::Unavailable);
26039 assert_eq!(current.active_session.as_ref(), Some(&address));
26040 assert_eq!(
26041 current.last_error.as_deref(),
26042 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26043 );
26044 std::fs::remove_dir_all(secondary_root).unwrap();
26045 }
26046
26047 #[tokio::test]
26048 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26049 let root = temporary_workspace_root("semantic-provider-session");
26050 std::fs::create_dir_all(&root).unwrap();
26051 let state_path = root.join("state-v1.json");
26052 let catalog = active_registry().unwrap();
26053 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26054 let workspace = WorkspaceConfig::new(
26055 WorkspaceId::new("primary").unwrap(),
26056 &root,
26057 )
26058 .unwrap();
26059 let node_id = NodeId::new("node-semantic-identity").unwrap();
26060 let shared = NodeShared::new_with_incarnation(
26061 handle,
26062 "fixture-token".to_owned(),
26063 node_id.clone(),
26064 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26065 vec![workspace.clone()],
26066 vec![agent("claude")],
26067 ProviderRuntimeStatuses::default(),
26068 None,
26069 Vec::new(),
26070 Vec::new(),
26071 SpawnProfileRegistry::default(),
26072 None,
26073 None,
26074 None,
26075 Some(state_path.clone()),
26076 Vec::new(),
26077 Vec::new(),
26078 Vec::new(),
26079 Vec::new(),
26080 None,
26081 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26082 );
26083 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26084 key: gate4agent_types::ProviderSessionKey::SessionId,
26085 id: "provider-session".to_owned(),
26086 transcript_path: None,
26087 };
26088 let durable_id = SessionRecordId::new("sr-durable").unwrap();
26089 shared
26090 .insert_record(ManagedSessionRecord {
26091 record_id: durable_id.clone(),
26092 display_name: "durable".to_owned(),
26093 provider: agent("claude"),
26094 mode: SessionMode::Pty,
26095 state: ManagedSessionState::Dormant,
26096 workspace_id: workspace.workspace_id.clone(),
26097 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26098 provider_session: Some(persisted_identity.clone()),
26099 active_session: None,
26100 environment_profile: None,
26101 bundle: None,
26102 context_id: None,
26103 context: None,
26104 exported_context: None,
26105 task_binding: None,
26106 created_at_unix_ms: 1,
26107 updated_at_unix_ms: 1,
26108 last_error: None,
26109 })
26110 .unwrap();
26111 let address = SessionAddress {
26112 workspace_id: workspace.workspace_id.clone(),
26113 session: SessionKey {
26114 instance_id: AgentInstanceId(92),
26115 generation: SessionGeneration(2),
26116 },
26117 };
26118 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26119 shared
26120 .insert_record(ManagedSessionRecord {
26121 record_id: pending_id.clone(),
26122 display_name: "pending".to_owned(),
26123 provider: agent("claude"),
26124 mode: SessionMode::Pty,
26125 state: ManagedSessionState::IdentityPending,
26126 workspace_id: workspace.workspace_id.clone(),
26127 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26128 provider_session: None,
26129 active_session: Some(address.clone()),
26130 environment_profile: None,
26131 bundle: None,
26132 context_id: None,
26133 context: None,
26134 exported_context: None,
26135 task_binding: None,
26136 created_at_unix_ms: 2,
26137 updated_at_unix_ms: 2,
26138 last_error: None,
26139 })
26140 .unwrap();
26141 shared.bind_managed_session(
26142 &address,
26143 pending_id.clone(),
26144 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26145 None,
26146 );
26147 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26148 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26149 ..persisted_identity
26150 };
26151 let event = ControlEvent {
26152 sequence: 1,
26153 command_id: None,
26154 instance_id: address.session.instance_id,
26155 generation: address.session.generation,
26156 event: ControlEventKind::ProviderEvent {
26157 sequence: 1,
26158 source: gate4agent_types::ProviderSource {
26159 family: gate4agent_types::AdapterFamily::PtySemantic,
26160 binding: gate4agent_types::AdapterBinding::new(
26161 gate4agent_types::AdapterId::new("claude").unwrap(),
26162 "fixture/v1",
26163 gate4agent_types::AdapterVerification::SyntheticFixture,
26164 )
26165 .unwrap(),
26166 },
26167 source_sequence: 1,
26168 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26169 identity: observed_identity.clone(),
26170 },
26171 },
26172 };
26173 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26174
26175 let snapshot = shared.snapshot();
26176 assert_eq!(snapshot.session_records.len(), 1);
26177 let live = &snapshot.session_records[0];
26178 assert_eq!(live.record_id, durable_id);
26179 assert_eq!(live.state, ManagedSessionState::Live);
26180 assert_eq!(live.active_session.as_ref(), Some(&address));
26181 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26182 let binding = shared
26183 .session_bindings
26184 .lock()
26185 .unwrap_or_else(|poisoned| poisoned.into_inner())
26186 .get(&address.session.instance_id)
26187 .cloned()
26188 .unwrap();
26189 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26190
26191 let mut duplicate = live.clone();
26192 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26193 duplicate.display_name = "duplicate".to_owned();
26194 duplicate.state = ManagedSessionState::Dormant;
26195 duplicate.active_session = None;
26196 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26197 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26198 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26199 assert_eq!(shared.snapshot().session_records.len(), 1);
26200
26201 let terminal_size = gate4agent_types::TerminalSize {
26202 rows: 24,
26203 columns: 80,
26204 };
26205 let (first_busy, second_busy) = tokio::join!(
26206 shared.resume_session_record(&durable_id, terminal_size, None),
26207 shared.resume_session_record(&durable_id, terminal_size, None),
26208 );
26209 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26210 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26211 assert_eq!(busy.message, "session-record-busy");
26212 }
26213
26214 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26215 assert_eq!(loaded.records.len(), 1);
26216 assert_eq!(loaded.records[0].record_id, durable_id);
26217 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26218 assert_eq!(
26219 loaded.records[0]
26220 .provider_session
26221 .as_ref()
26222 .unwrap()
26223 .transcript_path,
26224 None,
26225 );
26226 let catalog = active_registry().unwrap();
26227 let (reloaded_handle, _reloaded_runtime) =
26228 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26229 let reloaded = NodeShared::new_with_incarnation(
26230 reloaded_handle,
26231 "fixture-token".to_owned(),
26232 node_id,
26233 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26234 vec![workspace],
26235 vec![agent("claude")],
26236 ProviderRuntimeStatuses::default(),
26237 None,
26238 Vec::new(),
26239 Vec::new(),
26240 SpawnProfileRegistry::default(),
26241 None,
26242 None,
26243 None,
26244 None,
26245 loaded.records,
26246 loaded.managed_worktrees,
26247 loaded.managed_worktree_tombstones,
26248 loaded.materializations,
26249 loaded.warning,
26250 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26251 );
26252 assert_eq!(reloaded.snapshot().session_records.len(), 1);
26253 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26254 std::fs::remove_dir_all(root).unwrap();
26255 }
26256
26257 #[tokio::test]
26258 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26259 let secondary_root = temporary_workspace_root("workspace-restore");
26260 let other_root = temporary_workspace_root("workspace-conflict");
26261 std::fs::create_dir_all(&secondary_root).unwrap();
26262 std::fs::create_dir_all(&other_root).unwrap();
26263 let catalog = active_registry().unwrap();
26264 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26265 let primary = WorkspaceConfig::new(
26266 WorkspaceId::new("primary").unwrap(),
26267 std::env::current_dir().unwrap(),
26268 )
26269 .unwrap();
26270 let secondary = WorkspaceConfig::new(
26271 WorkspaceId::new("secondary").unwrap(),
26272 &secondary_root,
26273 )
26274 .unwrap();
26275 let shared = NodeShared::new(
26276 handle,
26277 "fixture-token".to_owned(),
26278 NodeId::new("node-1").unwrap(),
26279 vec![primary, secondary.clone()],
26280 vec![agent("claude")],
26281 );
26282 let record_id = SessionRecordId::new("sr-workspace").unwrap();
26283 shared
26284 .insert_record(ManagedSessionRecord {
26285 record_id: record_id.clone(),
26286 display_name: "workspace record".to_owned(),
26287 provider: agent("claude"),
26288 mode: SessionMode::Pty,
26289 state: ManagedSessionState::Dormant,
26290 workspace_id: secondary.workspace_id.clone(),
26291 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26292 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26293 key: gate4agent_types::ProviderSessionKey::SessionId,
26294 id: "provider-session".to_owned(),
26295 transcript_path: None,
26296 }),
26297 active_session: None,
26298 environment_profile: None,
26299 bundle: None,
26300 context_id: None,
26301 context: None,
26302 exported_context: None,
26303 task_binding: None,
26304 created_at_unix_ms: 1,
26305 updated_at_unix_ms: 1,
26306 last_error: None,
26307 })
26308 .unwrap();
26309 shared.unregister_workspace(&secondary.workspace_id).unwrap();
26310 let unavailable = shared.record(&record_id).unwrap();
26311 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26312 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26313
26314 let conflict = shared
26315 .register_workspace(
26316 secondary.workspace_id.clone(),
26317 other_root.to_string_lossy().into_owned(),
26318 )
26319 .await
26320 .unwrap_err();
26321 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26322 shared
26323 .register_workspace(
26324 secondary.workspace_id,
26325 secondary_root.to_string_lossy().into_owned(),
26326 )
26327 .await
26328 .unwrap();
26329 let restored = shared.record(&record_id).unwrap();
26330 assert_eq!(restored.state, ManagedSessionState::Dormant);
26331 assert!(restored.last_error.is_none());
26332 std::fs::remove_dir_all(secondary_root).unwrap();
26333 std::fs::remove_dir_all(other_root).unwrap();
26334 }
26335
26336 #[test]
26337 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26338 let error = validate_workspace_request_root(
26339 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26340 )
26341 .unwrap_err();
26342 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26343 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26344 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26345 }
26346
26347 #[test]
26348 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26349 let catalog = active_registry().unwrap();
26350 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26351 let workspace = WorkspaceConfig::new(
26352 WorkspaceId::new("primary").unwrap(),
26353 std::env::current_dir().unwrap(),
26354 )
26355 .unwrap();
26356 let shared = NodeShared::new(
26357 handle,
26358 "fixture-token".to_owned(),
26359 NodeId::new("node-1").unwrap(),
26360 vec![workspace],
26361 vec![agent("claude")],
26362 );
26363 let publishers = 4_u64;
26364 let events_per_publisher = 256_u64;
26365 let start = std::sync::Barrier::new(publishers as usize + 1);
26366 let finished = AtomicU64::new(0);
26367
26368 std::thread::scope(|scope| {
26369 for _ in 0..publishers {
26370 scope.spawn(|| {
26371 start.wait();
26372 for _ in 0..events_per_publisher {
26373 shared.publish(NodeEvent::ControllerChanged { controller: None });
26374 }
26375 finished.fetch_add(1, Ordering::Release);
26376 });
26377 }
26378 start.wait();
26379 while finished.load(Ordering::Acquire) != publishers {
26380 assert_resync_history_is_linearized(shared.resync(0));
26381 std::thread::yield_now();
26382 }
26383 });
26384 let response = shared.resync(0);
26385 assert_resync_history_is_linearized(response.clone());
26386 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26387 unreachable!("resync helper returned another response");
26388 };
26389 assert_eq!(event_sequence, publishers * events_per_publisher);
26390 assert_eq!(events.len() as u64, event_sequence);
26391 }
26392
26393 fn assert_resync_history_is_linearized(response: NodeResponse) {
26394 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26395 unreachable!("resync helper returned another response");
26396 };
26397 assert!(events.windows(2).all(|pair| {
26398 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26399 }));
26400 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26401 }
26402
26403 #[test]
26404 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26405 assert_eq!(
26406 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26407 r"C:\repo\worktree",
26408 );
26409 assert_eq!(
26410 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26411 r"\\server\share\repo",
26412 );
26413 assert!(validate_workspace_root(
26414 &WorkspaceId::new("network").unwrap(),
26415 r"\\server\share\repo",
26416 )
26417 .is_err());
26418 let workspace = WorkspaceConfig::new(
26419 WorkspaceId::new("primary").unwrap(),
26420 std::env::current_dir().unwrap(),
26421 )
26422 .unwrap();
26423 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26424 }
26425
26426 #[test]
26427 fn resume_rebinds_only_on_the_expected_authorized_generation() {
26428 let catalog = active_registry().unwrap();
26429 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26430 let workspace_id = WorkspaceId::new("primary").unwrap();
26431 let workspace = WorkspaceConfig::new(
26432 workspace_id.clone(),
26433 std::env::current_dir().unwrap(),
26434 )
26435 .unwrap();
26436 let shared = NodeShared::new(
26437 handle,
26438 "fixture-token".to_owned(),
26439 NodeId::new("node-1").unwrap(),
26440 vec![workspace],
26441 vec![agent("claude")],
26442 );
26443 let instance_id = AgentInstanceId(17);
26444 let original = SessionAddress {
26445 workspace_id,
26446 session: SessionKey {
26447 instance_id,
26448 generation: SessionGeneration(3),
26449 },
26450 };
26451 shared.bind_session(
26452 instance_id,
26453 original.workspace_id.clone(),
26454 original.session.generation,
26455 );
26456 shared
26457 .arm_resume(
26458 &original,
26459 CommandId(41),
26460 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26461 )
26462 .unwrap();
26463
26464 shared.publish_control(ControlEvent {
26465 sequence: 1,
26466 command_id: None,
26467 instance_id,
26468 generation: SessionGeneration(4),
26469 event: ControlEventKind::Running { process_id: None },
26470 });
26471 assert_eq!(
26472 shared.address_for(instance_id, SessionGeneration(3)),
26473 Some(original.clone()),
26474 );
26475 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26476
26477 shared.publish_control(ControlEvent {
26478 sequence: 2,
26479 command_id: Some(CommandId(42)),
26480 instance_id,
26481 generation: SessionGeneration(3),
26482 event: ControlEventKind::CommandRejected {
26483 message: "unrelated command".to_owned(),
26484 },
26485 });
26486
26487 shared.publish_control(ControlEvent {
26488 sequence: 3,
26489 command_id: None,
26490 instance_id,
26491 generation: SessionGeneration(4),
26492 event: ControlEventKind::ResumeAuthorized {
26493 session: gate4agent_types::ResumeSessionSummary {
26494 key: gate4agent_types::ProviderSessionKey::SessionId,
26495 id: "fixture-session".to_owned(),
26496 },
26497 },
26498 });
26499 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26500 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26501 assert_eq!(rebound.workspace_id, original.workspace_id);
26502 let history = shared
26503 .history
26504 .lock()
26505 .unwrap_or_else(|poisoned| poisoned.into_inner());
26506 assert!(history.events.iter().any(|envelope| matches!(
26507 &envelope.event,
26508 NodeEvent::Control { address, event }
26509 if address == &rebound
26510 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26511 )));
26512 }
26513
26514 #[test]
26515 fn all_node_pty_prompts_use_bracketed_paste_framing() {
26516 for agent in ["claude", "codex", "kimi"] {
26517 assert_eq!(
26518 prompt_framing(&AgentId::new(agent).unwrap()),
26519 PromptFraming::BracketedPaste
26520 );
26521 }
26522 }
26523
26524 fn generous_walk_deadline() -> Instant {
26525 Instant::now() + Duration::from_secs(30)
26526 }
26527
26528 #[test]
26529 fn workspace_tree_is_relative_and_skips_heavy_directories() {
26530 let root = temporary_workspace_root("tree");
26531 std::fs::create_dir_all(root.join("src")).unwrap();
26532 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26533 std::fs::create_dir_all(root.join("target/debug")).unwrap();
26534 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26535 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26536 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26537 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26543 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26544 std::fs::create_dir_all(root.join("target-notes")).unwrap();
26548 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26549
26550 let (entries, truncated, _budget) =
26551 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26552 assert!(!truncated);
26553 assert!(entries.iter().any(|entry| {
26554 entry.relative_path.as_utf8() == Some("src")
26555 && entry.kind == WorkspaceEntryKind::Directory
26556 }));
26557 assert!(entries.iter().any(|entry| {
26558 entry.relative_path.as_utf8() == Some("src/lib.rs")
26559 && entry.kind == WorkspaceEntryKind::File
26560 }));
26561 assert!(entries.iter().any(|entry| {
26562 entry.relative_path.as_utf8() == Some("target-notes")
26563 && entry.kind == WorkspaceEntryKind::Directory
26564 }));
26565 assert!(entries.iter().any(|entry| {
26566 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26567 && entry.kind == WorkspaceEntryKind::File
26568 }));
26569 assert!(entries.iter().all(|entry| {
26570 let path = entry.relative_path.as_utf8().unwrap();
26571 !path.starts_with(".git")
26572 && !(path == "target" || path.starts_with("target/"))
26573 && !(path == "target-a2" || path.starts_with("target-a2/"))
26574 && !path.starts_with("node_modules")
26575 }));
26576
26577 std::fs::remove_dir_all(&root).unwrap();
26578 }
26579
26580 #[test]
26581 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26582 let root = temporary_workspace_root("tree-entry-cap");
26583 std::fs::create_dir_all(&root).unwrap();
26584 for index in 0..20 {
26585 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26586 }
26587
26588 let (entries, truncated, budget) =
26589 collect_workspace_entries(&root, generous_walk_deadline(), 5);
26590 assert!(truncated);
26591 assert!(budget.entry_cap_exceeded);
26592 assert!(!budget.time_budget_exceeded);
26593 assert!(entries.len() <= 5);
26594
26595 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26596 .expect("an entry-cap-truncated walk must report a truncation marker");
26597 assert!(inspection_truncation.walk_entry_cap_exceeded);
26598 assert!(!inspection_truncation.walk_time_budget_exceeded);
26599 assert!(!inspection_truncation.git_time_budget_exceeded);
26600 assert!(inspection_truncation.entries_visited >= 5);
26601
26602 std::fs::remove_dir_all(&root).unwrap();
26603 }
26604
26605 #[test]
26606 fn workspace_walk_within_budget_reports_no_truncation() {
26607 let root = temporary_workspace_root("tree-no-truncation");
26608 std::fs::create_dir_all(&root).unwrap();
26609 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26610
26611 let (_entries, truncated, budget) =
26612 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26613 assert!(!truncated);
26614 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26615
26616 std::fs::remove_dir_all(&root).unwrap();
26617 }
26618
26619 #[test]
26620 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26621 let root = temporary_workspace_root("tree-prefix-siblings");
26622 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26623 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26624 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26625 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26626
26627 let (entries, truncated, _budget) =
26628 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26629 assert!(!truncated);
26630 let paths = entries
26631 .iter()
26632 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26633 .collect::<Vec<_>>();
26634 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26635 assert_eq!(
26636 paths,
26637 vec![
26638 "gate4agent-c2".to_owned(),
26639 "gate4agent-c2-client".to_owned(),
26640 "gate4agent-c2-client/lib.rs".to_owned(),
26641 "gate4agent-c2/src".to_owned(),
26642 "gate4agent-c2/src/lib.rs".to_owned(),
26643 ],
26644 );
26645
26646 std::fs::remove_dir_all(&root).unwrap();
26647 }
26648
26649 #[test]
26650 fn git_parsers_cap_status_and_commit_results() {
26651 let mut status_output = Vec::new();
26652 for index in 0..=GIT_STATUS_MAX_ENTRIES {
26653 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26654 }
26655 let mut snapshot = git_snapshot_for_parser();
26656 parse_git_status(&status_output, &mut snapshot);
26657 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26658 assert!(snapshot.truncated);
26659 assert_eq!(snapshot.status[0].index_status, " ");
26660 assert_eq!(snapshot.status[0].worktree_status, "M");
26661
26662 snapshot.truncated = false;
26663 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26664 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
26665 .collect::<String>();
26666 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
26667 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
26668 assert!(snapshot.truncated);
26669 }
26670
26671 #[test]
26672 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
26673 let mut snapshot = git_snapshot_for_parser();
26674 parse_git_status(
26675 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
26676 &mut snapshot,
26677 );
26678
26679 assert!(!snapshot.truncated);
26680 assert_eq!(snapshot.status.len(), 3);
26681 assert_eq!(snapshot.status[0].index_status, "R");
26682 assert_eq!(snapshot.status[0].worktree_status, " ");
26683 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
26684 assert_eq!(
26685 snapshot.status[0]
26686 .previous_path
26687 .as_ref()
26688 .and_then(RepositoryPath::as_utf8),
26689 Some("src/previous name.rs"),
26690 );
26691 assert_eq!(snapshot.status[1].index_status, " ");
26692 assert_eq!(snapshot.status[1].worktree_status, "C");
26693 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
26694 assert_eq!(
26695 snapshot.status[1]
26696 .previous_path
26697 .as_ref()
26698 .and_then(RepositoryPath::as_utf8),
26699 Some("src/original.rs"),
26700 );
26701 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
26702 assert!(snapshot.status[2].previous_path.is_none());
26703 }
26704
26705 #[test]
26706 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
26707 let mut snapshot = git_snapshot_for_parser();
26708 parse_git_status(
26709 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
26710 &mut snapshot,
26711 );
26712
26713 assert!(snapshot.truncated);
26714 assert_eq!(snapshot.status.len(), 1);
26715 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
26716 assert!(snapshot.status[0].previous_path.is_none());
26717 }
26718
26719 #[tokio::test]
26720 async fn observer_can_browse_host_directories_without_controller() {
26721 let root = temporary_workspace_root("host-directory-observer");
26722 std::fs::create_dir_all(root.join("directory-b")).unwrap();
26723 std::fs::create_dir_all(root.join("directory-a")).unwrap();
26724 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
26725 let workspace_id = WorkspaceId::new("primary").unwrap();
26726 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
26727 let catalog = active_registry().unwrap();
26728 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26729 let shared = NodeShared::new(
26730 handle,
26731 "fixture-token".to_owned(),
26732 NodeId::new("node-1").unwrap(),
26733 vec![workspace],
26734 vec![agent("claude")],
26735 );
26736 let request = NodeRequest::BrowseHostDirectories {
26737 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
26738 after: None,
26739 };
26740 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26741 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
26742 }));
26743 assert!(!request_uses_unnegotiated_capability(
26744 &request,
26745 &baseline_capabilities().unwrap(),
26746 ));
26747 assert!(request_uses_unnegotiated_capability(&request, &[]));
26748
26749 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
26750 .await
26751 .unwrap();
26752 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
26753 panic!("host directory browse returned another response");
26754 };
26755 assert_eq!(
26756 listing
26757 .entries
26758 .iter()
26759 .map(|entry| entry.display_name.as_str())
26760 .collect::<Vec<_>>(),
26761 vec!["directory-a", "directory-b"],
26762 );
26763 assert!(listing.entries.iter().all(|entry| !entry.is_link));
26764 assert!(!listing.incomplete);
26765 std::fs::remove_dir_all(root).unwrap();
26766 }
26767
26768 #[tokio::test]
26769 async fn observer_can_inspect_only_a_registered_workspace() {
26770 let root = temporary_workspace_root("observer");
26771 std::fs::create_dir_all(root.join("src")).unwrap();
26772 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
26773 let workspace_id = WorkspaceId::new("primary").unwrap();
26774 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26775 let catalog = active_registry().unwrap();
26776 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26777 let shared = NodeShared::new(
26778 handle,
26779 "fixture-token".to_owned(),
26780 NodeId::new("node-1").unwrap(),
26781 vec![workspace],
26782 vec![agent("claude")],
26783 );
26784
26785 let response = process_request_inner(
26786 &shared,
26787 77,
26788 ClientRole::Observer,
26789 NodeRequest::InspectWorkspace {
26790 workspace_id: workspace_id.clone(),
26791 },
26792 )
26793 .await
26794 .unwrap();
26795 let NodeResponse::WorkspaceInspected { inspection } = response else {
26796 panic!("workspace inspection returned another response");
26797 };
26798 assert_eq!(inspection.workspace_id, workspace_id);
26799 assert!(inspection.entries.iter().any(|entry| {
26800 entry.relative_path.as_utf8() == Some("src/main.rs")
26801 }));
26802
26803 let unknown = process_request_inner(
26804 &shared,
26805 77,
26806 ClientRole::Observer,
26807 NodeRequest::InspectWorkspace {
26808 workspace_id: WorkspaceId::new("unknown").unwrap(),
26809 },
26810 )
26811 .await
26812 .unwrap_err();
26813 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
26814 std::fs::remove_dir_all(&root).unwrap();
26815 }
26816
26817 #[test]
26818 fn context_pack_source_accepts_only_running_or_successful_exit() {
26819 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
26820 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
26821 exit_code: Some(0),
26822 }));
26823 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26824 exit_code: Some(1),
26825 }));
26826 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26827 exit_code: None,
26828 }));
26829 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
26830 message: "provider failed".to_owned(),
26831 }));
26832 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
26833 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
26834 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
26835 }
26836
26837 #[test]
26838 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
26839 let git = git_snapshot_for_parser();
26840 let first = ContextPackRepositoryObservation {
26841 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26842 git: git.clone(),
26843 files: Vec::new(),
26844 };
26845 let second = ContextPackRepositoryObservation {
26846 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
26847 git,
26848 files: Vec::new(),
26849 };
26850
26851 let error = stable_context_pack_repository(first, second).unwrap_err();
26852
26853 assert_eq!(
26854 error.code,
26855 NodeFailureCode::ContextPackMaterializationFailed,
26856 );
26857 }
26858
26859 #[test]
26860 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
26861 let git = git_snapshot_for_parser();
26862 let first = ContextPackRepositoryObservation {
26863 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26864 git: git.clone(),
26865 files: vec![ContextPackRepositoryFileSource::utf8(
26866 "README.md",
26867 "first\n".to_owned(),
26868 6,
26869 )],
26870 };
26871 let second = ContextPackRepositoryObservation {
26872 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26873 git,
26874 files: vec![ContextPackRepositoryFileSource::utf8(
26875 "README.md",
26876 "other\n".to_owned(),
26877 6,
26878 )],
26879 };
26880
26881 let error = stable_context_pack_repository(first, second).unwrap_err();
26882
26883 assert_eq!(
26884 error.code,
26885 NodeFailureCode::ContextPackMaterializationFailed,
26886 );
26887 }
26888
26889 #[tokio::test]
26890 async fn native_session_catalog_zero_active_session_fixture() {
26891 let workspace_root = temporary_workspace_root("native-catalog-workspace");
26892 let history_root = temporary_workspace_root("native-catalog-history");
26893 let outside_root = temporary_workspace_root("native-catalog-outside");
26894 std::fs::create_dir_all(&workspace_root).unwrap();
26895 std::fs::create_dir_all(&history_root).unwrap();
26896 std::fs::create_dir_all(&outside_root).unwrap();
26897 let transcript = |session_id: &str, cwd: &Path, title: &str| {
26898 [
26899 serde_json::json!({
26900 "type": "user",
26901 "sessionId": session_id,
26902 "cwd": cwd.to_str().unwrap(),
26903 "message": { "content": "raw user text must not cross the catalog wire" }
26904 }),
26905 serde_json::json!({
26906 "type": "assistant",
26907 "sessionId": session_id,
26908 "cwd": cwd.to_str().unwrap(),
26909 "message": { "model": "claude-sonnet", "content": "raw answer" }
26910 }),
26911 serde_json::json!({
26912 "type": "ai-title",
26913 "sessionId": session_id,
26914 "aiTitle": title
26915 }),
26916 ]
26917 .into_iter()
26918 .map(|record| record.to_string())
26919 .collect::<Vec<_>>()
26920 .join("\n")
26921 };
26922 std::fs::write(
26923 history_root.join("stable-session.jsonl"),
26924 transcript("stable-session", &workspace_root, "Workspace review"),
26925 )
26926 .unwrap();
26927 std::fs::write(
26928 history_root.join("outside-session.jsonl"),
26929 transcript("outside-session", &outside_root, "Outside review"),
26930 )
26931 .unwrap();
26932 let history = NativeHistoryConfig::new(vec![
26933 NativeHistoryRoot::new(
26934 gate4agent_types::AdapterId::new("claude-code").unwrap(),
26935 HistorySourceLayout::SingleNdjson,
26936 history_root.clone(),
26937 )
26938 .unwrap(),
26939 ])
26940 .unwrap();
26941 let catalog = active_registry().unwrap();
26942 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26943 assert!(handle.snapshot().sessions.is_empty());
26944 let workspace_id = WorkspaceId::new("primary").unwrap();
26945 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
26946 let mut shared = NodeShared::new(
26947 handle,
26948 "fixture-token".to_owned(),
26949 NodeId::new("node-native-catalog").unwrap(),
26950 vec![workspace],
26951 vec![agent("claude")],
26952 );
26953 shared.native_session_catalog = Some(Arc::new(Mutex::new(
26954 NativeSessionCatalogAuthority::new(history),
26955 )));
26956 let route = NativeSessionCatalogRoute::workspace(
26957 workspace_id.clone(),
26958 agent("claude"),
26959 );
26960 let request = NodeRequest::CatalogNativeSessions {
26961 route: route.clone(),
26962 limit: 2,
26963 };
26964 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
26965 .await
26966 .unwrap_err();
26967 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26968 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
26969 .await
26970 .unwrap();
26971 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
26972 panic!("native catalog returned a different response");
26973 };
26974 let summary = summary.expect("node must emit native session catalog summary metadata");
26975 assert_eq!(entries.len(), 1);
26976 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
26977 assert_eq!(entries[0].external_group, None);
26978 assert_eq!(entries[0].record_id, None);
26979 assert_eq!(entries[0].title, None);
26980 assert_eq!(entries[0].model, None);
26981 assert_eq!(entries[0].message_count, 0);
26982 let encoded_entries = serde_json::to_string(&entries).unwrap();
26983 assert!(!encoded_entries.contains("stable-session"));
26984 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
26985 let page_request = NodeRequest::PageNativeSessions {
26986 route: route.clone(),
26987 window: crate::protocol::NativeSessionCatalogWindow::Recent,
26988 catalog_revision: summary.catalog_revision,
26989 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26990 after_selection_id: None,
26991 limit: 2,
26992 };
26993 let observer = process_request_inner(
26994 &shared,
26995 7,
26996 ClientRole::Observer,
26997 page_request.clone(),
26998 )
26999 .await
27000 .unwrap_err();
27001 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27002 let page_response = process_request_inner(
27003 &shared,
27004 7,
27005 ClientRole::Operator,
27006 page_request.clone(),
27007 )
27008 .await
27009 .unwrap();
27010 assert!(matches!(
27011 page_response,
27012 NodeResponse::NativeSessionsPaged { page, .. }
27013 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27014 ));
27015 let stale = process_request_inner(
27016 &shared,
27017 7,
27018 ClientRole::Operator,
27019 NodeRequest::PageNativeSessions {
27020 route: route.clone(),
27021 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27022 catalog_revision: summary.catalog_revision.wrapping_add(1),
27023 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27024 after_selection_id: None,
27025 limit: 2,
27026 },
27027 )
27028 .await
27029 .unwrap_err();
27030 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27031 let selection = NativeSessionSelection {
27032 route: route.clone(),
27033 catalog_revision: summary.catalog_revision,
27034 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27035 selection_id: entries[0].selection_id.clone(),
27036 };
27037 let preview_response = process_request_inner(
27038 &shared,
27039 7,
27040 ClientRole::Operator,
27041 NodeRequest::PreviewNativeSession {
27042 selection: selection.clone(),
27043 message_limit: 1,
27044 },
27045 )
27046 .await
27047 .unwrap();
27048 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27049 panic!("native preview returned a different response");
27050 };
27051 assert_eq!(preview.messages.len(), 1);
27052 assert!(preview.message_count_exact);
27053 assert_eq!(preview.messages[0].text, "raw answer");
27054 assert!(preview.truncated);
27055
27056 let index_request = NodeRequest::IndexNativeSession {
27057 selection: selection.clone(),
27058 display_name: "Saved Claude".to_owned(),
27059 };
27060 let observer = process_request_inner(
27061 &shared,
27062 7,
27063 ClientRole::Observer,
27064 index_request.clone(),
27065 )
27066 .await
27067 .unwrap_err();
27068 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27069 let without_controller = process_request_inner(
27070 &shared,
27071 7,
27072 ClientRole::Operator,
27073 index_request.clone(),
27074 )
27075 .await
27076 .unwrap_err();
27077 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27078 let acquired = process_request_inner(
27079 &shared,
27080 7,
27081 ClientRole::Operator,
27082 NodeRequest::AcquireController { lease_ms: 5_000 },
27083 )
27084 .await
27085 .unwrap();
27086 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27087 let indexed = process_request_inner(
27088 &shared,
27089 7,
27090 ClientRole::Operator,
27091 index_request,
27092 )
27093 .await
27094 .unwrap();
27095 assert!(indexed.native_session_index_contract_is_valid());
27096 let NodeResponse::NativeSessionIndexed {
27097 selection: echoed_selection,
27098 record,
27099 } = indexed else {
27100 panic!("native session index returned a different response");
27101 };
27102 assert_eq!(echoed_selection, selection);
27103 let recataloged = process_request_inner(
27104 &shared,
27105 7,
27106 ClientRole::Operator,
27107 NodeRequest::CatalogNativeSessions {
27108 route,
27109 limit: 2,
27110 },
27111 )
27112 .await
27113 .unwrap();
27114 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27115 panic!("native catalog returned a different response after indexing");
27116 };
27117 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27118
27119 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27120 let external_catalog = process_request_inner(
27121 &shared,
27122 7,
27123 ClientRole::Operator,
27124 NodeRequest::CatalogNativeSessions {
27125 route: unregistered_route.clone(),
27126 limit: 2,
27127 },
27128 )
27129 .await
27130 .unwrap();
27131 let external_json = serde_json::to_string(&external_catalog).unwrap();
27132 assert!(!external_json.contains("outside-session"));
27133 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27134 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27135 assert!(!external_json.contains("\"session_id\""));
27136 assert!(!external_json.contains("\"cwd\""));
27137 assert!(!external_json.contains("\"path\""));
27138 let NodeResponse::NativeSessionsCataloged {
27139 route: echoed_route,
27140 entries: external_entries,
27141 summary: Some(external_summary),
27142 } = external_catalog
27143 else {
27144 panic!("unregistered native catalog returned a different response");
27145 };
27146 assert_eq!(echoed_route, unregistered_route);
27147 assert_eq!(external_entries.len(), 1);
27148 assert_eq!(external_entries[0].record_id, None);
27149 let external_group = external_entries[0]
27150 .external_group
27151 .as_ref()
27152 .expect("unregistered session must have an external group");
27153 assert_eq!(
27154 external_group.kind,
27155 gate4agent_types::NativeSessionExternalGroupKind::Project,
27156 );
27157 assert!(external_group.group_id.starts_with("external-"));
27158 assert_eq!(
27159 external_group.display_name,
27160 outside_root.file_name().unwrap().to_string_lossy(),
27161 );
27162 let external_selection = NativeSessionSelection {
27163 route: unregistered_route,
27164 catalog_revision: external_summary.catalog_revision,
27165 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27166 selection_id: external_entries[0].selection_id.clone(),
27167 };
27168 let external_preview = process_request_inner(
27169 &shared,
27170 7,
27171 ClientRole::Operator,
27172 NodeRequest::PreviewNativeSession {
27173 selection: external_selection.clone(),
27174 message_limit: 1,
27175 },
27176 )
27177 .await
27178 .unwrap();
27179 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27180 assert!(!external_preview_json.contains("outside-session"));
27181 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27182 assert!(!external_preview_json.contains("\"session_id\""));
27183 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27184 panic!("unregistered native preview returned a different response");
27185 };
27186 assert_eq!(preview.messages.len(), 1);
27187 let external_index = process_request_inner(
27188 &shared,
27189 7,
27190 ClientRole::Operator,
27191 NodeRequest::IndexNativeSession {
27192 selection: external_selection,
27193 display_name: "External Claude".to_owned(),
27194 },
27195 )
27196 .await
27197 .unwrap_err();
27198 assert_eq!(
27199 external_index.code,
27200 NodeFailureCode::WorkspaceRegistrationRequired,
27201 );
27202 let record_response = process_request_inner(
27203 &shared,
27204 7,
27205 ClientRole::Operator,
27206 NodeRequest::PreviewSessionRecord {
27207 record_id: record.record_id.clone(),
27208 message_limit: 2,
27209 },
27210 )
27211 .await
27212 .unwrap();
27213 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27214 panic!("record preview returned a different response");
27215 };
27216 assert_eq!(record_id, record.record_id);
27217 assert_eq!(preview.messages.len(), 2);
27218 let encoded = serde_json::to_string(&preview).unwrap();
27219 assert!(!encoded.contains("stable-session"));
27220 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27221 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27222 panic!("record preview history summary returned a different response");
27223 };
27224 let summaries = events
27225 .iter()
27226 .filter_map(|envelope| match &envelope.event {
27227 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27228 if record_id == &record.record_id =>
27229 {
27230 Some(*summary)
27231 }
27232 _ => None,
27233 })
27234 .collect::<Vec<_>>();
27235 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27236 let summary_json = serde_json::to_string(&summaries).unwrap();
27237 for private in [
27238 "stable-session",
27239 "raw user text must not cross the catalog wire",
27240 "raw answer",
27241 "Workspace review",
27242 "claude-sonnet",
27243 ] {
27244 assert!(!summary_json.contains(private), "{private}");
27245 }
27246 let identity = record.provider_session.clone().unwrap();
27247 assert_eq!(
27248 shared.revalidate_session_record_preview(&record, &identity),
27249 Ok(())
27250 );
27251 shared.remove_record_memory(&record.record_id);
27252 assert_eq!(
27253 shared
27254 .revalidate_session_record_preview(&record, &identity)
27255 .unwrap_err()
27256 .code,
27257 NodeFailureCode::UnknownSessionRecord
27258 );
27259 let released = process_request_inner(
27260 &shared,
27261 7,
27262 ClientRole::Operator,
27263 NodeRequest::ReleaseController,
27264 )
27265 .await
27266 .unwrap();
27267 assert!(matches!(released, NodeResponse::Controller { controller: None }));
27268 assert!(shared.controller_state().is_none());
27269 std::fs::remove_dir_all(workspace_root).unwrap();
27270 std::fs::remove_dir_all(history_root).unwrap();
27271 std::fs::remove_dir_all(outside_root).unwrap();
27272 }
27273
27274 #[tokio::test]
27275 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27276 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27277 std::fs::create_dir_all(&history_root).unwrap();
27278 let history = NativeHistoryConfig::new(vec![
27279 NativeHistoryRoot::new(
27280 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27281 HistorySourceLayout::SingleNdjson,
27282 history_root.clone(),
27283 )
27284 .unwrap(),
27285 ])
27286 .unwrap();
27287 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27288 let held = catalog.lock().unwrap();
27289 let result = timeout(
27290 Duration::from_secs(1),
27291 run_native_session_catalog_operation(
27292 Arc::clone(&catalog),
27293 "native session catalog",
27294 |_| Ok::<(), std::convert::Infallible>(()),
27295 ),
27296 )
27297 .await
27298 .expect("concurrent native catalog request must not wait for the operation deadline")
27299 .unwrap_err();
27300 assert_eq!(result.code, NodeFailureCode::BackendBusy);
27301 drop(held);
27302 std::fs::remove_dir_all(history_root).unwrap();
27303 }
27304
27305 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27306 let unique = std::time::SystemTime::now()
27307 .duration_since(std::time::UNIX_EPOCH)
27308 .unwrap()
27309 .as_nanos();
27310 std::env::temp_dir().join(format!(
27311 "gate4agent-node-{label}-{}-{unique}",
27312 std::process::id(),
27313 ))
27314 }
27315
27316 fn git_snapshot_for_parser() -> GitSnapshot {
27317 GitSnapshot {
27318 is_repository: true,
27319 branch: Some("main".to_owned()),
27320 status: Vec::new(),
27321 recent_commits: Vec::new(),
27322 worktrees: Vec::new(),
27323 managed_worktree: None,
27324 truncated: false,
27325 diagnostic: None,
27326 }
27327 }
27328
27329 #[tokio::test]
27330 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27331 let root = temporary_workspace_root("managed-git-scope");
27332 std::fs::create_dir_all(&root).unwrap();
27333 run_test_git(&root, &["init"]);
27334 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27335 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27336 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27337 run_test_git(&root, &["add", "seed.txt"]);
27338 run_test_git(&root, &["commit", "-m", "seed"]);
27339 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27340
27341 let workspace_id = WorkspaceId::new("managed-a").unwrap();
27342 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27343 let catalog = active_registry().unwrap();
27344 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27345 let shared = NodeShared::new(
27346 handle,
27347 "fixture-token".to_owned(),
27348 NodeId::new("node-1").unwrap(),
27349 vec![workspace],
27350 vec![agent("claude")],
27351 );
27352 let mut lease = ManagedWorktreeLeaseRecord {
27353 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27354 source_workspace_id: WorkspaceId::new("primary").unwrap(),
27355 workspace_id: workspace_id.clone(),
27356 profile_id: WorktreeProfileId::new("default").unwrap(),
27357 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27358 target_root: root.to_string_lossy().into_owned(),
27359 branch: "gate4agent/a".to_owned(),
27360 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27361 expected_head: None,
27362 retention: ManagedWorktreeRetention::Retain,
27363 state: ManagedWorktreeLeaseState::InUse,
27364 session_holders: vec![ManagedWorktreeSessionHolder {
27365 incarnation_id: shared.incarnation_id,
27366 instance_id: AgentInstanceId(7),
27367 generation: SessionGeneration(1),
27368 }],
27369 record_holders: Vec::new(),
27370 cleanup_failure: None,
27371 created_at_unix_ms: 1,
27372 updated_at_unix_ms: 1,
27373 };
27374 *shared
27375 .managed_worktrees
27376 .lock()
27377 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27378 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27379
27380 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27381 let scope = inspection.git.managed_worktree.unwrap();
27382 assert_eq!(scope.lease_id, lease.lease_id);
27383 assert_eq!(scope.branch, "gate4agent/a");
27384 assert_eq!(scope.active_session_count, 1);
27385
27386 lease.branch = "gate4agent/b".to_owned();
27387 *shared
27388 .managed_worktrees
27389 .lock()
27390 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27391 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27392 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27393 assert!(mismatch.git.managed_worktree.is_none());
27394 std::fs::remove_dir_all(root).unwrap();
27395 }
27396
27397 #[tokio::test]
27398 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27399 let root = temporary_workspace_root("workspace-file-write-controller");
27400 std::fs::create_dir_all(root.join("src")).unwrap();
27401 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27402 let workspace_id = WorkspaceId::new("primary").unwrap();
27403 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27404 let catalog = active_registry().unwrap();
27405 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27406 let shared = NodeShared::new(
27407 handle,
27408 "fixture-token".to_owned(),
27409 NodeId::new("node-1").unwrap(),
27410 vec![workspace],
27411 vec![agent("claude")],
27412 );
27413 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27414 let expected_revision = workspace_file_revision(b"old\n");
27415 let request = NodeRequest::WriteWorkspaceFile {
27416 workspace_id: workspace_id.clone(),
27417 path: path.clone(),
27418 expected_revision: expected_revision.clone(),
27419 text: "new\n".to_owned(),
27420 };
27421 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27422 .await
27423 .unwrap_err();
27424 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27425 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27426 .await
27427 .unwrap_err();
27428 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27429 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27430 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27431 let NodeResponse::WorkspaceFileWritten { file } = response else {
27432 panic!("workspace file write returned another response");
27433 };
27434 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27435 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27436 let stale = process_request_inner(
27437 &shared,
27438 77,
27439 ClientRole::Operator,
27440 NodeRequest::WriteWorkspaceFile {
27441 workspace_id,
27442 path,
27443 expected_revision,
27444 text: "lost update\n".to_owned(),
27445 },
27446 )
27447 .await
27448 .unwrap_err();
27449 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27450 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27451 std::fs::remove_dir_all(root).unwrap();
27452 }
27453
27454 #[tokio::test]
27455 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27456 let root = temporary_workspace_root("workspace-entry-create-controller");
27457 std::fs::create_dir_all(root.join("src")).unwrap();
27458 let workspace_id = WorkspaceId::new("primary").unwrap();
27459 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27460 let catalog = active_registry().unwrap();
27461 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27462 let shared = NodeShared::new(
27463 handle,
27464 "fixture-token".to_owned(),
27465 NodeId::new("node-1").unwrap(),
27466 vec![workspace],
27467 vec![agent("claude")],
27468 );
27469 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27470 let request = NodeRequest::CreateWorkspaceFile {
27471 workspace_id: workspace_id.clone(),
27472 path: path.clone(),
27473 };
27474 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27475 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27476 }));
27477 assert!(!request_uses_unnegotiated_capability(
27478 &request,
27479 &baseline_capabilities().unwrap(),
27480 ));
27481 assert!(request_uses_unnegotiated_capability(&request, &[]));
27482 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27483 .await
27484 .unwrap_err();
27485 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27486 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27487 .await
27488 .unwrap_err();
27489 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27490 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27491
27492 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27493 .await
27494 .unwrap();
27495 let NodeResponse::WorkspaceFileCreated { file } = response else {
27496 panic!("workspace file create returned another response");
27497 };
27498 assert_eq!(file.workspace_id, workspace_id);
27499 assert_eq!(file.path, path);
27500 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27501 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27502
27503 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27504 .await
27505 .unwrap_err();
27506 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27507
27508 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27509 let response = process_request_inner(
27510 &shared,
27511 77,
27512 ClientRole::Operator,
27513 NodeRequest::CreateWorkspaceDirectory {
27514 workspace_id: workspace_id.clone(),
27515 path: directory_path.clone(),
27516 },
27517 )
27518 .await
27519 .unwrap();
27520 assert_eq!(
27521 response,
27522 NodeResponse::WorkspaceDirectoryCreated {
27523 workspace_id: workspace_id.clone(),
27524 entry: WorkspaceEntry {
27525 relative_path: directory_path,
27526 kind: WorkspaceEntryKind::Directory,
27527 },
27528 },
27529 );
27530 assert!(root.join("src/nested").is_dir());
27531
27532 let missing_parent = process_request_inner(
27533 &shared,
27534 77,
27535 ClientRole::Operator,
27536 NodeRequest::CreateWorkspaceFile {
27537 workspace_id: workspace_id.clone(),
27538 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27539 },
27540 )
27541 .await
27542 .unwrap_err();
27543 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27544 let non_directory_parent = process_request_inner(
27545 &shared,
27546 77,
27547 ClientRole::Operator,
27548 NodeRequest::CreateWorkspaceFile {
27549 workspace_id,
27550 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27551 },
27552 )
27553 .await
27554 .unwrap_err();
27555 assert_eq!(
27556 non_directory_parent.code,
27557 NodeFailureCode::RepositoryParentNotDirectory,
27558 );
27559 std::fs::remove_dir_all(root).unwrap();
27560 }
27561
27562 #[tokio::test]
27563 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27564 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27565 std::fs::create_dir_all(root.join("src")).unwrap();
27566 #[cfg(windows)]
27567 let root = std::fs::canonicalize(root).unwrap();
27568 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27569 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27570 let barrier = Arc::new(std::sync::Barrier::new(2));
27571 let done = Arc::new(AtomicBool::new(false));
27572 let task_commit_state = Arc::clone(&commit_state);
27573 let task_barrier = Arc::clone(&barrier);
27574 let task_done = Arc::clone(&done);
27575 let task_root = root.clone();
27576 let task = tokio::task::spawn_blocking(move || {
27577 task_barrier.wait();
27578 let result = create_workspace_file_on_disk(
27579 &task_root,
27580 &path,
27581 &task_commit_state,
27582 );
27583 task_done.store(true, Ordering::Release);
27584 result
27585 });
27586
27587 let error = settle_workspace_entry_create(
27588 task,
27589 Arc::clone(&commit_state),
27590 Duration::from_millis(10),
27591 "fixture deadline",
27592 "fixture task failed",
27593 )
27594 .await
27595 .unwrap_err();
27596 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27597 assert_eq!(
27598 commit_state.load(Ordering::Acquire),
27599 WORKSPACE_ENTRY_CREATE_CANCELED,
27600 );
27601
27602 barrier.wait();
27603 timeout(Duration::from_secs(1), async {
27604 while !done.load(Ordering::Acquire) {
27605 tokio::task::yield_now().await;
27606 }
27607 })
27608 .await
27609 .expect("canceled blocking create did not settle");
27610 assert!(!root.join("src/late.rs").exists());
27611 std::fs::remove_dir_all(root).unwrap();
27612 }
27613}
27614
27615#[cfg(test)]
27616mod provisional_spawn_tests {
27617 use super::*;
27618 use std::sync::Arc;
27619
27620 fn shared() -> NodeShared {
27621 let catalog = active_registry().unwrap();
27622 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27623 let workspace = WorkspaceConfig::new(
27624 WorkspaceId::new("primary").unwrap(),
27625 std::env::current_dir().unwrap(),
27626 )
27627 .unwrap()
27628 .with_worktree_service_mode(WorktreeServiceMode::Manual);
27629 NodeShared::new(
27630 handle,
27631 "fixture-token".to_owned(),
27632 NodeId::new("node-provisional-test").unwrap(),
27633 vec![workspace],
27634 vec![AgentId::new("claude").unwrap()],
27635 )
27636 }
27637
27638 #[test]
27641 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
27642 let shared = shared();
27643 let ws = WorkspaceId::new("primary").unwrap();
27644 let mut ids = Vec::with_capacity(50);
27645 for i in 1u64..=50 {
27646 let id = AgentInstanceId(i);
27647 ids.push(id);
27648 let address = SessionAddress {
27649 workspace_id: ws.clone(),
27650 session: SessionKey {
27651 instance_id: id,
27652 generation: SessionGeneration(1),
27653 },
27654 };
27655 shared.mark_spawn_provisional(address);
27656 }
27657 assert_eq!(
27658 shared
27659 .provisional_spawns
27660 .lock()
27661 .unwrap_or_else(|p| p.into_inner())
27662 .len(),
27663 50
27664 );
27665 for id in ids.iter().step_by(2) {
27666 shared.sessionize_spawn(*id);
27667 }
27668 assert_eq!(
27669 shared
27670 .provisional_spawns
27671 .lock()
27672 .unwrap_or_else(|p| p.into_inner())
27673 .len(),
27674 25
27675 );
27676 assert!(shared.take_due_provisional_spawns().is_empty());
27677 {
27678 let mut map = shared
27679 .provisional_spawns
27680 .lock()
27681 .unwrap_or_else(|p| p.into_inner());
27682 let past = Instant::now() - Duration::from_secs(30);
27683 for (_, (_, at)) in map.iter_mut() {
27684 *at = past;
27685 }
27686 }
27687 let due = shared.take_due_provisional_spawns();
27688 assert_eq!(due.len(), 25);
27689 let due_ids: std::collections::BTreeSet<_> =
27690 due.iter().map(|a| a.session.instance_id.0).collect();
27691 let expected: std::collections::BTreeSet<_> =
27692 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
27693 assert_eq!(due_ids, expected);
27694 assert!(shared
27695 .provisional_spawns
27696 .lock()
27697 .unwrap_or_else(|p| p.into_inner())
27698 .is_empty());
27699 }
27700
27701 #[test]
27704 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
27705 let shared = Arc::new(shared());
27706 let ws = WorkspaceId::new("primary").unwrap();
27707 let mut handles = Vec::new();
27708 for i in 1u64..=20 {
27709 let shared = Arc::clone(&shared);
27710 let ws = ws.clone();
27711 handles.push(std::thread::spawn(move || {
27712 let id = AgentInstanceId(i);
27713 let address = SessionAddress {
27714 workspace_id: ws,
27715 session: SessionKey {
27716 instance_id: id,
27717 generation: SessionGeneration(1),
27718 },
27719 };
27720 shared.mark_spawn_provisional(address);
27721 id
27722 }));
27723 }
27724 let mut ids = Vec::new();
27725 for h in handles {
27726 ids.push(h.join().unwrap());
27727 }
27728 ids.sort_by_key(|id| id.0);
27729 assert_eq!(ids.len(), 20);
27730 ids.dedup();
27731 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
27732 assert_eq!(
27733 shared
27734 .provisional_spawns
27735 .lock()
27736 .unwrap_or_else(|p| p.into_inner())
27737 .len(),
27738 20
27739 );
27740 shared.sessionize_spawn(AgentInstanceId(7));
27741 let left: Vec<_> = shared
27742 .provisional_spawns
27743 .lock()
27744 .unwrap_or_else(|p| p.into_inner())
27745 .keys()
27746 .map(|id| id.0)
27747 .collect();
27748 assert!(!left.contains(&7));
27749 assert_eq!(left.len(), 19);
27750 }
27751}
27752