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 crate::session_signals::feed(
11005 binding.workspace_id.as_str(),
11006 session.instance_id.0,
11007 session.agent_id.as_str(),
11008 &frame.contents,
11009 );
11010 Some((
11011 SessionAddress {
11012 workspace_id: binding.workspace_id.clone(),
11013 session: SessionKey {
11014 instance_id: session.instance_id,
11015 generation: session.generation,
11016 },
11017 },
11018 frame,
11019 ))
11020 })
11021 .collect();
11022 drop(bindings);
11023 self.publish_terminal_frame_candidates(candidates);
11024 }
11025
11026 fn publish_terminal_frame_candidates(
11027 &self,
11028 candidates: Vec<(SessionAddress, TerminalFrame)>,
11029 ) {
11030 let mut watermarks = self
11031 .terminal_frame_watermarks
11032 .lock()
11033 .unwrap_or_else(|poisoned| poisoned.into_inner());
11034 let mut events = Vec::with_capacity(candidates.len());
11035 for (address, frame) in candidates {
11036 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11037 let advanced = watermarks
11038 .get(&address.session.instance_id)
11039 .map_or(true, |(current_address, sequence)| {
11040 current_address != &address || frame.sequence > *sequence
11041 });
11042 if !advanced {
11043 continue;
11044 }
11045 if is_first_frame {
11046 if self.controller_state().is_none() {
11060 tracing::warn!(
11061 node_id = %self.node_id,
11062 workspace_id = %address.workspace_id,
11063 session = ?address.session,
11064 terminal_size = ?frame.size,
11065 "session producing output with no controller attached",
11066 );
11067 } else {
11068 tracing::info!(
11069 node_id = %self.node_id,
11070 workspace_id = %address.workspace_id,
11071 session = ?address.session,
11072 terminal_size = ?frame.size,
11073 "session produced its first output",
11074 );
11075 }
11076 }
11077 watermarks.insert(
11078 address.session.instance_id,
11079 (address.clone(), frame.sequence),
11080 );
11081 if let Some((at, reported)) = self
11082 .pty_input_marks
11083 .lock()
11084 .unwrap_or_else(|poisoned| poisoned.into_inner())
11085 .get_mut(&address.session.instance_id)
11086 {
11087 if !*reported && at.elapsed() < Duration::from_secs(5) {
11089 *reported = true;
11090 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");
11091 }
11092 }
11093 events.push(NodeEvent::TerminalFrame { address, frame });
11094 }
11095 drop(watermarks);
11096 if events.is_empty() {
11097 return;
11098 }
11099 let mut history = self
11100 .history
11101 .lock()
11102 .unwrap_or_else(|poisoned| poisoned.into_inner());
11103 let mut envelopes = Vec::with_capacity(events.len());
11104 for event in events {
11105 let sequence = history
11106 .last_sequence
11107 .checked_add(1)
11108 .expect("node event sequence exhausted");
11109 history.last_sequence = sequence;
11110 envelopes.push(NodeEventEnvelope { sequence, event });
11111 }
11112 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11113 drop(history);
11114 }
11115
11116 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11125 let mut history = self
11126 .history
11127 .lock()
11128 .unwrap_or_else(|poisoned| poisoned.into_inner());
11129 let sequence = history
11130 .last_sequence
11131 .checked_add(1)
11132 .expect("node event sequence exhausted");
11133 history.last_sequence = sequence;
11134 drop(history);
11135 tracing::debug!(
11143 source_sequence = chunk.source_sequence,
11144 kind = agent_stream_chunk_kind_label(&chunk.kind),
11145 envelope_sequence = sequence,
11146 "publishing agent stream chunk",
11147 );
11148 let envelope = NodeEventEnvelope {
11149 sequence,
11150 event: NodeEvent::AgentStream { address, chunk },
11151 };
11152 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11153 }
11154
11155 fn current_sequence(&self) -> u64 {
11156 self.history
11157 .lock()
11158 .unwrap_or_else(|poisoned| poisoned.into_inner())
11159 .last_sequence
11160 }
11161
11162 fn controller_state(&self) -> Option<ControllerState> {
11163 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11164 let lease = controller.as_ref().copied()?;
11165 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11166 if remaining.is_zero() {
11167 *controller = None;
11168 return None;
11169 }
11170 Some(ControllerState {
11171 connection_id: lease.connection_id,
11172 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11173 })
11174 }
11175
11176 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11177 if role != ClientRole::Operator {
11178 tracing::warn!(
11179 node_id = %self.node_id,
11180 connection_id,
11181 "controller lease rejected: observer connections cannot acquire control",
11182 );
11183 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11184 }
11185 let lease_ms = if lease_ms == 0 {
11186 DEFAULT_CONTROLLER_LEASE_MS
11187 } else {
11188 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11189 };
11190 let now = Instant::now();
11191 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11192 if let Some(active) = controller.as_ref() {
11193 if active.connection_id != connection_id && active.expires_at > now {
11194 tracing::warn!(
11195 node_id = %self.node_id,
11196 connection_id,
11197 holder_connection_id = active.connection_id,
11198 "controller lease rejected: another operator holds it",
11199 );
11200 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11201 }
11202 }
11203 let is_new_attach = match controller.as_ref() {
11210 Some(active) => active.connection_id != connection_id,
11211 None => true,
11212 };
11213 *controller = Some(ControllerLease {
11214 connection_id,
11215 expires_at: now + Duration::from_millis(lease_ms),
11216 });
11217 drop(controller);
11218 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11219 if is_new_attach {
11220 tracing::info!(
11221 node_id = %self.node_id,
11222 connection_id,
11223 lease_ms,
11224 "controller lease acquired",
11225 );
11226 } else {
11227 tracing::debug!(
11228 node_id = %self.node_id,
11229 connection_id,
11230 lease_ms,
11231 "controller lease renewed",
11232 );
11233 }
11234 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11235 Ok(state)
11236 }
11237
11238 fn release_controller(&self, connection_id: u64) -> bool {
11239 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11240 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11241 if released {
11242 *controller = None;
11243 }
11244 drop(controller);
11245 if released {
11246 tracing::info!(
11247 node_id = %self.node_id,
11248 connection_id,
11249 "controller lease released",
11250 );
11251 self.publish(NodeEvent::ControllerChanged { controller: None });
11252 }
11253 released
11254 }
11255
11256 fn mark_spawn_provisional(&self, address: SessionAddress) {
11262 let id = address.session.instance_id;
11263 self.provisional_spawns
11264 .lock()
11265 .unwrap_or_else(|poisoned| poisoned.into_inner())
11266 .insert(id, (address, Instant::now()));
11267 tracing::info!(
11268 node_id = %self.node_id,
11269 instance_id = id.0,
11270 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11271 "draft spawn; waiting for HQ view",
11272 );
11273 }
11274
11275 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11276 let removed = self
11277 .provisional_spawns
11278 .lock()
11279 .unwrap_or_else(|poisoned| poisoned.into_inner())
11280 .remove(&instance_id);
11281 if removed.is_some() {
11282 tracing::info!(
11283 node_id = %self.node_id,
11284 instance_id = instance_id.0,
11285 "draft spawn claimed by HQ view",
11286 );
11287 }
11288 }
11289
11290 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11291 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11292 let mut map = self
11293 .provisional_spawns
11294 .lock()
11295 .unwrap_or_else(|poisoned| poisoned.into_inner());
11296 let due: Vec<AgentInstanceId> = map
11297 .iter()
11298 .filter(|(_, (_, at))| at.elapsed() >= grace)
11299 .map(|(id, _)| *id)
11300 .collect();
11301 due.into_iter()
11302 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11303 .collect()
11304 }
11305
11306 async fn reap_provisional_spawn(&self, address: SessionAddress) {
11307 let instance_id = address.session.instance_id;
11308 tracing::warn!(
11309 node_id = %self.node_id,
11310 instance_id = instance_id.0,
11311 workspace = %address.workspace_id,
11312 reason = "attach_never_arrived",
11313 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11314 "draft spawn never viewed; stopping PTY",
11315 );
11316 let _ = self.dispatch(ControlCommand::Stop {
11317 instance_id,
11318 force: true,
11319 });
11320 let _ = self.remove_session(&address).await;
11321 }
11322
11323 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11324 if role != ClientRole::Operator {
11325 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11326 }
11327 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11328 let now = Instant::now();
11329 match controller.as_ref().copied() {
11330 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11331 Some(active) if active.expires_at <= now => {
11332 *controller = None;
11333 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11334 }
11335 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11336 }
11337 }
11338
11339 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11340 if !self
11341 .workspaces
11342 .read()
11343 .unwrap_or_else(|poisoned| poisoned.into_inner())
11344 .contains_key(&address.workspace_id)
11345 {
11346 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11347 }
11348 let binding = self
11349 .session_bindings
11350 .lock()
11351 .unwrap_or_else(|poisoned| poisoned.into_inner())
11352 .get(&address.session.instance_id)
11353 .cloned()
11354 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11355 if binding.workspace_id != address.workspace_id {
11356 return Err(failure(
11357 NodeFailureCode::SessionWorkspaceMismatch,
11358 "session belongs to another workspace",
11359 ));
11360 }
11361 if binding.generation != address.session.generation {
11362 return Err(failure(
11363 NodeFailureCode::StaleGeneration,
11364 &format!(
11365 "stale session generation: expected {}, received {}",
11366 binding.generation.0,
11367 address.session.generation.0,
11368 ),
11369 ));
11370 }
11371 let snapshot = self.handle.snapshot();
11372 let Some(current) = snapshot
11373 .sessions
11374 .iter()
11375 .find(|item| item.instance_id == address.session.instance_id)
11376 else {
11377 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11378 };
11379 if current.generation != address.session.generation {
11380 return Err(failure(
11381 NodeFailureCode::StaleGeneration,
11382 &format!(
11383 "stale session generation: expected {}, received {}",
11384 current.generation.0,
11385 address.session.generation.0,
11386 ),
11387 ));
11388 }
11389 Ok(current.agent_id.clone())
11390 }
11391
11392 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11393 self.dispatch_envelope(self.prepare_command(command))
11394 }
11395
11396 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11397 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11398 CommandEnvelope {
11399 id: CommandId(id),
11400 command,
11401 }
11402 }
11403
11404 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11405 let command_id = envelope.id;
11406 self.handle
11407 .dispatch(envelope)
11408 .map_err(|error| match error {
11409 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11410 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11411 })?;
11412 Ok(command_id)
11413 }
11414
11415 fn record_rejected_command(
11424 &self,
11425 command_id: CommandId,
11426 message: String,
11427 unsupported_transport: bool,
11428 ) {
11429 let now = Instant::now();
11430 let mut reasons = self.rejected_command_reasons.lock()
11431 .unwrap_or_else(|poisoned| poisoned.into_inner());
11432 reasons.retain(|_, reason| reason.expires_at > now);
11433 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11434 if let Some(&oldest) = reasons.keys().next() {
11435 reasons.remove(&oldest);
11436 }
11437 }
11438 reasons.insert(
11439 command_id,
11440 RejectedCommandReason {
11441 message,
11442 unsupported_transport,
11443 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11444 },
11445 );
11446 }
11447
11448 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11455 let now = Instant::now();
11456 let mut reasons = self.rejected_command_reasons.lock()
11457 .unwrap_or_else(|poisoned| poisoned.into_inner());
11458 match reasons.remove(&command_id) {
11459 Some(reason) if reason.expires_at > now => Some(reason),
11460 _ => None,
11461 }
11462 }
11463
11464 async fn dispatch_bounded(
11465 &self,
11466 command: ControlCommand,
11467 timeout_duration: Duration,
11468 ) -> Result<CommandId, NodeFailure> {
11469 let deadline = Instant::now() + timeout_duration;
11470 loop {
11471 match self.dispatch(command.clone()) {
11472 Ok(command_id) => return Ok(command_id),
11473 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11474 sleep(Duration::from_millis(2)).await;
11475 }
11476 Err(error) => return Err(error),
11477 }
11478 }
11479 }
11480
11481 fn internal_session_snapshot(
11482 &self,
11483 address: &SessionAddress,
11484 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11485 self.validate_address(address)?;
11486 self.handle
11487 .snapshot()
11488 .sessions
11489 .clone()
11490 .into_iter()
11491 .find(|session| {
11492 session.instance_id == address.session.instance_id
11493 && session.generation == address.session.generation
11494 })
11495 .ok_or_else(|| {
11496 failure(
11497 NodeFailureCode::UnknownSession,
11498 "session snapshot is unavailable",
11499 )
11500 })
11501 }
11502
11503 fn require_acp_session(
11511 &self,
11512 address: &SessionAddress,
11513 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11514 let snapshot = self.internal_session_snapshot(address)?;
11515 if snapshot.transport != TransportKind::Acp {
11516 return Err(failure(
11517 NodeFailureCode::UnsupportedTransport,
11518 "this verb requires a session spawned over the ACP transport",
11519 ));
11520 }
11521 Ok(snapshot)
11522 }
11523
11524 async fn dispatch_history_bounded(
11525 &self,
11526 address: &SessionAddress,
11527 command: ControlCommand,
11528 operation: HistoryOperation,
11529 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11530 self.validate_address(address)?;
11531 let started_after = self.current_sequence();
11532 let command_id = self
11533 .dispatch_bounded(
11534 command,
11535 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11536 )
11537 .await?;
11538 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11539 let mut scan_after = started_after;
11540 let mut accepted_after = None;
11541 loop {
11542 let events = self
11543 .history
11544 .lock()
11545 .unwrap_or_else(|poisoned| poisoned.into_inner())
11546 .events
11547 .iter()
11548 .filter(|envelope| envelope.sequence > scan_after)
11549 .cloned()
11550 .collect::<Vec<_>>();
11551 for envelope in events {
11552 scan_after = scan_after.max(envelope.sequence);
11553 let NodeEvent::Control {
11554 address: event_address,
11555 event,
11556 } = envelope.event
11557 else {
11558 continue;
11559 };
11560 if event_address != *address {
11561 continue;
11562 }
11563 if event.command_id == Some(command_id) {
11564 match &event.event {
11565 ControlEventKind::CommandRejected { .. } => {
11566 return Err(failure(
11567 NodeFailureCode::InvalidRequest,
11568 "history request was rejected",
11569 ));
11570 }
11571 ControlEventKind::HistoryRequested {
11572 operation: requested,
11573 ..
11574 } if requested == &operation => {
11575 accepted_after = Some(envelope.sequence);
11576 continue;
11577 }
11578 _ => {}
11579 }
11580 }
11581 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11582 match (&operation, &event.event) {
11583 (
11584 HistoryOperation::Discover { .. },
11585 ControlEventKind::HistoryDiscovered { .. },
11586 )
11587 | (
11588 HistoryOperation::Load { .. },
11589 ControlEventKind::HistoryLoaded { .. },
11590 ) => return self.internal_session_snapshot(address),
11591 (_, ControlEventKind::HistoryFailed { .. }) => {
11592 return Err(failure(
11593 NodeFailureCode::BackendOperationFailed,
11594 "history operation failed",
11595 ));
11596 }
11597 _ => {}
11598 }
11599 }
11600 }
11601 if Instant::now() >= deadline {
11602 return Err(failure(
11603 NodeFailureCode::BackendBusy,
11604 if accepted_after.is_some() {
11605 "history operation did not settle before the bounded deadline"
11606 } else {
11607 "history operation was not accepted before the bounded deadline"
11608 },
11609 ));
11610 }
11611 sleep(Duration::from_millis(2)).await;
11612 }
11613 }
11614
11615 async fn discover_history(
11616 &self,
11617 address: &SessionAddress,
11618 limit: u16,
11619 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11620 let query = HistoryQuery {
11621 working_directory: Some(self.workspace_root(&address.workspace_id)?),
11622 limit,
11623 };
11624 query
11625 .validate()
11626 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11627 let snapshot = self
11628 .dispatch_history_bounded(
11629 address,
11630 ControlCommand::DiscoverHistory {
11631 instance_id: address.session.instance_id,
11632 query: query.clone(),
11633 },
11634 HistoryOperation::Discover { query },
11635 )
11636 .await?;
11637 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11638 return Err(failure(
11639 NodeFailureCode::BackendOperationFailed,
11640 "history discovery settled into an invalid state",
11641 ));
11642 }
11643 Ok(snapshot.history.candidates)
11644 }
11645
11646 async fn load_history(
11647 &self,
11648 address: &SessionAddress,
11649 candidate_id: String,
11650 ) -> Result<HistorySessionRecord, NodeFailure> {
11651 validate_candidate_id(&candidate_id)
11652 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11653 let snapshot = self
11654 .dispatch_history_bounded(
11655 address,
11656 ControlCommand::LoadHistory {
11657 instance_id: address.session.instance_id,
11658 candidate_id: candidate_id.clone(),
11659 },
11660 HistoryOperation::Load {
11661 candidate_id: candidate_id.clone(),
11662 },
11663 )
11664 .await?;
11665 if snapshot.history.pending.is_some()
11666 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11667 {
11668 return Err(failure(
11669 NodeFailureCode::BackendOperationFailed,
11670 "history load settled into an invalid state",
11671 ));
11672 }
11673 snapshot.history.loaded.ok_or_else(|| {
11674 failure(
11675 NodeFailureCode::BackendOperationFailed,
11676 "history load produced no bounded session",
11677 )
11678 })
11679 }
11680
11681 async fn export_context_pack_for_session_record(
11688 &self,
11689 record_id: &SessionRecordId,
11690 session: &SessionAddress,
11691 allow_clean_detachment: bool,
11692 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11693 let outcome = self
11699 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11700 .await;
11701 if let Err(failure) = &outcome {
11702 tracing::warn!(
11703 record = %record_id,
11704 code = ?failure.code,
11705 reason = %failure.message,
11706 "session-record context-pack export refused",
11707 );
11708 }
11709 outcome
11710 }
11711
11712 async fn export_context_pack_for_session_record_inner(
11713 &self,
11714 record_id: &SessionRecordId,
11715 session: &SessionAddress,
11716 allow_clean_detachment: bool,
11717 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11718 let step = |name: &'static str| move |failure: NodeFailure| {
11723 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11724 failure
11725 };
11726 let record = self.record(record_id).map_err(step("record-lookup"))?;
11727 let identity = match record.provider_session.clone() {
11743 Some(identity) => {
11744 if identity.key != ProviderSessionKey::SessionId {
11745 return Err(step("provider-identity-key")(failure(
11746 NodeFailureCode::BackendOperationFailed,
11747 "managed session provider identity cannot bind history exactly",
11748 )));
11749 }
11750 Some(identity)
11751 }
11752 None if record.state == ManagedSessionState::IdentityPending => {
11753 return Err(step("provider-session-identity")(failure(
11754 NodeFailureCode::BackendOperationFailed,
11755 "managed session has not yet observed its provider session identity",
11756 )));
11757 }
11758 None => None,
11759 };
11760 self.revalidate_session_record_context_export(
11761 &record,
11762 identity.as_ref(),
11763 session,
11764 allow_clean_detachment,
11765 ).map_err(step("revalidate-before-discovery"))?;
11766 let candidates = self
11767 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11768 .await
11769 .map_err(step("discover-history"))?;
11770 let candidate_id = select_history_candidate(
11775 &candidates,
11776 identity.as_ref().map(|identity| identity.id.as_str()),
11777 ).map_err(step("unique-candidate"))?;
11778 let loaded = self.load_history(session, candidate_id.clone()).await
11779 .map_err(step("load-history"))?;
11780 if let Some(identity) = identity.as_ref() {
11781 if loaded.session_id != identity.id {
11782 return Err(failure(
11783 NodeFailureCode::SessionRecordConflict,
11784 "loaded history identity does not match the managed session record",
11785 ));
11786 }
11787 }
11788 self.revalidate_session_record_context_export(
11789 &record,
11790 identity.as_ref(),
11791 session,
11792 allow_clean_detachment,
11793 )?;
11794 self.revalidate_loaded_history(session, &candidate_id, &loaded)
11795 .map_err(step("revalidate-loaded-history"))?;
11796 let pack = self.materialize_context_pack(session).await
11797 .map_err(step("materialize-context-pack"))?;
11798 self.commit_context_pack_for_session_record(
11799 &record,
11800 identity.as_ref(),
11801 session,
11802 &candidate_id,
11803 &loaded,
11804 pack,
11805 allow_clean_detachment,
11806 )
11807 }
11808
11809 async fn export_context_pack(
11810 &self,
11811 address: &SessionAddress,
11812 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11813 let pack = self.materialize_context_pack(address).await?;
11814 self.commit_context_pack(pack)
11815 }
11816
11817 async fn materialize_context_pack(
11818 &self,
11819 address: &SessionAddress,
11820 ) -> Result<NodeContextPack, NodeFailure> {
11821 let snapshot = self.internal_session_snapshot(address)?;
11822 if !context_pack_source_status_is_usable(&snapshot.status) {
11823 return Err(failure(
11824 NodeFailureCode::InvalidRequest,
11825 "context export requires a running or successfully exited source session",
11826 ));
11827 }
11828 if snapshot.history.pending.is_some()
11829 || snapshot.history.loaded_candidate_id.is_none()
11830 {
11831 return Err(failure(
11832 NodeFailureCode::InvalidRequest,
11833 "context export requires settled loaded history",
11834 ));
11835 }
11836 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11837 failure(
11838 NodeFailureCode::InvalidRequest,
11839 "context export requires settled loaded history",
11840 )
11841 })?;
11842 let source_provider = snapshot.agent_id.clone();
11843 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11844 let repository = self
11845 .context_pack_repository(&address.workspace_id)
11846 .await?;
11847 let current = self.internal_session_snapshot(address)?;
11848 if !context_pack_source_status_is_usable(¤t.status)
11849 || current.agent_id != source_provider
11850 || current.history.pending.is_some()
11851 || current.history.loaded_candidate_id != loaded_candidate_id
11852 || current.history.loaded.as_ref() != Some(&loaded)
11853 {
11854 return Err(failure(
11855 NodeFailureCode::ContextPackMaterializationFailed,
11856 "context source changed during bounded repository capture",
11857 ));
11858 }
11859 NodeContextPack::export_with_repository(
11860 ContextPackLineageReceipt {
11861 source_node_id: self.node_id.clone(),
11862 source_session: address.clone(),
11863 source_provider,
11864 },
11865 &loaded,
11866 Some(repository),
11867 )
11868 .map_err(|_| {
11869 failure(
11870 NodeFailureCode::ContextPackMaterializationFailed,
11871 "bounded context pack export failed",
11872 )
11873 })
11874 }
11875
11876 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
11882 let mut store = self
11883 .context_pack_store
11884 .lock()
11885 .unwrap_or_else(|poisoned| poisoned.into_inner());
11886 let Some(store) = store.as_mut() else {
11887 return Ok(());
11888 };
11889 store.commit(pack).map_err(|_| {
11890 failure(
11891 NodeFailureCode::ContextPackMaterializationFailed,
11892 "durable context pack commit failed",
11893 )
11894 })
11895 }
11896
11897 fn commit_context_pack(
11898 &self,
11899 pack: NodeContextPack,
11900 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11901 self.commit_context_pack_durably(&pack)?;
11902 self.context_catalog
11903 .write()
11904 .unwrap_or_else(|poisoned| poisoned.into_inner())
11905 .insert(pack)
11906 .map_err(|_| {
11907 failure(
11908 NodeFailureCode::ContextPackMaterializationFailed,
11909 "context pack catalog is full",
11910 )
11911 })
11912 }
11913
11914 fn commit_context_pack_for_session_record(
11915 &self,
11916 expected: &ManagedSessionRecord,
11917 identity: Option<&ProviderSessionIdentity>,
11918 session: &SessionAddress,
11919 candidate_id: &str,
11920 loaded: &HistorySessionRecord,
11921 pack: NodeContextPack,
11922 allow_clean_detachment: bool,
11923 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11924 let current_root = self.workspace_root(&expected.workspace_id)?;
11925 let _transaction = self
11926 .state_transaction
11927 .lock()
11928 .unwrap_or_else(|poisoned| poisoned.into_inner());
11929 let records = self
11930 .session_records
11931 .lock()
11932 .unwrap_or_else(|poisoned| poisoned.into_inner());
11933 let current = records.records.get(&expected.record_id).ok_or_else(|| {
11934 failure(
11935 NodeFailureCode::SessionRecordConflict,
11936 "managed session record disappeared during context export",
11937 )
11938 })?;
11939 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
11940 return Err(failure(
11941 NodeFailureCode::SessionRecordConflict,
11942 "managed session record changed during context export",
11943 ));
11944 }
11945 let bindings = self
11946 .session_bindings
11947 .lock()
11948 .unwrap_or_else(|poisoned| poisoned.into_inner());
11949 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
11950 failure(
11951 NodeFailureCode::SessionRecordConflict,
11952 "managed session runtime binding disappeared during context export",
11953 )
11954 })?;
11955 let runtime = self
11956 .handle
11957 .snapshot()
11958 .sessions
11959 .iter()
11960 .find(|snapshot| {
11961 snapshot.instance_id == session.session.instance_id
11962 && snapshot.generation == session.session.generation
11963 })
11964 .cloned()
11965 .ok_or_else(|| {
11966 failure(
11967 NodeFailureCode::SessionRecordConflict,
11968 "managed session runtime disappeared during context export",
11969 )
11970 })?;
11971 let receipt = pack.receipt();
11972 if !session_record_context_export_binding_is_exact(
11973 expected,
11974 current,
11975 identity,
11976 session,
11977 binding,
11978 &runtime.agent_id,
11979 ¤t_root,
11980 allow_clean_detachment,
11981 ) || !session_record_context_export_source_is_usable(
11982 current,
11983 &runtime.status,
11984 allow_clean_detachment,
11985 ) || runtime.history.pending.is_some()
11986 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
11987 || runtime.history.loaded.as_ref() != Some(loaded)
11988 || receipt.lineage.source_node_id != self.node_id
11989 || receipt.lineage.source_session != *session
11990 || receipt.lineage.source_provider != current.provider
11991 || receipt.source_message_count != loaded.message_count
11992 {
11993 return Err(failure(
11994 NodeFailureCode::SessionRecordConflict,
11995 "managed session source changed before context catalog commit",
11996 ));
11997 }
11998 self.commit_context_pack_durably(&pack)?;
11999 self.context_catalog
12000 .write()
12001 .unwrap_or_else(|poisoned| poisoned.into_inner())
12002 .insert(pack)
12003 .map_err(|_| {
12004 failure(
12005 NodeFailureCode::ContextPackMaterializationFailed,
12006 "context pack catalog is full",
12007 )
12008 })
12009 }
12010
12011 async fn context_pack_repository(
12012 &self,
12013 workspace_id: &WorkspaceId,
12014 ) -> Result<ContextPackRepository, NodeFailure> {
12015 let first = self
12016 .context_pack_repository_observation(workspace_id)
12017 .await?;
12018 let second = self
12019 .context_pack_repository_observation(workspace_id)
12020 .await?;
12021 stable_context_pack_repository(first, second)
12022 }
12023
12024 async fn context_pack_repository_observation(
12025 &self,
12026 workspace_id: &WorkspaceId,
12027 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12028 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12029 let head = if git.is_repository {
12030 let root = self.workspace_root(workspace_id)?;
12031 match resolve_base_commit_with_timeout(
12032 &root,
12033 "HEAD",
12034 GIT_COMMAND_TIMEOUT_MS,
12035 )
12036 .await
12037 {
12038 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12039 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12040 ContextPackRepositoryHead::Unborn
12041 }
12042 Err(_) => {
12043 return Err(failure(
12044 NodeFailureCode::ContextPackMaterializationFailed,
12045 "context pack HEAD observation failed",
12046 ));
12047 }
12048 }
12049 } else {
12050 ContextPackRepositoryHead::NotRepository
12051 };
12052 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12053 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12054 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12055 failure(
12056 NodeFailureCode::InvalidRepositoryPath,
12057 "context pack selected file path is invalid",
12058 )
12059 })?;
12060 match self.read_workspace_file(workspace_id.clone(), path).await {
12061 Ok(WorkspaceFileRead {
12062 content: WorkspaceFileContent::Utf8 { text, byte_len },
12063 ..
12064 }) => files.push(ContextPackRepositoryFileSource::utf8(
12065 selected_path,
12066 text,
12067 byte_len,
12068 )),
12069 Ok(WorkspaceFileRead {
12070 content: WorkspaceFileContent::NonUtf8 { byte_len },
12071 ..
12072 }) => files.push(ContextPackRepositoryFileSource::skipped(
12073 selected_path,
12074 ContextPackSelectedFileSkipReason::NonUtf8,
12075 Some(byte_len),
12076 )),
12077 Ok(WorkspaceFileRead {
12078 content: WorkspaceFileContent::TooLarge { .. },
12079 ..
12080 }) => files.push(ContextPackRepositoryFileSource::skipped(
12081 selected_path,
12082 ContextPackSelectedFileSkipReason::TooLarge,
12083 None,
12084 )),
12085 Err(error) => {
12086 let reason = if matches!(
12087 error.code,
12088 NodeFailureCode::InvalidRepositoryPath
12089 | NodeFailureCode::RepositoryFileNotRegular
12090 | NodeFailureCode::RepositoryPathUnsafe
12091 ) {
12092 ContextPackSelectedFileSkipReason::Unsafe
12093 } else {
12094 ContextPackSelectedFileSkipReason::Unavailable
12095 };
12096 files.push(ContextPackRepositoryFileSource::skipped(
12097 selected_path,
12098 reason,
12099 None,
12100 ));
12101 }
12102 }
12103 }
12104 Ok(ContextPackRepositoryObservation { head, git, files })
12105 }
12106
12107 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12108 let referenced_by_binding = self
12109 .session_bindings
12110 .lock()
12111 .unwrap_or_else(|poisoned| poisoned.into_inner())
12112 .values()
12113 .any(|binding| {
12114 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12115 });
12116 let referenced_by_record = self
12117 .session_records
12118 .lock()
12119 .unwrap_or_else(|poisoned| poisoned.into_inner())
12120 .records
12121 .values()
12122 .any(|record| record.context_id.as_ref() == Some(context_id));
12123 let referenced_by_materialization = self
12124 .materializations
12125 .lock()
12126 .unwrap_or_else(|poisoned| poisoned.into_inner())
12127 .values()
12128 .any(|ownership| {
12129 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12130 });
12131 if referenced_by_binding
12132 || referenced_by_record
12133 || referenced_by_materialization
12134 {
12135 return Err(failure(
12136 NodeFailureCode::ContextPackBusy,
12137 "context pack is still referenced",
12138 ));
12139 }
12140 let mut catalog = self.context_catalog
12141 .write()
12142 .unwrap_or_else(|poisoned| poisoned.into_inner());
12143 if catalog.get(context_id).is_none() {
12144 return Err(failure(
12145 NodeFailureCode::UnknownContextPack,
12146 "context pack is unavailable",
12147 ));
12148 }
12149 self.spawn_idempotency
12150 .lock()
12151 .unwrap_or_else(|poisoned| poisoned.into_inner())
12152 .entries
12153 .retain(|_, entry| !entry.value.references_context(context_id));
12154 let removed = catalog.remove(context_id);
12155 debug_assert!(removed.is_some(), "validated context pack remains present");
12156 Ok(())
12157 }
12158
12159 async fn dispatch_input_bounded(
12160 &self,
12161 address: &SessionAddress,
12162 action: InputAction,
12163 ) -> Result<(), NodeFailure> {
12164 let started_after = self.current_sequence();
12165 self.pty_input_marks
12166 .lock()
12167 .unwrap_or_else(|poisoned| poisoned.into_inner())
12168 .insert(address.session.instance_id, (Instant::now(), false));
12169 self.pty_input_wake.notify_one();
12170 let command_id = self
12171 .dispatch_bounded(
12172 ControlCommand::SendInput {
12173 instance_id: address.session.instance_id,
12174 action,
12175 },
12176 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12177 )
12178 .await?;
12179 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12183 let mut scan_after = started_after;
12184 let mut accepted_after = None;
12185 loop {
12186 let events = self
12187 .history
12188 .lock()
12189 .unwrap_or_else(|poisoned| poisoned.into_inner())
12190 .events
12191 .iter()
12192 .filter(|envelope| envelope.sequence > scan_after)
12193 .cloned()
12194 .collect::<Vec<_>>();
12195 for envelope in events {
12196 scan_after = scan_after.max(envelope.sequence);
12197 let NodeEvent::Control {
12198 address: event_address,
12199 event,
12200 } = envelope.event
12201 else {
12202 continue;
12203 };
12204 if event_address != *address {
12205 continue;
12206 }
12207 if event.command_id == Some(command_id) {
12208 match &event.event {
12209 ControlEventKind::CommandRejected { message } => {
12210 return Err(failure(NodeFailureCode::InvalidRequest, message));
12211 }
12212 ControlEventKind::InputRequested { .. } => {
12213 accepted_after = Some(envelope.sequence);
12214 continue;
12215 }
12216 _ => {}
12217 }
12218 }
12219 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12220 match &event.event {
12221 ControlEventKind::InputCompleted { .. } => return Ok(()),
12222 ControlEventKind::InputFailed { message, .. } => {
12223 return Err(failure(
12224 NodeFailureCode::BackendOperationFailed,
12225 message,
12226 ));
12227 }
12228 _ => {}
12229 }
12230 }
12231 }
12232 if Instant::now() >= deadline {
12233 return Err(failure(
12234 NodeFailureCode::BackendBusy,
12235 if accepted_after.is_some() {
12236 "input was accepted but did not settle before the bounded deadline"
12237 } else {
12238 "input was not accepted before the bounded deadline"
12239 },
12240 ));
12241 }
12242 sleep(Duration::from_millis(2)).await;
12243 }
12244 }
12245
12246 async fn dispatch_resize_bounded(
12247 &self,
12248 address: &SessionAddress,
12249 size: gate4agent_types::TerminalSize,
12250 ) -> Result<(), NodeFailure> {
12251 let started_after = self.current_sequence();
12252 let command_id = self
12253 .dispatch_bounded(
12254 ControlCommand::Resize {
12255 instance_id: address.session.instance_id,
12256 size,
12257 },
12258 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12259 )
12260 .await?;
12261 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12262 let mut scan_after = started_after;
12263 let mut accepted_after = None;
12264 loop {
12265 let events = self
12266 .history
12267 .lock()
12268 .unwrap_or_else(|poisoned| poisoned.into_inner())
12269 .events
12270 .iter()
12271 .filter(|envelope| envelope.sequence > scan_after)
12272 .cloned()
12273 .collect::<Vec<_>>();
12274 for envelope in events {
12275 scan_after = scan_after.max(envelope.sequence);
12276 let NodeEvent::Control {
12277 address: event_address,
12278 event,
12279 } = envelope.event
12280 else {
12281 continue;
12282 };
12283 if event_address != *address {
12284 continue;
12285 }
12286 if event.command_id == Some(command_id) {
12287 match &event.event {
12288 ControlEventKind::CommandRejected { message } => {
12289 return Err(failure(NodeFailureCode::InvalidRequest, message));
12290 }
12291 ControlEventKind::ResizeRequested {
12292 size: requested_size,
12293 ..
12294 } if *requested_size == size => {
12295 accepted_after = Some(envelope.sequence);
12296 continue;
12297 }
12298 _ => {}
12299 }
12300 }
12301 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12302 match &event.event {
12303 ControlEventKind::Resized { size: settled_size }
12304 if *settled_size == size =>
12305 {
12306 return Ok(());
12307 }
12308 ControlEventKind::ResizeFailed { message } => {
12309 return Err(failure(
12310 NodeFailureCode::BackendOperationFailed,
12311 message,
12312 ));
12313 }
12314 _ => {}
12315 }
12316 }
12317 }
12318 if Instant::now() >= deadline {
12319 return Err(failure(
12320 NodeFailureCode::BackendBusy,
12321 if accepted_after.is_some() {
12322 "resize was accepted but did not settle before the bounded deadline"
12323 } else {
12324 "resize was not accepted before the bounded deadline"
12325 },
12326 ));
12327 }
12328 sleep(Duration::from_millis(2)).await;
12329 }
12330 }
12331
12332 async fn spawn_session(
12333 &self,
12334 workspace_id: WorkspaceId,
12335 provider: AgentId,
12336 mode: SessionMode,
12337 terminal_size: gate4agent_types::TerminalSize,
12338 initial_prompt: Option<String>,
12339 ) -> Result<SessionAddress, NodeFailure> {
12340 if self.managed_worktrees.lock()
12341 .unwrap_or_else(|poisoned| poisoned.into_inner())
12342 .lease_for_workspace(&workspace_id)
12343 .is_some()
12344 {
12345 return Err(failure(
12346 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12347 "managed worktree targets may only be spawned through their lease request",
12348 ));
12349 }
12350 self
12351 .spawn_session_with_deadline(
12352 workspace_id,
12353 provider,
12354 mode,
12355 terminal_size,
12356 initial_prompt,
12357 None,
12358 None,
12359 None,
12360 None,
12361 None,
12362 SpawnRecordPolicy::ProviderIdentityOnly,
12363 None,
12364 &[],
12365 None,
12366 ApprovalLevel::default(),
12372 None,
12373 None,
12374 )
12375 .await
12376 .map(|(session, _runtime_policy)| session)
12377 }
12378
12379 async fn spawn_session_with_deadline(
12380 &self,
12381 workspace_id: WorkspaceId,
12382 provider: AgentId,
12383 mode: SessionMode,
12384 terminal_size: gate4agent_types::TerminalSize,
12385 initial_prompt: Option<String>,
12386 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12387 bundle: Option<ResolvedBundleReceipt>,
12388 context: Option<ResolvedContextPackReceipt>,
12389 managed_authority: Option<ManagedWorktreeLeaseId>,
12390 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12391 record_policy: SpawnRecordPolicy,
12392 deadline: Option<Instant>,
12393 required_capabilities: &[ProviderRuntimeCapability],
12394 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12395 approval_level: ApprovalLevel,
12396 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12397 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12403 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12404 let reserved_lease = self.managed_worktrees.lock()
12405 .unwrap_or_else(|poisoned| poisoned.into_inner())
12406 .lease_for_workspace(&workspace_id);
12407 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12408 (Some(expected), Some(provided)) if expected == provided => {}
12409 (None, None) => {}
12410 _ => {
12411 return Err(failure(
12412 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12413 "spawn lacks exact managed worktree lease authority",
12414 ));
12415 }
12416 }
12417 self.ensure_workspace_environment_profile_compatible(
12421 &workspace_id,
12422 environment_profile.as_ref(),
12423 )?;
12424 let runtime_requirement = match (mode, initial_prompt.is_some()) {
12425 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12426 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12427 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12428 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12429 };
12430 let transport = match mode {
12436 SessionMode::Pty => TransportKind::Pty,
12437 SessionMode::Inline => TransportKind::Pipe,
12438 SessionMode::Acp => TransportKind::Acp,
12439 };
12440 let pipe_transport = self.provider_supports_pipe_transport(&provider);
12441 let acp_transport = self.provider_supports_acp_transport(&provider);
12442 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12443 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12444 NodeFailureCode::UnsupportedSpawnCapability,
12445 "pre-admitted provider runtime no longer satisfies spawn requirements",
12446 ))?;
12447 runtime_policy
12448 } else if let Some(deadline) = deadline {
12449 let remaining = spawn_deadline_remaining(deadline)?;
12450 timeout(
12451 remaining,
12452 self.admit_provider_runtime(&provider, runtime_requirement),
12453 )
12454 .await
12455 .map_err(|_| {
12456 failure(
12457 NodeFailureCode::SpawnDeadlineExceeded,
12458 "provider admission exceeded the spawn deadline",
12459 )
12460 })??
12461 } else {
12462 self.admit_provider_runtime(&provider, runtime_requirement).await?
12463 };
12464 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12465 let runtime_policy =
12477 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12478 if required_capabilities
12479 .iter()
12480 .any(|capability| !runtime_policy.admits(*capability))
12481 {
12482 return Err(failure(
12483 NodeFailureCode::UnsupportedSpawnCapability,
12484 "provider runtime does not admit a required spawn capability",
12485 ));
12486 }
12487 if let Some(deadline) = deadline {
12488 spawn_deadline_remaining(deadline)?;
12489 }
12490 self.ensure_binding_capacity()?;
12491 let working_directory = self.workspace_root(&workspace_id)?;
12492 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12493 let session = SessionKey {
12494 instance_id,
12495 generation: SessionGeneration(1),
12496 };
12497 let address = SessionAddress {
12498 workspace_id: workspace_id.clone(),
12499 session,
12500 };
12501 tracing::info!(
12509 node_id = %self.node_id,
12510 workspace_id = %workspace_id,
12511 provider = %provider,
12512 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12513 session = ?address.session,
12514 "session spawn request accepted",
12515 );
12516 let mut prepared_spawn = SpawnPreparer::prepare(
12517 self,
12518 SpawnPrepareRequest {
12519 instance_id,
12520 address: &address,
12521 provider: &provider,
12522 mode,
12523 environment_profile: environment_profile.as_ref(),
12524 bundle: bundle.as_ref(),
12525 context: context.as_ref(),
12526 managed_authority: managed_authority.as_ref(),
12527 harness_mcp,
12528 approval_level,
12529 network_allowlist: network_allowlist.as_ref(),
12530 browser_profile_id,
12531 },
12532 )?;
12533 let materialization_id = prepared_spawn.materialization_id.take();
12534 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12535 let record_id = self.bind_spawn_session_with_materialization(
12536 &address,
12537 provider.clone(),
12538 mode,
12539 runtime_policy,
12540 record_policy,
12541 environment_profile,
12542 bundle,
12543 context,
12544 materialization_id,
12545 )?;
12546 prepared_spawn.retain_session_guards();
12547 let agent_id = provider;
12551 let register_command_id = match self.dispatch_bounded(
12552 ControlCommand::Register {
12553 instance_id,
12554 agent_id,
12555 transport,
12556 },
12557 dispatch_timeout,
12558 )
12559 .await {
12560 Ok(command_id) => command_id,
12561 Err(error) => {
12562 if let Some(binding) = self.remove_binding(&address) {
12563 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12564 }
12565 if let Some(record_id) = record_id.as_ref() {
12566 self.discard_record(record_id)?;
12567 }
12568 return Err(spawn_dispatch_error(error, deadline));
12569 }
12570 };
12571 let start_timeout = match spawn_dispatch_timeout(deadline) {
12572 Ok(timeout) => timeout,
12573 Err(error) => {
12574 let recovery = self
12575 .rollback_spawn(
12576 &address,
12577 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12578 )
12579 .await;
12580 return match recovery {
12581 Ok(()) => {
12582 if let Some(record_id) = record_id.as_ref() {
12583 self.discard_record(record_id)?;
12584 }
12585 Err(error)
12586 }
12587 Err(recovery_error) => {
12588 if let Some(record_id) = record_id.as_ref() {
12589 self.mark_record_error(
12590 record_id,
12591 "spawn deadline elapsed and registration recovery failed",
12592 )?;
12593 }
12594 Err(recovery_error)
12595 }
12596 };
12597 }
12598 };
12599 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12600 if let Err(recovery_error) = self
12601 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12602 .await
12603 {
12604 return Err(recovery_error);
12605 }
12606 if let Some(record_id) = record_id.as_ref() {
12607 self.discard_record(record_id)?;
12608 }
12609 return Err(start_error);
12610 }
12611 let start_result = self
12612 .dispatch_bounded(
12613 ControlCommand::Start {
12614 instance_id,
12615 runtime_policy,
12616 request: StartRequest {
12617 working_directory,
12618 terminal_size,
12619 initial_prompt,
12620 session_options: None,
12621 approval_level,
12622 },
12623 },
12624 start_timeout,
12625 )
12626 .await;
12627 let start_command_id = match start_result {
12628 Ok(command_id) => command_id,
12629 Err(start_error) => {
12630 let start_error = spawn_dispatch_error(start_error, deadline);
12631 let recovery = self
12632 .rollback_spawn(
12633 &address,
12634 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12635 )
12636 .await;
12637 return match recovery {
12638 Ok(()) => {
12639 if let Some(record_id) = record_id.as_ref() {
12640 self.discard_record(record_id)?;
12641 }
12642 Err(start_error)
12643 }
12644 Err(recovery_error) => {
12645 let diagnostic = format!(
12646 "start failed and registration recovery failed: {}",
12647 recovery_error.message,
12648 );
12649 if let Some(record_id) = record_id.as_ref() {
12650 self.mark_record_error(record_id, &diagnostic)?;
12651 }
12652 Err(failure(recovery_error.code, &diagnostic))
12653 }
12654 };
12655 }
12656 };
12657
12658 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12659 loop {
12660 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12669 .or_else(|| self.take_rejected_command_reason(start_command_id))
12670 {
12671 self.rollback_spawn(
12672 &address,
12673 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12674 ).await.map_err(|error| {
12675 failure(
12676 error.code,
12677 &format!(
12678 "spawn was rejected ({}) and registration recovery failed: {}",
12679 reason.message, error.message
12680 ),
12681 )
12682 })?;
12683 if let Some(record_id) = record_id.as_ref() {
12684 self.discard_record(record_id)?;
12685 }
12686 let code = if reason.unsupported_transport {
12687 NodeFailureCode::UnsupportedTransport
12688 } else {
12689 NodeFailureCode::BackendOperationFailed
12690 };
12691 return Err(failure(code, &reason.message));
12692 }
12693 if let Some(current) = self
12694 .handle
12695 .snapshot()
12696 .sessions
12697 .iter()
12698 .find(|current| current.instance_id == instance_id)
12699 {
12700 if current.generation != session.generation {
12701 let divergence = failure(
12702 NodeFailureCode::StaleGeneration,
12703 "spawn generation diverged",
12704 );
12705 return match self
12706 .rollback_spawn(
12707 &address,
12708 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12709 )
12710 .await
12711 {
12712 Ok(()) => {
12713 if let Some(record_id) = record_id.as_ref() {
12714 self.discard_record(record_id)?;
12715 }
12716 Err(divergence)
12717 }
12718 Err(recovery_error) => {
12719 if let Some(record_id) = record_id.as_ref() {
12720 self.mark_record_error(
12721 record_id,
12722 "spawn generation diverged and recovery failed",
12723 )?;
12724 }
12725 Err(recovery_error)
12726 }
12727 };
12728 }
12729 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12730 prepared_spawn.retain_launch_guards();
12731 return Ok((address, runtime_policy));
12732 }
12733 }
12734 if Instant::now() >= commit_deadline {
12735 self.rollback_spawn(
12736 &address,
12737 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12738 ).await.map_err(|error| {
12739 failure(
12740 error.code,
12741 &format!(
12742 "start did not commit and registration recovery failed: {}",
12743 error.message
12744 ),
12745 )
12746 })?;
12747 if let Some(record_id) = record_id.as_ref() {
12748 self.discard_record(record_id)?;
12749 }
12750 return Err(failure(
12751 if deadline.is_some() {
12752 NodeFailureCode::SpawnDeadlineExceeded
12753 } else {
12754 NodeFailureCode::BackendBusy
12755 },
12756 "start did not commit before the bounded deadline; registration was removed",
12757 ));
12758 }
12759 sleep(Duration::from_millis(2)).await;
12760 }
12761 }
12762
12763 fn effective_spawn_runtime_policy(
12764 &self,
12765 runtime_policy: ProviderRuntimePolicy,
12766 ) -> ProviderRuntimePolicy {
12767 #[cfg(feature = "fixture")]
12768 let runtime_policy = if self.fixture_semantic_hook_policy {
12769 ProviderRuntimePolicy::new(true, true, true, true, false, true)
12770 .expect("monitoring Hook fixture policy is internally valid")
12771 } else {
12772 runtime_policy
12773 };
12774 runtime_policy
12775 }
12776
12777 fn verify_harness_mcp_before_start(
12778 &self,
12779 prepared: Option<&PreparedHarnessMcpSpawn>,
12780 ) -> Result<(), NodeFailure> {
12781 prepared
12782 .map(PreparedHarnessMcpSpawn::verify_helper_program)
12783 .transpose()
12784 .map(|_| ())
12785 .map_err(harness_mcp_failure)
12786 }
12787
12788 async fn rollback_spawn(
12789 &self,
12790 address: &SessionAddress,
12791 commit_timeout: Duration,
12792 ) -> Result<(), NodeFailure> {
12793 self.dispatch_bounded(
12794 ControlCommand::Remove {
12795 instance_id: address.session.instance_id,
12796 },
12797 commit_timeout,
12798 )
12799 .await?;
12800 self.wait_until_removed(address, commit_timeout).await?;
12801 if let Some(binding) = self.remove_binding(address) {
12802 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
12803 }
12804 Ok(())
12805 }
12806
12807 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12808 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12809 self.dispatch_bounded(
12810 ControlCommand::Remove {
12811 instance_id: address.session.instance_id,
12812 },
12813 commit_timeout,
12814 )
12815 .await?;
12816 self.wait_until_removed(address, commit_timeout).await?;
12817 let removed = self.remove_binding(address);
12818 if let Some(binding) = removed.as_ref() {
12819 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
12820 }
12821 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12822 let snapshot = self.managed_worktrees.lock()
12823 .unwrap_or_else(|poisoned| poisoned.into_inner())
12824 .release_session(
12825 &lease_id,
12826 self.incarnation_id,
12827 address.session.instance_id,
12828 address.session.generation,
12829 unix_time_ms(),
12830 );
12831 if self.persist_state().is_err() {
12832 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12833 }
12834 if let Some(snapshot) = snapshot {
12835 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12836 }
12837 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12838 }
12839 Ok(())
12840 }
12841
12842 async fn wait_until_removed(
12888 &self,
12889 address: &SessionAddress,
12890 commit_timeout: Duration,
12891 ) -> Result<(), NodeFailure> {
12892 let deadline = Instant::now() + commit_timeout;
12893 let mut next_dispatch = Instant::now();
12894 loop {
12895 let snapshot = self.handle.snapshot();
12896 let status = snapshot
12897 .sessions
12898 .iter()
12899 .find(|session| session.instance_id == address.session.instance_id)
12900 .map(|session| session.status.clone());
12901 let Some(status) = status else {
12902 return Ok(());
12903 };
12904 let now = Instant::now();
12905 if now >= deadline {
12906 return Err(failure(
12907 NodeFailureCode::BackendBusy,
12908 "remove did not commit before the bounded deadline; session binding was retained",
12909 ));
12910 }
12911 if status.allows_remove() && now >= next_dispatch {
12912 let _ = self.dispatch(ControlCommand::Remove {
12913 instance_id: address.session.instance_id,
12914 });
12915 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
12916 }
12917 sleep(Duration::from_millis(2)).await;
12918 }
12919 }
12920
12921 fn resync(&self, after_sequence: u64) -> NodeResponse {
12922 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12923 let event_sequence = history.last_sequence;
12924 let oldest_available_sequence = history.replay_floor_sequence;
12925 let events = history
12926 .events
12927 .iter()
12928 .filter(|event| event.sequence > after_sequence)
12929 .cloned()
12930 .collect();
12931 drop(history);
12932 NodeResponse::Resync {
12933 event_sequence,
12934 oldest_available_sequence,
12935 snapshot: self.snapshot(),
12936 events,
12937 }
12938 }
12939}
12940
12941fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
12946 match kind {
12947 AgentStreamChunkKindV1::Text { .. } => "text",
12948 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
12949 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
12950 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
12951 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
12952 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
12953 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
12954 }
12955}
12956
12957async fn accept_connections(
12971 endpoint: &str,
12972 call_home: Option<std::net::SocketAddr>,
12973 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
12974 shared: Arc<NodeShared>,
12975) -> Result<(), NodeServerError> {
12976 let mut connections = JoinSet::new();
12977 let result = match call_home {
12978 None => accept_connections_inner(endpoint, shared, &mut connections).await,
12979 Some(relay) => {
12980 let listener =
12981 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
12982 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
12983 tokio::pin!(listener);
12984 tokio::pin!(dialler);
12985 tokio::select! {
12986 result = &mut listener => result,
12987 result = &mut dialler => result,
12988 }
12989 }
12990 };
12991 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
12992 while !connections.is_empty() {
12993 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
12994 if remaining.is_zero()
12995 || timeout(remaining, connections.join_next()).await.is_err()
12996 {
12997 break;
12998 }
12999 }
13000 connections.abort_all();
13001 while connections.join_next().await.is_some() {}
13002 result
13003}
13004
13005const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13009
13010async fn call_home_loop(
13024 relay: std::net::SocketAddr,
13025 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13026 shared: Arc<NodeShared>,
13027) -> Result<(), NodeServerError> {
13028 let mut failures = 0_usize;
13029 loop {
13030 if shared.shutdown.load(Ordering::Acquire) {
13031 return Ok(());
13032 }
13033 let permit = tokio::select! {
13034 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13035 permit.map_err(|_| NodeServerError::PreauthClosed)?
13036 }
13037 _ = shared.shutdown_notify.notified() => return Ok(()),
13038 };
13039 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13040 Ok(()) => {
13041 failures = 0;
13045 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed");
13046 }
13047 Err(error) => {
13048 failures = failures.saturating_add(1);
13049 tracing::warn!(
13050 node_id = %shared.node_id,
13051 %relay,
13052 cause = %error,
13053 attempt = failures,
13054 "call-home connection failed",
13055 );
13056 let backoff = CALL_HOME_BACKOFFS_MS
13057 [failures.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)];
13058 tokio::select! {
13059 _ = sleep(Duration::from_millis(backoff)) => {}
13060 _ = shared.shutdown_notify.notified() => return Ok(()),
13061 }
13062 }
13063 }
13064 }
13065}
13066
13067async fn dial_relay_and_serve(
13069 relay: std::net::SocketAddr,
13070 identity: Option<&hatchery_enroll::NodeIdentity>,
13071 shared: Arc<NodeShared>,
13072 preauth_permit: OwnedSemaphorePermit,
13073) -> Result<(), NodeServerError> {
13074 let mut stream = tokio::net::TcpStream::connect(relay)
13075 .await
13076 .map_err(NodeServerError::CallHomeTransport)?;
13077 let _ = stream.set_nodelay(true);
13081 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13082 .await
13083 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13084 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13085 let mut session_token = None;
13086 if let Some(identity) = identity {
13087 let mut magic = [0_u8; 4];
13091 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13092 .await
13093 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13094 .map_err(NodeServerError::CallHomeTransport)?;
13095 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13096 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13097 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13098 .await
13099 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13100 .map_err(NodeServerError::CallHomeTransport)?;
13101 let (answer, token) = identity
13102 .answer_challenge(shared.node_id.as_str(), &frame)
13103 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13104 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13105 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13106 session_token = Some(token);
13107 }
13108 }
13109 serve_connection(stream, shared, session_token, preauth_permit).await
13110}
13111
13112async fn accept_connections_inner(
13113 endpoint: &str,
13114 shared: Arc<NodeShared>,
13115 connections: &mut JoinSet<Result<(), NodeServerError>>,
13116) -> Result<(), NodeServerError> {
13117 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13118 loop {
13119 while connections.try_join_next().is_some() {}
13120 if shared.shutdown.load(Ordering::Acquire) {
13121 return Ok(());
13122 }
13123 let preauth_permit = tokio::select! {
13124 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13125 permit.map_err(|_| NodeServerError::PreauthClosed)?
13126 }
13127 _ = shared.shutdown_notify.notified() => return Ok(()),
13128 };
13129 let server = tokio::select! {
13130 result = listener.accept() => result?,
13131 _ = shared.shutdown_notify.notified() => return Ok(()),
13132 };
13133 let connection_shared = Arc::clone(&shared);
13134 connections.spawn(async move {
13135 serve_connection(server, connection_shared, None, preauth_permit).await
13136 });
13137 if shared.shutdown.load(Ordering::Acquire) {
13138 return Ok(());
13139 }
13140 }
13141}
13142
13143async fn serve_connection<S>(
13151 mut pipe: S,
13152 shared: Arc<NodeShared>,
13153 session_token: Option<String>,
13154 preauth_permit: OwnedSemaphorePermit,
13155) -> Result<(), NodeServerError>
13156where
13157 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13158{
13159 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13161 let hello_frame = timeout(
13162 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13163 read_json_frame_limited_body_timeout(
13164 &mut pipe,
13165 MAX_NODE_HELLO_FRAME_BYTES,
13166 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13167 ),
13168 )
13169 .await
13170 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13171 let ClientFrame::Hello(hello) = hello_frame else {
13172 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13173 };
13174 if hello.build_stamp != BUILD_STAMP {
13175 return Err(NodeServerError::Handshake(format!(
13176 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13177 hello.build_stamp,
13178 )));
13179 }
13180 let compatibility = match hello.compatibility.as_ref() {
13181 Some(offer) => {
13182 let mut selected = node_compatibility_support(&shared)?
13183 .negotiate(offer)
13184 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13185 project_negotiated_provider_ids(&mut selected);
13186 Some(selected)
13187 }
13188 None => None,
13189 };
13190 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13191 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13192 (Some(offer), Some(selected)) => negotiated_auth_proof(
13193 auth_token,
13194 AuthDirection::Server,
13195 hello.role,
13196 &hello.client_nonce,
13197 &server_nonce,
13198 offer,
13199 selected,
13200 ),
13201 (None, None) => auth_proof(
13202 auth_token,
13203 AuthDirection::Server,
13204 hello.role,
13205 &hello.client_nonce,
13206 &server_nonce,
13207 ),
13208 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13209 }
13210 .map_err(NodeServerError::Authentication)?;
13211 write_json_frame_limited(
13212 &mut pipe,
13213 &ServerFrame::Challenge(ServerChallenge {
13214 build_stamp: BUILD_STAMP.to_owned(),
13215 server_nonce,
13216 server_proof,
13217 compatibility: compatibility.clone(),
13218 }),
13219 MAX_NODE_HELLO_FRAME_BYTES,
13220 )
13221 .await?;
13222 let authentication = timeout(
13223 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13224 read_json_frame_limited_body_timeout(
13225 &mut pipe,
13226 MAX_NODE_HELLO_FRAME_BYTES,
13227 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13228 ),
13229 )
13230 .await
13231 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13232 let ClientFrame::Authenticate(authentication) = authentication else {
13233 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13234 };
13235 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13236 (Some(offer), Some(selected)) => negotiated_auth_proof(
13237 auth_token,
13238 AuthDirection::Client,
13239 hello.role,
13240 &hello.client_nonce,
13241 &server_nonce,
13242 offer,
13243 selected,
13244 ),
13245 (None, None) => auth_proof(
13246 auth_token,
13247 AuthDirection::Client,
13248 hello.role,
13249 &hello.client_nonce,
13250 &server_nonce,
13251 ),
13252 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13253 }
13254 .map_err(NodeServerError::Authentication)?;
13255 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13256 return Err(NodeServerError::Handshake("access denied".to_owned()));
13257 }
13258 let selected_capabilities = compatibility
13259 .as_ref()
13260 .map(|selected| selected.capabilities.clone())
13261 .unwrap_or_default();
13262 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13263 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13264 });
13265 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13266 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13267 });
13268 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13269 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13270 });
13271 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13272 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13273 });
13274 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13275 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13276 });
13277 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13278 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13279 }) && selected_capabilities.iter().any(|capability| {
13280 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13281 });
13282 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13283 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13284 });
13285 let include_session_bundles = selected_capabilities.iter().any(|capability| {
13286 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13287 });
13288 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13289 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13290 });
13291 let include_agent_progress = selected_capabilities.iter().any(|capability| {
13292 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13293 });
13294 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13295 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13296 });
13297 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13298 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13299 });
13300 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13301 .try_acquire_owned()
13302 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13303 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13304 let _release_guard = ControllerReleaseGuard {
13305 shared: Arc::clone(&shared),
13306 connection_id,
13307 };
13308 drop(preauth_permit);
13309 let _authenticated_permit = authenticated_permit;
13310 let mut event_rx = shared.event_tx.subscribe();
13311 let mut terminal_event_rx = terminal_event_subscription(
13312 &shared,
13313 include_terminal_frame_events,
13314 );
13315 let mut agent_stream_event_rx = agent_stream_event_subscription(
13316 &shared,
13317 include_agent_stream_events,
13318 );
13319 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13320 &shared,
13321 include_harness_mcp_proxy,
13322 );
13323 write_json_frame(
13324 &mut pipe,
13325 &ServerFrame::Hello(NodeHello {
13326 build_stamp: BUILD_STAMP.to_owned(),
13327 incarnation_id: shared.incarnation_id,
13328 connection_id,
13329 role: hello.role,
13330 event_sequence: shared.current_sequence(),
13331 controller: shared.controller_state(),
13332 snapshot: snapshot_for_wire(
13333 &shared,
13334 include_provider_runtime_status,
13335 include_open_provider_ids,
13336 include_spawn_profiles,
13337 include_managed_worktrees,
13338 include_child_environment_profiles,
13339 include_session_bundles,
13340 include_history_context_packs,
13341 include_agent_progress,
13342 include_session_task_correlation,
13343 ),
13344 compatibility,
13345 }),
13346 )
13347 .await?;
13348 let (mut reader, mut writer) = tokio::io::split(pipe);
13349 let (frame_tx, mut frame_rx) = mpsc::channel(16);
13350 let reader_task = tokio::spawn(async move {
13351 loop {
13352 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13353 &mut reader,
13354 MAX_NODE_CLIENT_FRAME_BYTES,
13355 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13356 )
13357 .await;
13358 let terminal = frame.is_err();
13359 if frame_tx.send(frame).await.is_err() || terminal {
13360 break;
13361 }
13362 }
13363 });
13364 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13365 let mut pending_events = Vec::new();
13366 let mut resync_required = false;
13367 let mut discard_events_through = 0_u64;
13368 loop {
13369 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13374 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13375 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13376 for _ in 0..durable_drain_budget {
13377 match event_rx.try_recv() {
13378 Ok(event) => queue_connection_event(
13379 &mut pending_events,
13380 event,
13381 discard_events_through,
13382 ),
13383 Err(broadcast::error::TryRecvError::Empty) => break,
13384 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13385 resync_required = true;
13386 break;
13387 }
13388 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13389 }
13390 }
13391 if let Some(receiver) = terminal_event_rx.as_mut() {
13392 match receiver.try_recv() {
13393 Ok(events) => queue_connection_event_batch(
13394 &mut pending_events,
13395 &events,
13396 discard_events_through,
13397 ),
13398 Err(broadcast::error::TryRecvError::Empty) => {}
13399 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13400 resync_required = true;
13401 }
13402 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13403 }
13404 }
13405 if let Some(receiver) = agent_stream_event_rx.as_mut() {
13406 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13416 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13417 for _ in 0..agent_stream_drain_budget {
13418 match receiver.try_recv() {
13419 Ok(events) => queue_connection_event_batch(
13420 &mut pending_events,
13421 &events,
13422 discard_events_through,
13423 ),
13424 Err(broadcast::error::TryRecvError::Empty) => break,
13425 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13426 tracing::warn!(
13427 connection_id,
13428 dropped,
13429 "agent stream event subscription lagged; forcing a resync",
13430 );
13431 resync_required = true;
13432 break;
13433 }
13434 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13435 }
13436 }
13437 }
13438 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13439 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13445 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13446 for _ in 0..harness_mcp_proxy_drain_budget {
13447 match receiver.try_recv() {
13448 Ok(events) => queue_connection_event_batch(
13449 &mut pending_events,
13450 &events,
13451 discard_events_through,
13452 ),
13453 Err(broadcast::error::TryRecvError::Empty) => break,
13454 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13455 tracing::warn!(
13456 connection_id,
13457 dropped,
13458 "harness MCP proxy event subscription lagged; forcing a resync",
13459 );
13460 resync_required = true;
13461 break;
13462 }
13463 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13464 }
13465 }
13466 }
13467 if resync_required {
13468 let event = resync_required_event(&shared);
13469 apply_connection_resync_watermark(
13470 &mut pending_events,
13471 &mut discard_events_through,
13472 event.sequence,
13473 );
13474 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13475 resync_required = false;
13476 continue;
13477 }
13478 if !pending_events.is_empty() {
13479 pending_events.sort_unstable_by_key(|event| event.sequence);
13480 let burst_len = connection_event_burst_len(pending_events.len());
13481 shared
13482 .connection_events_sent
13483 .fetch_add(burst_len as u64, Ordering::Relaxed);
13484 for event in pending_events.drain(..burst_len) {
13485 let event = if include_managed_worktrees {
13486 Some(event)
13487 } else {
13488 project_event_without_managed_worktrees(event)
13489 };
13490 let event = event.and_then(|event| if include_open_provider_ids {
13491 Some(event)
13492 } else {
13493 project_event_legacy_provider_ids(&shared, event)
13494 });
13495 let event = event.map(|event| if include_child_environment_profiles {
13496 event
13497 } else {
13498 project_event_without_child_environment_profile(event)
13499 });
13500 let event = event.map(|event| if include_session_bundles {
13501 event
13502 } else {
13503 project_event_without_session_bundle(event)
13504 });
13505 let event = event.map(|event| {
13506 project_event_history_for_wire(event, include_history_context_packs)
13507 });
13508 let event = event.map(|event| if include_history_context_packs {
13509 event
13510 } else {
13511 project_event_without_context_pack(event)
13512 });
13513 let event = event.map(|event| if include_session_task_correlation {
13514 event
13515 } else {
13516 project_event_without_session_task_binding(event)
13517 });
13518 let exact_harness_controller = hello.role == ClientRole::Operator
13519 && shared.controller_state().is_some_and(|controller| {
13520 controller.connection_id == connection_id
13521 });
13522 let event = event.and_then(|event| {
13523 if include_harness_mcp_proxy && exact_harness_controller {
13524 Some(event)
13525 } else {
13526 project_event_without_harness_mcp_proxy(event)
13527 }
13528 });
13529 if let Some(event) = event {
13530 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13531 }
13532 }
13533 }
13534 tokio::select! {
13535 biased;
13536 frame = frame_rx.recv() => {
13537 let frame = match frame {
13538 Some(frame) => frame,
13539 None => break,
13540 };
13541 let frame = match frame {
13542 Ok(frame) => frame,
13543 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13544 Err(error) => return Err(error.into()),
13545 };
13546 let ClientFrame::Request(request) = frame else {
13547 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13548 };
13549 #[cfg(feature = "fixture")]
13550 let probe_spawn_managed_worktree_v2 = matches!(
13551 &request.request,
13552 NodeRequest::SpawnManagedWorktreeV2 { .. }
13553 );
13554 let requires_open_provider_ids =
13555 request_requires_open_provider_ids(&shared, &request.request);
13556 let requires_child_environment_profile =
13557 request_requires_child_environment_profile(&shared, &request.request);
13558 let requires_session_bundle =
13559 request_requires_session_bundle(&shared, &request.request);
13560 let requires_history_context_pack =
13561 request_requires_history_context_pack(&shared, &request.request);
13562 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13563 ResponseEnvelope {
13564 request_id: request.request_id,
13565 result: Err(failure(
13566 NodeFailureCode::UnsupportedCapability,
13567 "open provider IDs were not negotiated",
13568 )),
13569 }
13570 } else if requires_child_environment_profile
13571 && !include_child_environment_profiles
13572 {
13573 ResponseEnvelope {
13574 request_id: request.request_id,
13575 result: Err(failure(
13576 NodeFailureCode::UnsupportedCapability,
13577 "child environment profiles were not negotiated",
13578 )),
13579 }
13580 } else if requires_session_bundle && !include_session_bundles {
13581 ResponseEnvelope {
13582 request_id: request.request_id,
13583 result: Err(failure(
13584 NodeFailureCode::UnsupportedCapability,
13585 "session bundles were not negotiated",
13586 )),
13587 }
13588 } else if requires_history_context_pack && !include_history_context_packs {
13589 ResponseEnvelope {
13590 request_id: request.request_id,
13591 result: Err(failure(
13592 NodeFailureCode::UnsupportedCapability,
13593 "history context packs were not negotiated",
13594 )),
13595 }
13596 } else if request_uses_unnegotiated_capability(
13597 &request.request,
13598 &selected_capabilities,
13599 )
13600 {
13601 ResponseEnvelope {
13602 request_id: request.request_id,
13603 result: Err(failure(
13604 NodeFailureCode::UnsupportedCapability,
13605 "request capability was not negotiated",
13606 )),
13607 }
13608 } else {
13609 process_request(&shared, connection_id, hello.role, request).await
13610 };
13611 #[cfg(feature = "fixture")]
13612 if probe_spawn_managed_worktree_v2 {
13613 shared
13614 .fixture_spawn_managed_worktree_v2_failure_probe
13615 .record(reply.result.as_ref().err().map(|failure| failure.code));
13616 }
13617 if !include_provider_runtime_status {
13618 clear_response_provider_runtime_status(&mut reply);
13619 }
13620 if !include_managed_worktrees {
13621 project_response_without_managed_worktrees(&mut reply);
13622 }
13623 if !include_open_provider_ids {
13624 project_response_legacy_provider_ids(&shared, &mut reply);
13625 }
13626 if !include_child_environment_profiles {
13627 project_response_without_child_environment_profile(&mut reply);
13628 }
13629 if !include_session_bundles {
13630 project_response_without_session_bundle(&mut reply);
13631 }
13632 project_response_history_for_wire(
13633 &mut reply,
13634 include_history_context_packs,
13635 );
13636 if !include_history_context_packs {
13637 project_response_without_context_pack(&mut reply);
13638 }
13639 if !include_agent_progress {
13640 project_response_without_agent_progress(&mut reply);
13641 }
13642 if !include_session_task_correlation {
13643 project_response_without_session_task_binding(&mut reply);
13644 }
13645 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13646 }
13647 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13648 event = event_rx.recv() => {
13649 match event {
13650 Ok(event) => queue_connection_event(
13651 &mut pending_events,
13652 event,
13653 discard_events_through,
13654 ),
13655 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13656 Err(broadcast::error::RecvError::Closed) => break,
13657 }
13658 }
13659 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13660 match events {
13661 Ok(events) => queue_connection_event_batch(
13662 &mut pending_events,
13663 &events,
13664 discard_events_through,
13665 ),
13666 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13667 Err(broadcast::error::RecvError::Closed) => break,
13668 }
13669 }
13670 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13671 match events {
13672 Ok(events) => queue_connection_event_batch(
13673 &mut pending_events,
13674 &events,
13675 discard_events_through,
13676 ),
13677 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13678 Err(broadcast::error::RecvError::Closed) => break,
13679 }
13680 }
13681 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13682 match events {
13683 Ok(events) => queue_connection_event_batch(
13684 &mut pending_events,
13685 &events,
13686 discard_events_through,
13687 ),
13688 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13689 Err(broadcast::error::RecvError::Closed) => break,
13690 }
13691 }
13692 _ = shared.shutdown_notify.notified() => break,
13693 }
13694 }
13695 Ok(())
13696}
13697
13698async fn receive_terminal_event_batch(
13699 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13700) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13701 match receiver {
13702 Some(receiver) => receiver.recv().await,
13703 None => std::future::pending().await,
13704 }
13705}
13706
13707fn terminal_event_subscription(
13708 shared: &NodeShared,
13709 enabled: bool,
13710) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13711 enabled.then(|| shared.terminal_event_tx.subscribe())
13712}
13713
13714fn agent_stream_event_subscription(
13718 shared: &NodeShared,
13719 enabled: bool,
13720) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13721 enabled.then(|| shared.agent_stream_event_tx.subscribe())
13722}
13723
13724fn harness_mcp_proxy_event_subscription(
13729 shared: &NodeShared,
13730 enabled: bool,
13731) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13732 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13733}
13734
13735fn queue_connection_event(
13736 pending: &mut Vec<NodeEventEnvelope>,
13737 event: NodeEventEnvelope,
13738 discard_through: u64,
13739) {
13740 if event.sequence <= discard_through
13741 && !event.event.requires_harness_mcp_proxy_capability()
13742 {
13743 return;
13744 }
13745 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13746 if let Some(index) = pending.iter().position(|current| {
13747 matches!(
13748 ¤t.event,
13749 NodeEvent::TerminalFrame {
13750 address: current_address,
13751 ..
13752 } if current_address == address
13753 )
13754 }) {
13755 pending.remove(index);
13756 }
13757 }
13758 pending.push(event);
13759}
13760
13761fn project_event_without_harness_mcp_proxy(
13762 envelope: NodeEventEnvelope,
13763) -> Option<NodeEventEnvelope> {
13764 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13765}
13766
13767fn queue_connection_event_batch(
13768 pending: &mut Vec<NodeEventEnvelope>,
13769 events: &[NodeEventEnvelope],
13770 discard_through: u64,
13771) {
13772 for event in events {
13773 queue_connection_event(pending, event.clone(), discard_through);
13774 }
13775}
13776
13777fn apply_connection_resync_watermark(
13778 pending: &mut Vec<NodeEventEnvelope>,
13779 discard_through: &mut u64,
13780 marker_sequence: u64,
13781) {
13782 *discard_through = (*discard_through).max(marker_sequence);
13783 pending.retain(|event| event.sequence > *discard_through);
13784}
13785
13786fn connection_event_burst_len(pending_len: usize) -> usize {
13787 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13788}
13789
13790fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13791 let history = shared
13792 .history
13793 .lock()
13794 .unwrap_or_else(|poisoned| poisoned.into_inner());
13795 let sequence = history.last_sequence;
13796 let oldest_available_sequence = history.replay_floor_sequence;
13797 NodeEventEnvelope {
13798 sequence,
13799 event: NodeEvent::ResyncRequired {
13800 oldest_available_sequence,
13801 },
13802 }
13803}
13804
13805fn node_compatibility_support(
13806 shared: &NodeShared,
13807) -> Result<NodeCompatibilitySupport, NodeServerError> {
13808 let mut support = node_compatibility_support_for_manifest(
13809 &shared.provider_contracts,
13810 &shared.provider_adapter_contracts,
13811 )?;
13812 if shared
13813 .delivery_store
13814 .lock()
13815 .unwrap_or_else(|poisoned| poisoned.into_inner())
13816 .is_some()
13817 {
13818 support.capabilities.push(
13819 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13820 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13821 );
13822 }
13823 if shared.harness_mcp_registry.is_some() {
13824 support.capabilities.push(
13825 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
13826 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13827 );
13828 }
13829 Ok(support)
13830}
13831
13832fn node_compatibility_support_for_manifest(
13833 provider_contracts: &[ProviderContractSupport],
13834 provider_adapter_contracts: &[ProviderAdapterContractSupport],
13835) -> Result<NodeCompatibilitySupport, NodeServerError> {
13836 Ok(NodeCompatibilitySupport {
13837 build_stamp: BUILD_STAMP.to_owned(),
13838 capabilities: baseline_capabilities()?,
13839 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
13840 path_semantics: platform::path_semantics(),
13841 local_transport: platform::local_transport(),
13842 state_schema: StateSchemaSupport {
13843 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
13844 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13845 },
13846 provider_contracts: provider_contracts.to_vec(),
13847 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
13848 })
13849}
13850
13851fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
13852 let capabilities = [
13853 NODE_COMPATIBILITY_METADATA_CAPABILITY,
13854 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
13855 NODE_REPOSITORY_PATH_CAPABILITY,
13856 NODE_WORKSPACE_FILE_READ_CAPABILITY,
13857 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
13858 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
13859 NODE_GIT_READ_CAPABILITY,
13860 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
13861 NODE_PROVIDER_ID_OPEN_CAPABILITY,
13862 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
13863 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
13864 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
13865 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
13866 NODE_ACP_CONTROL_CAPABILITY,
13867 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
13868 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
13869 NODE_WORKTREE_SELECTION_CAPABILITY,
13870 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
13871 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
13872 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
13873 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
13874 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
13875 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
13876 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
13877 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
13878 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
13879 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
13880 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
13881 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
13882 ]
13883 .into_iter()
13884 .map(|capability| {
13885 CapabilityId::new(capability)
13886 .map_err(|error| NodeServerError::Handshake(error.to_string()))
13887 })
13888 .collect::<Result<Vec<_>, _>>()?;
13889 #[cfg(windows)]
13890 let capabilities = {
13891 let mut capabilities = capabilities;
13892 capabilities.push(
13893 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
13894 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13895 );
13896 capabilities
13897 };
13898 Ok(capabilities)
13899}
13900
13901fn request_uses_unnegotiated_capability(
13902 request: &NodeRequest,
13903 selected_capabilities: &[CapabilityId],
13904) -> bool {
13905 let selected = |required: &str| {
13906 selected_capabilities
13907 .iter()
13908 .any(|capability| capability.as_str() == required)
13909 };
13910 request
13911 .required_capability()
13912 .is_some_and(|required| !selected(required))
13913 || (request.requires_worktree_selection_capability()
13914 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
13915 || (request.requires_spawn_spec_defaults_overrides_capability()
13916 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
13917 || (request.requires_spawn_profile_revision_capability()
13918 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
13919 || (request.requires_child_environment_profile_capability()
13920 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
13921 || (request.requires_session_bundle_materialization_capability()
13922 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
13923 || (request.requires_history_context_pack_capability()
13924 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
13925}
13926
13927fn snapshot_for_wire(
13928 shared: &NodeShared,
13929 include_provider_runtime_status: bool,
13930 include_open_provider_ids: bool,
13931 include_spawn_profiles: bool,
13932 include_managed_worktrees: bool,
13933 include_child_environment_profiles: bool,
13934 include_session_bundles: bool,
13935 include_history_context_packs: bool,
13936 include_agent_progress: bool,
13937 include_session_task_correlation: bool,
13938) -> NodeSnapshot {
13939 let mut snapshot = shared.snapshot();
13940 if !include_provider_runtime_status {
13941 snapshot.provider_runtime_statuses.clear();
13942 }
13943 if !include_open_provider_ids {
13944 project_snapshot_legacy_provider_ids(&mut snapshot);
13945 }
13946 if !include_managed_worktrees {
13947 snapshot.managed_worktrees.clear();
13948 for workspace in &mut snapshot.workspaces {
13949 workspace.managed_worktree_profiles = None;
13950 }
13951 }
13952 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
13953 if !include_spawn_profiles {
13954 inventory.spawn_profiles = None;
13955 }
13956 if !include_session_bundles {
13957 inventory.bundles = None;
13958 }
13959 if inventory.spawn_profiles.is_none()
13960 && inventory.bundles.is_none()
13961 && inventory.network_allowlists.is_none()
13962 {
13963 snapshot.launch_inventory = None;
13964 }
13965 }
13966 if !include_child_environment_profiles {
13967 clear_snapshot_child_environment_profiles(&mut snapshot);
13968 }
13969 if !include_session_bundles {
13970 clear_snapshot_session_bundles(&mut snapshot);
13971 }
13972 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
13973 if !include_history_context_packs {
13974 clear_snapshot_context_packs(&mut snapshot);
13975 }
13976 if !include_agent_progress {
13977 snapshot.agent_progress.clear();
13978 }
13979 if !include_session_task_correlation {
13980 clear_snapshot_session_task_bindings(&mut snapshot);
13981 }
13982 snapshot
13983}
13984
13985fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
13986 for record in &mut snapshot.session_records {
13987 record.task_binding = None;
13988 }
13989}
13990
13991fn project_event_without_session_task_binding(
13992 mut envelope: NodeEventEnvelope,
13993) -> NodeEventEnvelope {
13994 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
13995 record.task_binding = None;
13996 }
13997 envelope
13998}
13999
14000fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14001 let Ok(response) = reply.result.as_mut() else { return };
14002 match response {
14003 NodeResponse::Snapshot { snapshot, .. } => {
14004 clear_snapshot_session_task_bindings(snapshot);
14005 }
14006 NodeResponse::Resync { snapshot, events, .. } => {
14007 clear_snapshot_session_task_bindings(snapshot);
14008 for event in events {
14009 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14010 record.task_binding = None;
14011 }
14012 }
14013 }
14014 NodeResponse::SessionRecordUpdated { record }
14015 | NodeResponse::ProviderSessionIndexed { record }
14016 | NodeResponse::NativeSessionIndexed { record, .. }
14017 | NodeResponse::SessionRecordResumed { record, .. } => {
14018 record.task_binding = None;
14019 }
14020 _ => {}
14021 }
14022}
14023
14024fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14025 let Ok(response) = reply.result.as_mut() else { return };
14026 match response {
14027 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14028 snapshot.agent_progress.clear();
14029 }
14030 _ => {}
14031 }
14032}
14033
14034fn project_event_without_managed_worktrees(
14035 mut envelope: NodeEventEnvelope,
14036) -> Option<NodeEventEnvelope> {
14037 if matches!(
14038 envelope.event,
14039 NodeEvent::ManagedWorktreeUpserted { .. }
14040 | NodeEvent::ManagedWorktreeRemoved { .. }
14041 ) {
14042 return None;
14043 }
14044 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14045 workspace.managed_worktree_profiles = None;
14046 }
14047 Some(envelope)
14048}
14049
14050fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14051 if matches!(
14052 reply.result,
14053 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14054 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14055 ) {
14056 reply.result = Err(failure(
14057 NodeFailureCode::UnsupportedCapability,
14058 "managed worktree capability was not negotiated",
14059 ));
14060 return;
14061 }
14062 let Ok(response) = reply.result.as_mut() else { return };
14063 match response {
14064 NodeResponse::Snapshot { snapshot, .. } => {
14065 snapshot.managed_worktrees.clear();
14066 for workspace in &mut snapshot.workspaces {
14067 workspace.managed_worktree_profiles = None;
14068 }
14069 }
14070 NodeResponse::Resync { snapshot, events, .. } => {
14071 snapshot.managed_worktrees.clear();
14072 for workspace in &mut snapshot.workspaces {
14073 workspace.managed_worktree_profiles = None;
14074 }
14075 *events = std::mem::take(events)
14076 .into_iter()
14077 .filter_map(project_event_without_managed_worktrees)
14078 .collect();
14079 }
14080 NodeResponse::WorkspaceRegistered { workspace }
14081 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14082 | NodeResponse::WorktreeCreated { workspace, .. } => {
14083 workspace.managed_worktree_profiles = None;
14084 }
14085 NodeResponse::WorkspaceInspected { inspection } => {
14086 inspection.git.managed_worktree = None;
14087 }
14088 _ => {}
14089 }
14090}
14091
14092fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14093 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14094 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14095 });
14096 if includes_open_provider_ids {
14097 return;
14098 }
14099 compatibility
14100 .provider_contracts
14101 .retain(|contract| provider_id_is_legacy(&contract.provider));
14102 compatibility
14103 .provider_adapter_contracts
14104 .retain(|contract| provider_id_is_legacy(&contract.provider));
14105}
14106
14107fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14108 if let Some(spec) = match request {
14109 NodeRequest::SpawnSpec { spec }
14110 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14111 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14112 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14113 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14114 _ => None,
14115 } {
14116 return shared
14117 .resolve_spawn_spec(spec)
14118 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14119 }
14120 request_requires_open_provider_ids_with(
14121 request,
14122 |session| shared.validate_address(session).ok(),
14123 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14124 )
14125}
14126
14127fn request_requires_child_environment_profile(
14128 shared: &NodeShared,
14129 request: &NodeRequest,
14130) -> bool {
14131 if let Some(spec) = match request {
14132 NodeRequest::SpawnSpec { spec }
14133 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14134 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14135 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14136 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14137 _ => None,
14138 } {
14139 return shared
14140 .resolve_spawn_spec(spec)
14141 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14142 }
14143 match request {
14144 NodeRequest::Resume { session, .. }
14145 | NodeRequest::Prompt { session, .. }
14146 | NodeRequest::Paste { session, .. }
14147 | NodeRequest::Input { session, .. }
14148 | NodeRequest::TerminalBytes { session, .. }
14149 | NodeRequest::TerminalControl { session, .. }
14150 | NodeRequest::Resize { session, .. }
14151 | NodeRequest::Interrupt { session }
14152 | NodeRequest::Stop { session, .. }
14153 | NodeRequest::Remove { session }
14154 | NodeRequest::ResolveInteraction { session, .. }
14155 | NodeRequest::SetSessionMode { session, .. }
14156 | NodeRequest::SetSessionConfigOption { session, .. }
14157 | NodeRequest::SetSessionModel { session, .. }
14158 | NodeRequest::DiscoverHistory { session, .. }
14159 | NodeRequest::LoadHistory { session, .. }
14160 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14161 | NodeRequest::ExportContextPack { session } => shared
14162 .session_bindings
14163 .lock()
14164 .unwrap_or_else(|poisoned| poisoned.into_inner())
14165 .get(&session.session.instance_id)
14166 .filter(|binding| {
14167 binding.workspace_id == session.workspace_id
14168 && binding.generation == session.session.generation
14169 })
14170 .map_or(true, |binding| binding.environment_profile.is_some()),
14171 NodeRequest::RenameSessionRecord { record_id, .. }
14172 | NodeRequest::SetSessionTask { record_id, .. }
14173 | NodeRequest::ResumeSessionRecord { record_id, .. }
14174 | NodeRequest::ForgetSessionRecord { record_id } => shared
14175 .record(record_id)
14176 .map_or(true, |record| record.environment_profile.is_some()),
14177 NodeRequest::ArmHarnessMcpReservation { .. }
14178 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14179 | NodeRequest::ActivateHarnessMcpReservation { .. }
14180 | NodeRequest::AbortHarnessMcpReservation { .. }
14181 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14182 | NodeRequest::RejectHarnessMcpCall { .. }
14183 | NodeRequest::Snapshot
14184 | NodeRequest::Resync { .. }
14185 | NodeRequest::BeginDeliveryStage { .. }
14186 | NodeRequest::PutDeliveryBlobChunk { .. }
14187 | NodeRequest::CommitDeliveryStage { .. }
14188 | NodeRequest::AbortDeliveryStage { .. }
14189 | NodeRequest::BrowseHostDirectories { .. }
14190 | NodeRequest::InspectWorkspace { .. }
14191 | NodeRequest::ReadWorkspaceFile { .. }
14192 | NodeRequest::WriteWorkspaceFile { .. }
14193 | NodeRequest::CreateWorkspaceFile { .. }
14194 | NodeRequest::CreateWorkspaceDirectory { .. }
14195 | NodeRequest::ReadGitHistory { .. }
14196 | NodeRequest::ReadGitDiff { .. }
14197 | NodeRequest::AcquireController { .. }
14198 | NodeRequest::ReleaseController
14199 | NodeRequest::Spawn { .. }
14200 | NodeRequest::SpawnSpec { .. }
14201 | NodeRequest::RegisterWorkspace { .. }
14202 | NodeRequest::CreateStandaloneWorkspace { .. }
14203 | NodeRequest::UnregisterWorkspace { .. }
14204 | NodeRequest::CreateWorktree { .. }
14205 | NodeRequest::RemoveWorktree { .. }
14206 | NodeRequest::SpawnManagedWorktree { .. }
14207 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14208 | NodeRequest::CleanupManagedWorktree { .. }
14209 | NodeRequest::IndexProviderSession { .. }
14210 | NodeRequest::IndexNativeSession { .. }
14211 | NodeRequest::CatalogNativeSessions { .. }
14212 | NodeRequest::PageNativeSessions { .. }
14213 | NodeRequest::PreviewNativeSession { .. }
14214 | NodeRequest::PreviewSessionRecord { .. }
14215 | NodeRequest::ForgetContextPack { .. }
14216 | NodeRequest::ResolveDurableContextPack { .. }
14217 | NodeRequest::ReadContextPack { .. }
14218 | NodeRequest::Shutdown => false,
14219 }
14220}
14221
14222fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14223 if let Some(spec) = match request {
14224 NodeRequest::SpawnSpec { spec }
14225 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14226 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14227 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14228 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14229 _ => None,
14230 } {
14231 return shared
14232 .resolve_spawn_spec(spec)
14233 .is_ok_and(|resolved| resolved.bundle_id.is_some());
14234 }
14235 match request {
14236 NodeRequest::Resume { session, .. }
14237 | NodeRequest::Prompt { session, .. }
14238 | NodeRequest::Paste { session, .. }
14239 | NodeRequest::Input { session, .. }
14240 | NodeRequest::TerminalBytes { session, .. }
14241 | NodeRequest::TerminalControl { session, .. }
14242 | NodeRequest::Resize { session, .. }
14243 | NodeRequest::Interrupt { session }
14244 | NodeRequest::Stop { session, .. }
14245 | NodeRequest::Remove { session }
14246 | NodeRequest::ResolveInteraction { session, .. }
14247 | NodeRequest::SetSessionMode { session, .. }
14248 | NodeRequest::SetSessionConfigOption { session, .. }
14249 | NodeRequest::SetSessionModel { session, .. }
14250 | NodeRequest::DiscoverHistory { session, .. }
14251 | NodeRequest::LoadHistory { session, .. }
14252 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14253 | NodeRequest::ExportContextPack { session } => shared
14254 .session_bindings
14255 .lock()
14256 .unwrap_or_else(|poisoned| poisoned.into_inner())
14257 .get(&session.session.instance_id)
14258 .filter(|binding| {
14259 binding.workspace_id == session.workspace_id
14260 && binding.generation == session.session.generation
14261 })
14262 .map_or(true, |binding| binding.bundle.is_some()),
14263 NodeRequest::RenameSessionRecord { record_id, .. }
14264 | NodeRequest::SetSessionTask { record_id, .. }
14265 | NodeRequest::ResumeSessionRecord { record_id, .. }
14266 | NodeRequest::ForgetSessionRecord { record_id } => shared
14267 .record(record_id)
14268 .map_or(true, |record| record.bundle.is_some()),
14269 NodeRequest::ArmHarnessMcpReservation { .. }
14270 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14271 | NodeRequest::ActivateHarnessMcpReservation { .. }
14272 | NodeRequest::AbortHarnessMcpReservation { .. }
14273 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14274 | NodeRequest::RejectHarnessMcpCall { .. }
14275 | NodeRequest::Snapshot
14276 | NodeRequest::Resync { .. }
14277 | NodeRequest::BeginDeliveryStage { .. }
14278 | NodeRequest::PutDeliveryBlobChunk { .. }
14279 | NodeRequest::CommitDeliveryStage { .. }
14280 | NodeRequest::AbortDeliveryStage { .. }
14281 | NodeRequest::BrowseHostDirectories { .. }
14282 | NodeRequest::InspectWorkspace { .. }
14283 | NodeRequest::ReadWorkspaceFile { .. }
14284 | NodeRequest::WriteWorkspaceFile { .. }
14285 | NodeRequest::CreateWorkspaceFile { .. }
14286 | NodeRequest::CreateWorkspaceDirectory { .. }
14287 | NodeRequest::ReadGitHistory { .. }
14288 | NodeRequest::ReadGitDiff { .. }
14289 | NodeRequest::AcquireController { .. }
14290 | NodeRequest::ReleaseController
14291 | NodeRequest::Spawn { .. }
14292 | NodeRequest::SpawnSpec { .. }
14293 | NodeRequest::RegisterWorkspace { .. }
14294 | NodeRequest::CreateStandaloneWorkspace { .. }
14295 | NodeRequest::UnregisterWorkspace { .. }
14296 | NodeRequest::CreateWorktree { .. }
14297 | NodeRequest::RemoveWorktree { .. }
14298 | NodeRequest::SpawnManagedWorktree { .. }
14299 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14300 | NodeRequest::CleanupManagedWorktree { .. }
14301 | NodeRequest::IndexProviderSession { .. }
14302 | NodeRequest::IndexNativeSession { .. }
14303 | NodeRequest::CatalogNativeSessions { .. }
14304 | NodeRequest::PageNativeSessions { .. }
14305 | NodeRequest::PreviewNativeSession { .. }
14306 | NodeRequest::PreviewSessionRecord { .. }
14307 | NodeRequest::ForgetContextPack { .. }
14308 | NodeRequest::ResolveDurableContextPack { .. }
14309 | NodeRequest::ReadContextPack { .. }
14310 | NodeRequest::Shutdown => false,
14311 }
14312}
14313
14314fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14315 if request.requires_history_context_pack_capability() {
14316 return true;
14317 }
14318 if let Some(spec) = match request {
14319 NodeRequest::SpawnSpec { spec }
14320 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14321 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14322 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14323 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14324 _ => None,
14325 } {
14326 return shared
14327 .resolve_spawn_spec(spec)
14328 .map_or(true, |resolved| resolved.context_id.is_some());
14329 }
14330 match request {
14331 NodeRequest::Resume { session, .. }
14332 | NodeRequest::Prompt { session, .. }
14333 | NodeRequest::Paste { session, .. }
14334 | NodeRequest::Input { session, .. }
14335 | NodeRequest::TerminalBytes { session, .. }
14336 | NodeRequest::TerminalControl { session, .. }
14337 | NodeRequest::Resize { session, .. }
14338 | NodeRequest::Interrupt { session }
14339 | NodeRequest::Stop { session, .. }
14340 | NodeRequest::Remove { session }
14341 | NodeRequest::ResolveInteraction { session, .. }
14342 | NodeRequest::SetSessionMode { session, .. }
14343 | NodeRequest::SetSessionConfigOption { session, .. }
14344 | NodeRequest::SetSessionModel { session, .. } => shared
14345 .session_bindings
14346 .lock()
14347 .unwrap_or_else(|poisoned| poisoned.into_inner())
14348 .get(&session.session.instance_id)
14349 .filter(|binding| {
14350 binding.workspace_id == session.workspace_id
14351 && binding.generation == session.session.generation
14352 })
14353 .map_or(true, |binding| binding.context.is_some()),
14354 NodeRequest::RenameSessionRecord { record_id, .. }
14355 | NodeRequest::SetSessionTask { record_id, .. }
14356 | NodeRequest::ResumeSessionRecord { record_id, .. }
14357 | NodeRequest::ForgetSessionRecord { record_id } => shared
14358 .record(record_id)
14359 .map_or(true, |record| record.context.is_some()),
14360 NodeRequest::ArmHarnessMcpReservation { .. }
14361 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14362 | NodeRequest::ActivateHarnessMcpReservation { .. }
14363 | NodeRequest::AbortHarnessMcpReservation { .. }
14364 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14365 | NodeRequest::RejectHarnessMcpCall { .. }
14366 | NodeRequest::Snapshot
14367 | NodeRequest::Resync { .. }
14368 | NodeRequest::BeginDeliveryStage { .. }
14369 | NodeRequest::PutDeliveryBlobChunk { .. }
14370 | NodeRequest::CommitDeliveryStage { .. }
14371 | NodeRequest::AbortDeliveryStage { .. }
14372 | NodeRequest::BrowseHostDirectories { .. }
14373 | NodeRequest::InspectWorkspace { .. }
14374 | NodeRequest::ReadWorkspaceFile { .. }
14375 | NodeRequest::WriteWorkspaceFile { .. }
14376 | NodeRequest::CreateWorkspaceFile { .. }
14377 | NodeRequest::CreateWorkspaceDirectory { .. }
14378 | NodeRequest::ReadGitHistory { .. }
14379 | NodeRequest::ReadGitDiff { .. }
14380 | NodeRequest::AcquireController { .. }
14381 | NodeRequest::ReleaseController
14382 | NodeRequest::Spawn { .. }
14383 | NodeRequest::SpawnSpec { .. }
14384 | NodeRequest::RegisterWorkspace { .. }
14385 | NodeRequest::CreateStandaloneWorkspace { .. }
14386 | NodeRequest::UnregisterWorkspace { .. }
14387 | NodeRequest::CreateWorktree { .. }
14388 | NodeRequest::RemoveWorktree { .. }
14389 | NodeRequest::SpawnManagedWorktree { .. }
14390 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14391 | NodeRequest::CleanupManagedWorktree { .. }
14392 | NodeRequest::IndexProviderSession { .. }
14393 | NodeRequest::IndexNativeSession { .. }
14394 | NodeRequest::CatalogNativeSessions { .. }
14395 | NodeRequest::PageNativeSessions { .. }
14396 | NodeRequest::PreviewNativeSession { .. }
14397 | NodeRequest::PreviewSessionRecord { .. }
14398 | NodeRequest::DiscoverHistory { .. }
14399 | NodeRequest::LoadHistory { .. }
14400 | NodeRequest::ExportContextPackForSessionRecord { .. }
14401 | NodeRequest::ExportContextPack { .. }
14402 | NodeRequest::ForgetContextPack { .. }
14403 | NodeRequest::ResolveDurableContextPack { .. }
14404 | NodeRequest::ReadContextPack { .. }
14405 | NodeRequest::Shutdown => false,
14406 }
14407}
14408
14409fn request_requires_open_provider_ids_with(
14410 request: &NodeRequest,
14411 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14412 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14413) -> bool {
14414 match request {
14415 NodeRequest::Spawn { provider, .. } => !provider_id_is_legacy(provider),
14416 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14417 NodeRequest::CatalogNativeSessions { route, .. }
14418 | NodeRequest::PageNativeSessions { route, .. } => {
14419 !provider_id_is_legacy(&route.provider)
14420 }
14421 NodeRequest::PreviewNativeSession { selection, .. }
14422 | NodeRequest::IndexNativeSession { selection, .. } => {
14423 !provider_id_is_legacy(&selection.route.provider)
14424 }
14425 NodeRequest::SpawnSpec { .. }
14426 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14427 NodeRequest::SpawnManagedWorktree { .. }
14428 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14429 NodeRequest::Resume { session, .. }
14430 | NodeRequest::Prompt { session, .. }
14431 | NodeRequest::Paste { session, .. }
14432 | NodeRequest::Input { session, .. }
14433 | NodeRequest::TerminalBytes { session, .. }
14434 | NodeRequest::TerminalControl { session, .. }
14435 | NodeRequest::Resize { session, .. }
14436 | NodeRequest::Interrupt { session }
14437 | NodeRequest::Stop { session, .. }
14438 | NodeRequest::Remove { session }
14439 | NodeRequest::ResolveInteraction { session, .. }
14440 | NodeRequest::SetSessionMode { session, .. }
14441 | NodeRequest::SetSessionConfigOption { session, .. }
14442 | NodeRequest::SetSessionModel { session, .. }
14443 | NodeRequest::DiscoverHistory { session, .. }
14444 | NodeRequest::LoadHistory { session, .. }
14445 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14446 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14447 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14448 NodeRequest::RenameSessionRecord { record_id, .. }
14449 | NodeRequest::SetSessionTask { record_id, .. }
14450 | NodeRequest::ResumeSessionRecord { record_id, .. }
14451 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14452 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14453 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14454 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14455 NodeRequest::ArmHarnessMcpReservation { .. }
14456 | NodeRequest::ActivateHarnessMcpReservation { .. }
14457 | NodeRequest::AbortHarnessMcpReservation { .. }
14458 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14459 | NodeRequest::RejectHarnessMcpCall { .. }
14460 | NodeRequest::Snapshot
14461 | NodeRequest::Resync { .. }
14462 | NodeRequest::BeginDeliveryStage { .. }
14463 | NodeRequest::PutDeliveryBlobChunk { .. }
14464 | NodeRequest::CommitDeliveryStage { .. }
14465 | NodeRequest::AbortDeliveryStage { .. }
14466 | NodeRequest::BrowseHostDirectories { .. }
14467 | NodeRequest::InspectWorkspace { .. }
14468 | NodeRequest::ReadWorkspaceFile { .. }
14469 | NodeRequest::WriteWorkspaceFile { .. }
14470 | NodeRequest::CreateWorkspaceFile { .. }
14471 | NodeRequest::CreateWorkspaceDirectory { .. }
14472 | NodeRequest::ReadGitHistory { .. }
14473 | NodeRequest::ReadGitDiff { .. }
14474 | NodeRequest::AcquireController { .. }
14475 | NodeRequest::ReleaseController
14476 | NodeRequest::RegisterWorkspace { .. }
14477 | NodeRequest::CreateStandaloneWorkspace { .. }
14478 | NodeRequest::UnregisterWorkspace { .. }
14479 | NodeRequest::CreateWorktree { .. }
14480 | NodeRequest::RemoveWorktree { .. }
14481 | NodeRequest::CleanupManagedWorktree { .. }
14482 | NodeRequest::Shutdown => false,
14483 NodeRequest::ForgetContextPack { .. } => true,
14484 NodeRequest::ResolveDurableContextPack { .. }
14490 | NodeRequest::ReadContextPack { .. } => true,
14491 }
14492}
14493
14494fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14495 snapshot.enabled_providers.retain(provider_id_is_legacy);
14496 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14497 snapshot
14498 .provider_runtime_statuses
14499 .as_slice()
14500 .iter()
14501 .filter(|status| provider_id_is_legacy(status.provider()))
14502 .cloned(),
14503 )
14504 .expect("a filtered provider runtime projection remains valid");
14505 snapshot
14506 .session_records
14507 .retain(|record| provider_id_is_legacy(&record.provider));
14508 for workspace in &mut snapshot.workspaces {
14509 workspace
14510 .sessions
14511 .retain(|session| provider_id_is_legacy(&session.agent_id));
14512 }
14513}
14514
14515fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14516 for record in &mut snapshot.session_records {
14517 record.environment_profile = None;
14518 }
14519 if let Some(profiles) = snapshot
14520 .launch_inventory
14521 .as_mut()
14522 .and_then(|inventory| inventory.spawn_profiles.as_mut())
14523 {
14524 for profile in profiles {
14525 profile.environment_profile = None;
14526 }
14527 }
14528}
14529
14530fn project_event_without_child_environment_profile(
14531 mut envelope: NodeEventEnvelope,
14532) -> NodeEventEnvelope {
14533 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14534 record.environment_profile = None;
14535 }
14536 envelope
14537}
14538
14539fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14540 let contains_environment_profile = match reply.result.as_ref() {
14541 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14542 receipt.environment_profile.is_some()
14543 }
14544 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14545 receipt.spawn.environment_profile.is_some()
14546 }
14547 _ => false,
14548 };
14549 if contains_environment_profile {
14550 reply.result = Err(failure(
14551 NodeFailureCode::UnsupportedCapability,
14552 "child environment profiles were not negotiated",
14553 ));
14554 return;
14555 }
14556 let Ok(response) = reply.result.as_mut() else {
14557 return;
14558 };
14559 match response {
14560 NodeResponse::Snapshot { snapshot, .. } => {
14561 clear_snapshot_child_environment_profiles(snapshot);
14562 }
14563 NodeResponse::Resync { snapshot, events, .. } => {
14564 clear_snapshot_child_environment_profiles(snapshot);
14565 for event in events {
14566 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14567 record.environment_profile = None;
14568 }
14569 }
14570 }
14571 NodeResponse::SessionRecordUpdated { record }
14572 | NodeResponse::ProviderSessionIndexed { record }
14573 | NodeResponse::NativeSessionIndexed { record, .. }
14574 | NodeResponse::SessionRecordResumed { record, .. } => {
14575 record.environment_profile = None;
14576 }
14577 NodeResponse::Armed { .. }
14578 | NodeResponse::Spawned { .. }
14579 | NodeResponse::Activated { .. }
14580 | NodeResponse::Aborted { .. }
14581 | NodeResponse::ReplyChunkAccepted { .. }
14582 | NodeResponse::CallRejected { .. }
14583 | NodeResponse::DeliveryStageBegun { .. }
14584 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14585 | NodeResponse::DeliveryCommitted { .. }
14586 | NodeResponse::DeliveryStageAborted { .. }
14587 | NodeResponse::WorkspaceInspected { .. }
14588 | NodeResponse::HostDirectoriesBrowsed { .. }
14589 | NodeResponse::WorkspaceFileRead { .. }
14590 | NodeResponse::WorkspaceFileWritten { .. }
14591 | NodeResponse::WorkspaceFileCreated { .. }
14592 | NodeResponse::WorkspaceDirectoryCreated { .. }
14593 | NodeResponse::GitHistoryRead { .. }
14594 | NodeResponse::GitDiffRead { .. }
14595 | NodeResponse::Controller { .. }
14596 | NodeResponse::SpawnAccepted { .. }
14597 | NodeResponse::SpawnSpecAccepted { .. }
14598 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14599 | NodeResponse::ManagedWorktreeCleanup { .. }
14600 | NodeResponse::NativeSessionsCataloged { .. }
14601 | NodeResponse::NativeSessionsPaged { .. }
14602 | NodeResponse::NativeSessionPreviewed { .. }
14603 | NodeResponse::SessionRecordPreviewed { .. }
14604 | NodeResponse::HistoryDiscovered { .. }
14605 | NodeResponse::HistoryLoaded { .. }
14606 | NodeResponse::ContextPackForSessionRecordExported { .. }
14607 | NodeResponse::ContextPackExported { .. }
14608 | NodeResponse::ContextPackForgotten { .. }
14609 | NodeResponse::DurableContextPackResolved { .. }
14610 | NodeResponse::ContextPackBytesRead { .. }
14611 | NodeResponse::SessionRecordForgotten { .. }
14612 | NodeResponse::WorkspaceRegistered { .. }
14613 | NodeResponse::StandaloneWorkspaceCreated { .. }
14614 | NodeResponse::WorkspaceUnregistered { .. }
14615 | NodeResponse::WorktreeCreated { .. }
14616 | NodeResponse::WorktreeRemoved { .. }
14617 | NodeResponse::Accepted
14618 | NodeResponse::ShuttingDown => {}
14619 }
14620}
14621
14622fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14623 for record in &mut snapshot.session_records {
14624 record.bundle = None;
14625 }
14626}
14627
14628fn project_event_without_session_bundle(
14629 mut envelope: NodeEventEnvelope,
14630) -> NodeEventEnvelope {
14631 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14632 record.bundle = None;
14633 }
14634 envelope
14635}
14636
14637fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14638 let contains_bundle = match reply.result.as_ref() {
14639 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14640 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14641 receipt.spawn.bundle.is_some()
14642 }
14643 _ => false,
14644 };
14645 if contains_bundle {
14646 reply.result = Err(failure(
14647 NodeFailureCode::UnsupportedCapability,
14648 "session bundles were not negotiated",
14649 ));
14650 return;
14651 }
14652 let Ok(response) = reply.result.as_mut() else {
14653 return;
14654 };
14655 match response {
14656 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14657 NodeResponse::Resync { snapshot, events, .. } => {
14658 clear_snapshot_session_bundles(snapshot);
14659 for event in events {
14660 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14661 record.bundle = None;
14662 }
14663 }
14664 }
14665 NodeResponse::SessionRecordUpdated { record }
14666 | NodeResponse::ProviderSessionIndexed { record }
14667 | NodeResponse::NativeSessionIndexed { record, .. }
14668 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14669 NodeResponse::Armed { .. }
14670 | NodeResponse::Spawned { .. }
14671 | NodeResponse::Activated { .. }
14672 | NodeResponse::Aborted { .. }
14673 | NodeResponse::ReplyChunkAccepted { .. }
14674 | NodeResponse::CallRejected { .. }
14675 | NodeResponse::DeliveryStageBegun { .. }
14676 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14677 | NodeResponse::DeliveryCommitted { .. }
14678 | NodeResponse::DeliveryStageAborted { .. }
14679 | NodeResponse::WorkspaceInspected { .. }
14680 | NodeResponse::HostDirectoriesBrowsed { .. }
14681 | NodeResponse::WorkspaceFileRead { .. }
14682 | NodeResponse::WorkspaceFileWritten { .. }
14683 | NodeResponse::WorkspaceFileCreated { .. }
14684 | NodeResponse::WorkspaceDirectoryCreated { .. }
14685 | NodeResponse::GitHistoryRead { .. }
14686 | NodeResponse::GitDiffRead { .. }
14687 | NodeResponse::Controller { .. }
14688 | NodeResponse::SpawnAccepted { .. }
14689 | NodeResponse::SpawnSpecAccepted { .. }
14690 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14691 | NodeResponse::ManagedWorktreeCleanup { .. }
14692 | NodeResponse::NativeSessionsCataloged { .. }
14693 | NodeResponse::NativeSessionsPaged { .. }
14694 | NodeResponse::NativeSessionPreviewed { .. }
14695 | NodeResponse::SessionRecordPreviewed { .. }
14696 | NodeResponse::HistoryDiscovered { .. }
14697 | NodeResponse::HistoryLoaded { .. }
14698 | NodeResponse::ContextPackForSessionRecordExported { .. }
14699 | NodeResponse::ContextPackExported { .. }
14700 | NodeResponse::ContextPackForgotten { .. }
14701 | NodeResponse::DurableContextPackResolved { .. }
14702 | NodeResponse::ContextPackBytesRead { .. }
14703 | NodeResponse::SessionRecordForgotten { .. }
14704 | NodeResponse::WorkspaceRegistered { .. }
14705 | NodeResponse::StandaloneWorkspaceCreated { .. }
14706 | NodeResponse::WorkspaceUnregistered { .. }
14707 | NodeResponse::WorktreeCreated { .. }
14708 | NodeResponse::WorktreeRemoved { .. }
14709 | NodeResponse::Accepted
14710 | NodeResponse::ShuttingDown => {}
14711 }
14712}
14713
14714fn project_snapshot_history_for_wire(
14715 snapshot: &mut NodeSnapshot,
14716 include_history_metadata: bool,
14717) {
14718 for workspace in &mut snapshot.workspaces {
14719 for session in &mut workspace.sessions {
14720 if include_history_metadata {
14721 session.history.loaded = None;
14722 } else {
14723 session.history = Default::default();
14724 }
14725 }
14726 }
14727}
14728
14729fn project_event_history_for_wire(
14730 mut envelope: NodeEventEnvelope,
14731 include_history_metadata: bool,
14732) -> NodeEventEnvelope {
14733 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14734 for session in &mut workspace.sessions {
14735 if include_history_metadata {
14736 session.history.loaded = None;
14737 } else {
14738 session.history = Default::default();
14739 }
14740 }
14741 }
14742 envelope
14743}
14744
14745fn project_response_history_for_wire(
14746 reply: &mut ResponseEnvelope,
14747 include_history_metadata: bool,
14748) {
14749 let Ok(response) = reply.result.as_mut() else {
14750 return;
14751 };
14752 match response {
14753 NodeResponse::Snapshot { snapshot, .. } => {
14754 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14755 }
14756 NodeResponse::Resync {
14757 snapshot, events, ..
14758 } => {
14759 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14760 for event in events {
14761 *event = project_event_history_for_wire(
14762 event.clone(),
14763 include_history_metadata,
14764 );
14765 }
14766 }
14767 NodeResponse::WorkspaceRegistered { workspace }
14768 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14769 | NodeResponse::WorktreeCreated { workspace, .. } => {
14770 for session in &mut workspace.sessions {
14771 if include_history_metadata {
14772 session.history.loaded = None;
14773 } else {
14774 session.history = Default::default();
14775 }
14776 }
14777 }
14778 _ => {}
14779 }
14780}
14781
14782fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14783 for record in &mut snapshot.session_records {
14784 record.context_id = None;
14785 record.context = None;
14786 record.exported_context = None;
14787 }
14788}
14789
14790fn project_event_without_context_pack(
14791 mut envelope: NodeEventEnvelope,
14792) -> NodeEventEnvelope {
14793 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14794 record.context_id = None;
14795 record.context = None;
14796 record.exported_context = None;
14797 }
14798 envelope
14799}
14800
14801fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14802 let contains_context = match reply.result.as_ref() {
14803 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14804 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14805 receipt.spawn.context.is_some()
14806 }
14807 Ok(NodeResponse::HistoryDiscovered { .. })
14808 | Ok(NodeResponse::HistoryLoaded { .. })
14809 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14810 | Ok(NodeResponse::ContextPackExported { .. })
14811 | Ok(NodeResponse::ContextPackForgotten { .. })
14812 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14813 _ => false,
14814 };
14815 if contains_context {
14816 reply.result = Err(failure(
14817 NodeFailureCode::UnsupportedCapability,
14818 "history context packs were not negotiated",
14819 ));
14820 return;
14821 }
14822 let Ok(response) = reply.result.as_mut() else {
14823 return;
14824 };
14825 match response {
14826 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
14827 NodeResponse::Resync {
14828 snapshot, events, ..
14829 } => {
14830 clear_snapshot_context_packs(snapshot);
14831 for event in events {
14832 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14833 record.context_id = None;
14834 record.context = None;
14835 record.exported_context = None;
14836 }
14837 }
14838 }
14839 NodeResponse::SessionRecordUpdated { record }
14840 | NodeResponse::ProviderSessionIndexed { record }
14841 | NodeResponse::NativeSessionIndexed { record, .. }
14842 | NodeResponse::SessionRecordResumed { record, .. } => {
14843 record.context_id = None;
14844 record.context = None;
14845 record.exported_context = None;
14846 }
14847 _ => {}
14848 }
14849}
14850
14851fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
14852 let contains_open_record = match reply.result.as_ref() {
14853 Ok(NodeResponse::SessionRecordUpdated { record })
14854 | Ok(NodeResponse::ProviderSessionIndexed { record })
14855 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
14856 !provider_id_is_legacy(&record.provider)
14857 }
14858 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
14859 !provider_id_is_legacy(&selection.route.provider)
14860 || !provider_id_is_legacy(&record.provider)
14861 }
14862 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14863 !provider_id_is_legacy(&receipt.provider)
14864 }
14865 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14866 !provider_id_is_legacy(&receipt.spawn.provider)
14867 }
14868 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
14869 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
14870 !provider_id_is_legacy(&route.provider)
14871 }
14872 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
14873 !provider_id_is_legacy(&selection.route.provider)
14874 }
14875 _ => false,
14876 };
14877 if contains_open_record {
14878 reply.result = Err(failure(
14879 NodeFailureCode::UnsupportedCapability,
14880 "open provider IDs were not negotiated",
14881 ));
14882 return;
14883 }
14884 let Ok(response) = reply.result.as_mut() else {
14885 return;
14886 };
14887 match response {
14888 NodeResponse::Snapshot { snapshot, .. } => {
14889 project_snapshot_legacy_provider_ids(snapshot);
14890 }
14891 NodeResponse::Resync { snapshot, events, .. } => {
14892 project_snapshot_legacy_provider_ids(snapshot);
14893 *events = std::mem::take(events)
14894 .into_iter()
14895 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
14896 .collect();
14897 }
14898 NodeResponse::WorkspaceRegistered { workspace }
14899 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14900 | NodeResponse::WorktreeCreated { workspace, .. } => {
14901 workspace
14902 .sessions
14903 .retain(|session| provider_id_is_legacy(&session.agent_id));
14904 }
14905 NodeResponse::Armed { .. }
14906 | NodeResponse::Spawned { .. }
14907 | NodeResponse::Activated { .. }
14908 | NodeResponse::Aborted { .. }
14909 | NodeResponse::ReplyChunkAccepted { .. }
14910 | NodeResponse::CallRejected { .. }
14911 | NodeResponse::DeliveryStageBegun { .. }
14912 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14913 | NodeResponse::DeliveryCommitted { .. }
14914 | NodeResponse::DeliveryStageAborted { .. }
14915 | NodeResponse::WorkspaceInspected { .. }
14916 | NodeResponse::HostDirectoriesBrowsed { .. }
14917 | NodeResponse::WorkspaceFileRead { .. }
14918 | NodeResponse::WorkspaceFileWritten { .. }
14919 | NodeResponse::WorkspaceFileCreated { .. }
14920 | NodeResponse::WorkspaceDirectoryCreated { .. }
14921 | NodeResponse::GitHistoryRead { .. }
14922 | NodeResponse::GitDiffRead { .. }
14923 | NodeResponse::Controller { .. }
14924 | NodeResponse::SpawnAccepted { .. }
14925 | NodeResponse::SpawnSpecAccepted { .. }
14926 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14927 | NodeResponse::ManagedWorktreeCleanup { .. }
14928 | NodeResponse::NativeSessionsCataloged { .. }
14929 | NodeResponse::NativeSessionsPaged { .. }
14930 | NodeResponse::NativeSessionPreviewed { .. }
14931 | NodeResponse::SessionRecordPreviewed { .. }
14932 | NodeResponse::HistoryDiscovered { .. }
14933 | NodeResponse::HistoryLoaded { .. }
14934 | NodeResponse::ContextPackForSessionRecordExported { .. }
14935 | NodeResponse::ContextPackExported { .. }
14936 | NodeResponse::ContextPackForgotten { .. }
14937 | NodeResponse::DurableContextPackResolved { .. }
14938 | NodeResponse::ContextPackBytesRead { .. }
14939 | NodeResponse::SessionRecordUpdated { .. }
14940 | NodeResponse::ProviderSessionIndexed { .. }
14941 | NodeResponse::NativeSessionIndexed { .. }
14942 | NodeResponse::SessionRecordResumed { .. }
14943 | NodeResponse::SessionRecordForgotten { .. }
14944 | NodeResponse::WorkspaceUnregistered { .. }
14945 | NodeResponse::WorktreeRemoved { .. }
14946 | NodeResponse::Accepted
14947 | NodeResponse::ShuttingDown => {}
14948 }
14949}
14950
14951fn project_event_legacy_provider_ids(
14952 shared: &NodeShared,
14953 mut envelope: NodeEventEnvelope,
14954) -> Option<NodeEventEnvelope> {
14955 let include = match &mut envelope.event {
14956 NodeEvent::HarnessMcpReadCall { .. } => true,
14957 NodeEvent::Control { address, .. }
14958 | NodeEvent::AgentStream { address, .. }
14959 | NodeEvent::TerminalFrame { address, .. } => shared
14960 .validate_address(address)
14961 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
14962 NodeEvent::WorkspaceAdded { workspace } => {
14963 workspace
14964 .sessions
14965 .retain(|session| provider_id_is_legacy(&session.agent_id));
14966 true
14967 }
14968 NodeEvent::SessionRecordUpserted { record } => {
14969 provider_id_is_legacy(&record.provider)
14970 }
14971 NodeEvent::SessionRecordHistorySummarized { .. } => false,
14972 NodeEvent::SessionRecordRemoved { .. } => shared
14973 .history
14974 .lock()
14975 .unwrap_or_else(|poisoned| poisoned.into_inner())
14976 .removed_record_providers
14977 .get(&envelope.sequence)
14978 .is_some_and(provider_id_is_legacy),
14979 NodeEvent::ControllerChanged { .. }
14980 | NodeEvent::ManagedWorktreeUpserted { .. }
14981 | NodeEvent::ManagedWorktreeRemoved { .. }
14982 | NodeEvent::WorkspaceRemoved { .. }
14983 | NodeEvent::ResyncRequired { .. } => true,
14984 };
14985 include.then_some(envelope)
14986}
14987
14988fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
14989 let Ok(response) = reply.result.as_mut() else {
14990 return;
14991 };
14992 match response {
14993 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14994 snapshot.provider_runtime_statuses.clear();
14995 }
14996 NodeResponse::Armed { .. }
14997 | NodeResponse::Spawned { .. }
14998 | NodeResponse::Activated { .. }
14999 | NodeResponse::Aborted { .. }
15000 | NodeResponse::ReplyChunkAccepted { .. }
15001 | NodeResponse::CallRejected { .. }
15002 | NodeResponse::DeliveryStageBegun { .. }
15003 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15004 | NodeResponse::DeliveryCommitted { .. }
15005 | NodeResponse::DeliveryStageAborted { .. }
15006 | NodeResponse::WorkspaceInspected { .. }
15007 | NodeResponse::HostDirectoriesBrowsed { .. }
15008 | NodeResponse::WorkspaceFileRead { .. }
15009 | NodeResponse::WorkspaceFileWritten { .. }
15010 | NodeResponse::WorkspaceFileCreated { .. }
15011 | NodeResponse::WorkspaceDirectoryCreated { .. }
15012 | NodeResponse::GitHistoryRead { .. }
15013 | NodeResponse::GitDiffRead { .. }
15014 | NodeResponse::Controller { .. }
15015 | NodeResponse::SpawnAccepted { .. }
15016 | NodeResponse::SpawnSpecAccepted { .. }
15017 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15018 | NodeResponse::ManagedWorktreeCleanup { .. }
15019 | NodeResponse::NativeSessionsCataloged { .. }
15020 | NodeResponse::NativeSessionsPaged { .. }
15021 | NodeResponse::NativeSessionPreviewed { .. }
15022 | NodeResponse::SessionRecordPreviewed { .. }
15023 | NodeResponse::HistoryDiscovered { .. }
15024 | NodeResponse::HistoryLoaded { .. }
15025 | NodeResponse::ContextPackForSessionRecordExported { .. }
15026 | NodeResponse::ContextPackExported { .. }
15027 | NodeResponse::ContextPackForgotten { .. }
15028 | NodeResponse::DurableContextPackResolved { .. }
15029 | NodeResponse::ContextPackBytesRead { .. }
15030 | NodeResponse::SessionRecordUpdated { .. }
15031 | NodeResponse::ProviderSessionIndexed { .. }
15032 | NodeResponse::NativeSessionIndexed { .. }
15033 | NodeResponse::SessionRecordResumed { .. }
15034 | NodeResponse::SessionRecordForgotten { .. }
15035 | NodeResponse::WorkspaceRegistered { .. }
15036 | NodeResponse::StandaloneWorkspaceCreated { .. }
15037 | NodeResponse::WorkspaceUnregistered { .. }
15038 | NodeResponse::WorktreeCreated { .. }
15039 | NodeResponse::WorktreeRemoved { .. }
15040 | NodeResponse::Accepted
15041 | NodeResponse::ShuttingDown => {}
15042 }
15043}
15044
15045struct ControllerReleaseGuard {
15046 shared: Arc<NodeShared>,
15047 connection_id: u64,
15048}
15049
15050impl Drop for ControllerReleaseGuard {
15051 fn drop(&mut self) {
15052 self.shared.release_controller(self.connection_id);
15053 }
15054}
15055
15056struct AbortTaskOnDrop(AbortHandle);
15057
15058impl Drop for AbortTaskOnDrop {
15059 fn drop(&mut self) {
15060 self.0.abort();
15061 }
15062}
15063
15064fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15069 std::env::var(name)
15070 .ok()
15071 .and_then(|value| value.parse::<u64>().ok())
15072 .filter(|value| *value > 0)
15073 .unwrap_or(default)
15074 .min(max)
15075}
15076
15077fn workspace_inspection_time_budget_ms() -> u64 {
15078 env_config_bounded(
15079 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15080 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15081 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15082 )
15083}
15084
15085fn workspace_inspection_entry_cap() -> usize {
15086 env_config_bounded(
15087 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15088 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15089 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15090 ) as usize
15091}
15092
15093struct WorkspaceWalkBudget {
15099 deadline: Instant,
15100 entry_cap: usize,
15101 entries_visited: u64,
15102 time_budget_exceeded: bool,
15103 entry_cap_exceeded: bool,
15104}
15105
15106impl WorkspaceWalkBudget {
15107 fn new(deadline: Instant, entry_cap: usize) -> Self {
15108 Self {
15109 deadline,
15110 entry_cap,
15111 entries_visited: 0,
15112 time_budget_exceeded: false,
15113 entry_cap_exceeded: false,
15114 }
15115 }
15116
15117 fn is_exhausted(&self) -> bool {
15118 self.time_budget_exceeded || self.entry_cap_exceeded
15119 }
15120
15121 fn record_visit(&mut self) -> bool {
15124 self.entries_visited += 1;
15125 if self.entries_visited as usize >= self.entry_cap {
15126 self.entry_cap_exceeded = true;
15127 }
15128 if Instant::now() >= self.deadline {
15129 self.time_budget_exceeded = true;
15130 }
15131 self.is_exhausted()
15132 }
15133}
15134
15135fn collect_workspace_entries(
15136 root: &Path,
15137 deadline: Instant,
15138 entry_cap: usize,
15139) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15140 let mut entries = Vec::new();
15141 let mut truncated = false;
15142 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15143 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15144 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15148 (entries, truncated, budget)
15149}
15150
15151fn workspace_inspection_truncation(
15155 walk_budget: &WorkspaceWalkBudget,
15156 git_time_budget_exceeded: bool,
15157 elapsed: Duration,
15158) -> Option<WorkspaceInspectionTruncationV1> {
15159 if !walk_budget.time_budget_exceeded
15160 && !walk_budget.entry_cap_exceeded
15161 && !git_time_budget_exceeded
15162 {
15163 return None;
15164 }
15165 Some(WorkspaceInspectionTruncationV1 {
15166 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15167 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15168 git_time_budget_exceeded,
15169 entries_visited: walk_budget.entries_visited,
15170 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15171 })
15172}
15173
15174fn walk_workspace_directory(
15175 directory: &Path,
15176 relative_directory: Option<&RepositoryPath>,
15177 depth: usize,
15178 entries: &mut Vec<WorkspaceEntry>,
15179 truncated: &mut bool,
15180 budget: &mut WorkspaceWalkBudget,
15181) {
15182 if budget.is_exhausted() {
15183 *truncated = true;
15184 return;
15185 }
15186 let read_dir = match std::fs::read_dir(directory) {
15187 Ok(read_dir) => read_dir,
15188 Err(_) => {
15189 *truncated = true;
15190 return;
15191 }
15192 };
15193 let mut children = Vec::new();
15194 for child in read_dir {
15195 match child {
15196 Ok(child) => children.push(child),
15197 Err(_) => *truncated = true,
15198 }
15199 }
15200 children.sort_by_key(|child| child.file_name());
15201 for child in children {
15202 if budget.record_visit() {
15203 *truncated = true;
15204 return;
15205 }
15206 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15207 *truncated = true;
15208 return;
15209 }
15210 let file_type = match child.file_type() {
15211 Ok(file_type) => file_type,
15212 Err(_) => {
15213 *truncated = true;
15214 continue;
15215 }
15216 };
15217 if file_type.is_symlink() {
15218 continue;
15219 }
15220 let name = match child.file_name().into_string() {
15221 Ok(name) => name,
15222 Err(_) => {
15223 *truncated = true;
15224 continue;
15225 }
15226 };
15227 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15228 continue;
15229 }
15230 let relative_path = match relative_directory {
15231 None => windows_repository_path(name),
15232 Some(parent) => parent
15233 .as_utf8()
15234 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15235 };
15236 let Some(relative_path) = relative_path else {
15237 *truncated = true;
15238 continue;
15239 };
15240 let kind = if file_type.is_dir() {
15241 WorkspaceEntryKind::Directory
15242 } else if file_type.is_file() {
15243 WorkspaceEntryKind::File
15244 } else {
15245 continue;
15246 };
15247 entries.push(WorkspaceEntry {
15248 relative_path: relative_path.clone(),
15249 kind,
15250 });
15251 if file_type.is_dir() {
15252 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15253 walk_workspace_directory(
15254 &child.path(),
15255 Some(&relative_path),
15256 depth + 1,
15257 entries,
15258 truncated,
15259 budget,
15260 );
15261 } else {
15262 *truncated = true;
15263 }
15264 }
15265 }
15266}
15267
15268fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15269 if value.contains('\\') {
15270 return None;
15271 }
15272 RepositoryPath::utf8(value).ok()
15273}
15274
15275fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15276 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15277}
15278
15279fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15288 if name.eq_ignore_ascii_case(".git")
15289 || name.eq_ignore_ascii_case("target")
15290 || name.eq_ignore_ascii_case("node_modules")
15291 {
15292 return true;
15293 }
15294 path.join("CACHEDIR.TAG").is_file()
15295}
15296
15297fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15298 matches!(
15299 status,
15300 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15301 )
15302}
15303
15304#[derive(Clone, Debug, Eq, PartialEq)]
15305enum ContextPackRepositoryHead {
15306 NotRepository,
15307 Unborn,
15308 Commit(String),
15309}
15310
15311#[derive(Clone, Debug, Eq, PartialEq)]
15312struct ContextPackRepositoryObservation {
15313 head: ContextPackRepositoryHead,
15314 git: GitSnapshot,
15315 files: Vec<ContextPackRepositoryFileSource>,
15316}
15317
15318fn stable_context_pack_repository(
15319 first: ContextPackRepositoryObservation,
15320 second: ContextPackRepositoryObservation,
15321) -> Result<ContextPackRepository, NodeFailure> {
15322 if first != second {
15323 return Err(failure(
15324 NodeFailureCode::ContextPackMaterializationFailed,
15325 "context repository changed during bounded capture",
15326 ));
15327 }
15328 Ok(ContextPackRepository::from_sources(
15329 &second.git,
15330 second.files,
15331 ))
15332}
15333
15334fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15341 if Instant::now() < deadline {
15342 return false;
15343 }
15344 append_git_diagnostic(
15345 snapshot,
15346 &format!("{stage} skipped: workspace inspection time budget exhausted"),
15347 );
15348 true
15349}
15350
15351async fn run_git_bounded_with_deadline(
15360 root: &str,
15361 arguments: &[&str],
15362 output_limit: usize,
15363 deadline: Instant,
15364) -> io::Result<GitCommandOutput> {
15365 let remaining = deadline.checked_duration_since(Instant::now());
15366 let timeout_ms = remaining
15367 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15368 .unwrap_or(0)
15369 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15370 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15371}
15372
15373async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15380 let mut snapshot = GitSnapshot {
15381 is_repository: false,
15382 branch: None,
15383 status: Vec::new(),
15384 recent_commits: Vec::new(),
15385 worktrees: Vec::new(),
15386 managed_worktree: None,
15387 truncated: false,
15388 diagnostic: None,
15389 };
15390 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15391 return (snapshot, true);
15392 }
15393 let repository = match run_git_bounded_with_deadline(
15394 root,
15395 &["rev-parse", "--is-inside-work-tree"],
15396 4 * 1_024,
15397 deadline,
15398 )
15399 .await
15400 {
15401 Ok(output) => output,
15402 Err(error) => {
15403 append_git_diagnostic(
15404 &mut snapshot,
15405 &format!("git inspection unavailable: {error}"),
15406 );
15407 return (snapshot, false);
15408 }
15409 };
15410 snapshot.truncated |= repository.truncated;
15411 if repository.timed_out {
15412 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15413 return (snapshot, false);
15414 }
15415 if !repository.success
15416 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15417 {
15418 let stderr = String::from_utf8_lossy(&repository.stderr);
15419 if !stderr.to_ascii_lowercase().contains("not a git repository") {
15420 append_git_diagnostic(&mut snapshot, stderr.trim());
15421 }
15422 return (snapshot, false);
15423 }
15424 snapshot.is_repository = true;
15425
15426 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15427 return (snapshot, true);
15428 }
15429 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15430 Ok(output) => {
15431 snapshot.truncated |= output.truncated;
15432 if output.timed_out {
15433 append_git_diagnostic(&mut snapshot, "git branch query timed out");
15434 } else if output.success {
15435 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15436 if !branch.is_empty() {
15437 snapshot.branch = Some(truncate_git_field(
15438 &branch,
15439 MAX_REPOSITORY_PATH_BYTES,
15440 &mut snapshot.truncated,
15441 ));
15442 }
15443 }
15444 }
15445 Err(error) => append_git_diagnostic(
15446 &mut snapshot,
15447 &format!("git branch query failed: {error}"),
15448 ),
15449 }
15450 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15451 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15452 snapshot.truncated |= output.truncated;
15453 if output.success && !output.timed_out {
15454 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15455 if !head.is_empty() {
15456 snapshot.branch = Some(format!("detached:{head}"));
15457 }
15458 }
15459 }
15460 }
15461
15462 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15463 return (snapshot, true);
15464 }
15465 match run_git_bounded_with_deadline(
15466 root,
15467 &[
15468 "status",
15469 "--porcelain=v1",
15470 "-z",
15471 "--renames",
15472 "--untracked-files=normal",
15473 "--",
15474 ".",
15475 ],
15476 GIT_OUTPUT_MAX_BYTES,
15477 deadline,
15478 )
15479 .await
15480 {
15481 Ok(output) => {
15482 snapshot.truncated |= output.truncated;
15483 if output.timed_out {
15484 append_git_diagnostic(&mut snapshot, "git status timed out");
15485 } else if output.success {
15486 parse_git_status(&output.stdout, &mut snapshot);
15487 } else {
15488 append_git_diagnostic(
15489 &mut snapshot,
15490 String::from_utf8_lossy(&output.stderr).trim(),
15491 );
15492 }
15493 }
15494 Err(error) => append_git_diagnostic(
15495 &mut snapshot,
15496 &format!("git status failed: {error}"),
15497 ),
15498 }
15499
15500 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15501 return (snapshot, true);
15502 }
15503 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15504 match run_git_bounded_with_deadline(
15505 root,
15506 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15507 16 * 1_024,
15508 deadline,
15509 )
15510 .await
15511 {
15512 Ok(output) => {
15513 snapshot.truncated |= output.truncated;
15514 if output.timed_out {
15515 append_git_diagnostic(&mut snapshot, "git log timed out");
15516 } else if output.success {
15517 parse_git_commits(&output.stdout, &mut snapshot);
15518 } else {
15519 let stderr = String::from_utf8_lossy(&output.stderr);
15520 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15521 append_git_diagnostic(&mut snapshot, stderr.trim());
15522 }
15523 }
15524 }
15525 Err(error) => append_git_diagnostic(
15526 &mut snapshot,
15527 &format!("git log failed: {error}"),
15528 ),
15529 }
15530
15531 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15532 return (snapshot, true);
15533 }
15534 match list_git_worktrees_with_deadline(root, deadline).await {
15535 Ok(worktrees) => {
15536 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15537 }
15538 Err(error) => append_git_diagnostic(
15539 &mut snapshot,
15540 &format!("git worktree list failed: {}", error.message),
15541 ),
15542 }
15543 (snapshot, false)
15544}
15545
15546fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15547 let mut remaining = output;
15548 while !remaining.is_empty() {
15549 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15550 snapshot.truncated = true;
15551 break;
15552 }
15553 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15554 snapshot.truncated = true;
15555 break;
15556 };
15557 let record = &remaining[..record_end];
15558 remaining = &remaining[record_end + 1..];
15559 let has_previous_path = record
15560 .get(..2)
15561 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15562 let previous_path_bytes = if has_previous_path {
15563 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15564 snapshot.truncated = true;
15565 break;
15566 };
15567 let previous = &remaining[..previous_end];
15568 remaining = &remaining[previous_end + 1..];
15569 Some(previous)
15570 } else {
15571 None
15572 };
15573 if record.len() < 4
15574 || record[2] != b' '
15575 || !is_git_status_code(record[0])
15576 || !is_git_status_code(record[1])
15577 {
15578 snapshot.truncated = true;
15579 continue;
15580 }
15581 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15582 snapshot.truncated = true;
15583 continue;
15584 };
15585 let previous_path = match previous_path_bytes {
15586 Some(previous) => {
15587 let Some(previous) = windows_repository_path_from_bytes(previous) else {
15588 snapshot.truncated = true;
15589 continue;
15590 };
15591 Some(previous)
15592 }
15593 None => None,
15594 };
15595 snapshot.status.push(GitStatusEntry {
15596 index_status: (record[0] as char).to_string(),
15597 worktree_status: (record[1] as char).to_string(),
15598 path,
15599 previous_path,
15600 });
15601 }
15602}
15603
15604fn is_git_status_code(byte: u8) -> bool {
15605 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15606}
15607
15608fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15609 for line in String::from_utf8_lossy(output).lines() {
15610 let Some((id, summary)) = line.split_once('\u{1f}') else {
15611 continue;
15612 };
15613 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15614 snapshot.truncated = true;
15615 break;
15616 }
15617 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15618 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15619 snapshot.recent_commits.push(GitCommitSummary { id, summary });
15620 }
15621}
15622
15623fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15624 if value.len() <= max_bytes {
15625 return value.to_owned();
15626 }
15627 *truncated = true;
15628 let mut boundary = max_bytes;
15629 while boundary > 0 && !value.is_char_boundary(boundary) {
15630 boundary -= 1;
15631 }
15632 value[..boundary].to_owned()
15633}
15634
15635fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15636 if message.is_empty() {
15637 return;
15638 }
15639 let normalized = message
15640 .chars()
15641 .map(|character| if character.is_control() { ' ' } else { character })
15642 .collect::<String>();
15643 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15644 if !diagnostic.is_empty() {
15645 diagnostic.push_str("; ");
15646 }
15647 diagnostic.push_str(normalized.trim());
15648 snapshot.diagnostic = Some(truncate_git_field(
15649 &diagnostic,
15650 GIT_DIAGNOSTIC_MAX_BYTES,
15651 &mut snapshot.truncated,
15652 ));
15653}
15654
15655async fn run_git_bounded(
15656 root: &str,
15657 arguments: &[&str],
15658 output_limit: usize,
15659) -> io::Result<GitCommandOutput> {
15660 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15661}
15662
15663async fn read_git_history_bounded(
15664 root: &str,
15665 path: Option<&RepositoryPath>,
15666 before: Option<&GitObjectId>,
15667 limit: u16,
15668) -> Result<GitHistoryPage, NodeFailure> {
15669 let path = path
15670 .map(|path| {
15671 path.as_utf8().ok_or_else(|| {
15672 failure(
15673 NodeFailureCode::InvalidRepositoryPath,
15674 "git history path must use UTF-8",
15675 )
15676 })
15677 })
15678 .transpose()?
15679 .unwrap_or(".");
15680 let count = (usize::from(limit) + 1).to_string();
15681 let start = before
15682 .map(|revision| format!("{}^", revision.as_str()))
15683 .unwrap_or_else(|| "HEAD".to_owned());
15684 let arguments = [
15685 "--no-pager",
15686 "log",
15687 "--no-decorate",
15688 "--date=iso-strict",
15689 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15690 "-n",
15691 count.as_str(),
15692 start.as_str(),
15693 "--",
15694 path,
15695 ];
15696 let output = run_git_bounded(root, &arguments, 256 * 1_024)
15697 .await
15698 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15699 if output.timed_out {
15700 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15701 }
15702 if !output.success {
15703 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15704 }
15705 let mut commits = parse_git_history(&output.stdout);
15706 let has_more = commits.len() > usize::from(limit) || output.truncated;
15707 commits.truncate(usize::from(limit));
15708 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15709 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15710}
15711
15712fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15713 String::from_utf8_lossy(output)
15714 .split('\u{1e}')
15715 .filter_map(|record| {
15716 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15717 if fields.len() != 9 {
15718 return None;
15719 }
15720 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15721 let parents = if fields[1].is_empty() {
15722 Vec::new()
15723 } else {
15724 fields[1]
15725 .split(' ')
15726 .map(|value| GitObjectId::new(value.to_owned()))
15727 .collect::<Result<Vec<_>, _>>()
15728 .ok()?
15729 };
15730 Some(GitCommitDetails {
15731 id,
15732 parents,
15733 subject: bounded_git_text(fields[2], 512),
15734 author_name: bounded_git_text(fields[3], 256),
15735 author_email: bounded_git_text(fields[4], 320),
15736 authored_at: bounded_git_text(fields[5], 64),
15737 committer_name: bounded_git_text(fields[6], 256),
15738 committer_email: bounded_git_text(fields[7], 320),
15739 committed_at: bounded_git_text(fields[8], 64),
15740 signature_status: GitSignatureStatus::NoSignature,
15741 signer: None,
15742 })
15743 })
15744 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15745 .collect()
15746}
15747
15748fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15749 let normalized = value
15750 .chars()
15751 .map(|character| if character.is_control() { ' ' } else { character })
15752 .collect::<String>();
15753 if normalized.len() <= max_bytes {
15754 return normalized;
15755 }
15756 let mut boundary = max_bytes;
15757 while boundary > 0 && !normalized.is_char_boundary(boundary) {
15758 boundary -= 1;
15759 }
15760 normalized[..boundary].to_owned()
15761}
15762
15763async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15764 let path = request.path.as_ref().map(|path| {
15765 path.as_utf8()
15766 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15767 }).transpose()?;
15768 let mut arguments = match &request.mode {
15769 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15770 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15771 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15772 };
15773 if let GitDiffMode::Commit { revision } = &request.mode {
15774 arguments.push(revision.as_str());
15775 }
15776 arguments.push("--");
15777 if let Some(path) = path {
15778 arguments.push(path);
15779 }
15780 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15781 .await
15782 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15783 if output.timed_out {
15784 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15785 }
15786 if !output.success {
15787 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15788 }
15789 Ok(GitDiff {
15790 mode: request.mode,
15791 path: request.path,
15792 text: String::from_utf8_lossy(&output.stdout).into_owned(),
15793 truncated: output.truncated,
15794 })
15795}
15796
15797async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15798 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15799 ResponseEnvelope { request_id: envelope.request_id, result }
15800}
15801
15802async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15803 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15804 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15805 }
15806 let read_only = matches!(
15807 &request,
15808 NodeRequest::Snapshot
15809 | NodeRequest::Resync { .. }
15810 | NodeRequest::BrowseHostDirectories { .. }
15811 | NodeRequest::InspectWorkspace { .. }
15812 | NodeRequest::ReadWorkspaceFile { .. }
15813 | NodeRequest::ReadGitHistory { .. }
15814 | NodeRequest::ReadGitDiff { .. }
15815 | NodeRequest::CatalogNativeSessions { .. }
15816 | NodeRequest::PageNativeSessions { .. }
15817 | NodeRequest::PreviewNativeSession { .. }
15818 | NodeRequest::PreviewSessionRecord { .. }
15819 );
15820 let _mutation_guard = if read_only {
15821 None
15822 } else {
15823 let guard = shared.mutation_gate.lock().await;
15824 if shared.shutdown.load(Ordering::Acquire) {
15825 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
15826 }
15827 Some(guard)
15828 };
15829 match request {
15830 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
15831 event_sequence: shared.current_sequence(),
15832 controller: shared.controller_state(),
15833 snapshot: shared.snapshot(),
15834 }),
15835 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
15836 NodeRequest::ArmHarnessMcpReservation {
15837 reservation_id,
15838 activation_digest,
15839 spawn_spec,
15840 launch,
15841 expires_at_unix_ms,
15842 } => {
15843 shared.require_controller(connection_id, role)?;
15844 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15845 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15846 })?;
15847 let expires_at_unix_ms = registry
15848 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
15849 .await
15850 .map_err(harness_mcp_failure)?;
15851 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
15852 }
15853 NodeRequest::SpawnSpecWithHarnessMcp {
15854 reservation_id,
15855 activation_digest,
15856 spec,
15857 deadline_unix_ms,
15858 } => {
15859 shared.require_controller(connection_id, role)?;
15860 let receipt = shared
15861 .spawn_from_spec_with_harness_mcp(
15862 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
15863 )
15864 .await?;
15865 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
15866 }
15867 NodeRequest::ActivateHarnessMcpReservation {
15868 reservation_id,
15869 activation_digest,
15870 record_id,
15871 session,
15872 } => {
15873 shared.require_controller(connection_id, role)?;
15874 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
15875 current.instance_id == session.session.instance_id
15876 && current.generation == session.session.generation
15877 }).and_then(|current| current.process_id).ok_or_else(|| {
15878 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
15879 })?;
15880 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15881 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15882 })?.activate(
15883 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
15884 ).map_err(harness_mcp_failure)?;
15885 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
15886 }
15887 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
15888 shared.require_controller(connection_id, role)?;
15889 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15890 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15891 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
15892 if let Some(instance_id) = instance_id {
15893 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
15894 }
15895 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
15896 }
15897 NodeRequest::PutHarnessMcpReplyChunk {
15898 reservation_id, activation_digest, record_id, session, call_id,
15899 offset, final_chunk, chunk_hex,
15900 } => {
15901 shared.require_controller(connection_id, role)?;
15902 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15903 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15904 })?.put_reply_chunk(
15905 &reservation_id, &activation_digest, &record_id, &session, &call_id,
15906 offset, final_chunk, &chunk_hex,
15907 ).map_err(harness_mcp_failure)?;
15908 Ok(NodeResponse::ReplyChunkAccepted {
15909 reservation_id, activation_digest, record_id, session, call_id,
15910 next_offset, completed,
15911 })
15912 }
15913 NodeRequest::RejectHarnessMcpCall {
15914 reservation_id, activation_digest, record_id, session, call_id, reason,
15915 } => {
15916 shared.require_controller(connection_id, role)?;
15917 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15918 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15919 })?.reject_call(
15920 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
15921 ).map_err(harness_mcp_failure)?;
15922 Ok(NodeResponse::CallRejected {
15923 reservation_id, activation_digest, record_id, session, call_id,
15924 })
15925 }
15926 NodeRequest::BeginDeliveryStage { manifest } => {
15927 shared.require_controller(connection_id, role)?;
15928 shared.begin_delivery_stage(manifest)
15929 }
15930 NodeRequest::PutDeliveryBlobChunk {
15931 stage_id,
15932 blob_digest,
15933 offset,
15934 chunk_hex,
15935 } => {
15936 shared.require_controller(connection_id, role)?;
15937 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
15938 }
15939 NodeRequest::CommitDeliveryStage { stage_id } => {
15940 shared.require_controller(connection_id, role)?;
15941 let receipt = shared.commit_delivery_stage(stage_id)?;
15942 Ok(NodeResponse::DeliveryCommitted { receipt })
15943 }
15944 NodeRequest::AbortDeliveryStage { stage_id } => {
15945 shared.require_controller(connection_id, role)?;
15946 shared.abort_delivery_stage(&stage_id)?;
15947 Ok(NodeResponse::DeliveryStageAborted { stage_id })
15948 }
15949 NodeRequest::BrowseHostDirectories { directory, after } => {
15950 let listing = shared.browse_host_directories(directory, after).await?;
15951 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
15952 }
15953 NodeRequest::InspectWorkspace { workspace_id } => {
15954 let inspection = shared.inspect_workspace(workspace_id).await?;
15955 Ok(NodeResponse::WorkspaceInspected { inspection })
15956 }
15957 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
15958 let file = shared.read_workspace_file(workspace_id, path).await?;
15959 Ok(NodeResponse::WorkspaceFileRead { file })
15960 }
15961 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
15962 shared.require_controller(connection_id, role)?;
15963 let file = shared
15964 .write_workspace_file(workspace_id, path, expected_revision, text)
15965 .await?;
15966 Ok(NodeResponse::WorkspaceFileWritten { file })
15967 }
15968 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
15969 shared.require_controller(connection_id, role)?;
15970 let file = shared.create_workspace_file(workspace_id, path).await?;
15971 Ok(NodeResponse::WorkspaceFileCreated { file })
15972 }
15973 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
15974 shared.require_controller(connection_id, role)?;
15975 let entry = shared
15976 .create_workspace_directory(workspace_id.clone(), path)
15977 .await?;
15978 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
15979 }
15980 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
15981 if role != ClientRole::Operator {
15982 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
15983 }
15984 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
15985 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
15986 }
15987 let page = shared
15988 .read_git_history(workspace_id.clone(), path, before, limit)
15989 .await?;
15990 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
15991 }
15992 NodeRequest::ReadGitDiff { workspace_id, request } => {
15993 if role != ClientRole::Operator {
15994 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
15995 }
15996 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
15997 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
15998 }
15999 NodeRequest::CatalogNativeSessions { route, limit } => {
16000 if role != ClientRole::Operator {
16001 return Err(failure(
16002 NodeFailureCode::ObserverReadOnly,
16003 "native session catalog requires an operator connection",
16004 ));
16005 }
16006 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16007 return Err(failure(
16008 NodeFailureCode::InvalidRequest,
16009 "native session catalog limit is invalid",
16010 ));
16011 }
16012 let (entries, summary) = shared
16013 .catalog_native_sessions(route.clone(), limit)
16014 .await?;
16015 Ok(NodeResponse::NativeSessionsCataloged {
16016 route,
16017 entries,
16018 summary: Some(summary),
16019 })
16020 }
16021 NodeRequest::PageNativeSessions {
16022 route,
16023 window,
16024 catalog_revision,
16025 recent_cutoff_unix_ms,
16026 after_selection_id,
16027 limit,
16028 } => {
16029 if role != ClientRole::Operator {
16030 return Err(failure(
16031 NodeFailureCode::ObserverReadOnly,
16032 "native session catalog paging requires an operator connection",
16033 ));
16034 }
16035 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16036 || after_selection_id
16037 .as_deref()
16038 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16039 {
16040 return Err(failure(
16041 NodeFailureCode::InvalidRequest,
16042 "native session catalog page request is invalid",
16043 ));
16044 }
16045 let page = shared
16046 .page_native_sessions(
16047 route.clone(),
16048 window,
16049 catalog_revision,
16050 recent_cutoff_unix_ms,
16051 after_selection_id,
16052 limit,
16053 )
16054 .await?;
16055 Ok(NodeResponse::NativeSessionsPaged {
16056 route,
16057 page,
16058 })
16059 }
16060 NodeRequest::PreviewNativeSession {
16061 selection,
16062 message_limit,
16063 } => {
16064 if role != ClientRole::Operator {
16065 return Err(failure(
16066 NodeFailureCode::ObserverReadOnly,
16067 "native session preview requires an operator connection",
16068 ));
16069 }
16070 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16071 .contains(&message_limit)
16072 || selection.validate().is_err()
16073 {
16074 return Err(failure(
16075 NodeFailureCode::InvalidRequest,
16076 "native session preview request is invalid",
16077 ));
16078 }
16079 let preview = shared
16080 .preview_native_session(selection.clone(), message_limit)
16081 .await?;
16082 Ok(NodeResponse::NativeSessionPreviewed {
16083 selection,
16084 preview,
16085 })
16086 }
16087 NodeRequest::IndexNativeSession {
16088 selection,
16089 display_name,
16090 } => {
16091 shared.require_controller(connection_id, role)?;
16092 let record = shared
16093 .index_native_session(selection.clone(), display_name)
16094 .await?;
16095 Ok(NodeResponse::NativeSessionIndexed { selection, record })
16096 }
16097 NodeRequest::PreviewSessionRecord {
16098 record_id,
16099 message_limit,
16100 } => {
16101 if role != ClientRole::Operator {
16102 return Err(failure(
16103 NodeFailureCode::ObserverReadOnly,
16104 "native session preview requires an operator connection",
16105 ));
16106 }
16107 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16108 .contains(&message_limit)
16109 {
16110 return Err(failure(
16111 NodeFailureCode::InvalidRequest,
16112 "native session preview request is invalid",
16113 ));
16114 }
16115 let record = shared.record(&record_id)?;
16116 let working_directory = shared.workspace_root(&record.workspace_id)?;
16117 if !platform::roots_equal(
16118 &working_directory,
16119 windows_path_text(&record.canonical_root),
16120 ) {
16121 return Err(failure(
16122 NodeFailureCode::SessionRecordConflict,
16123 "managed session workspace root changed; refusing to preview another directory",
16124 ));
16125 }
16126 let identity = record.provider_session.clone().ok_or_else(|| {
16127 failure(
16128 NodeFailureCode::BackendOperationFailed,
16129 "native session preview is unavailable for this session record",
16130 )
16131 })?;
16132 if identity.key != ProviderSessionKey::SessionId {
16133 return Err(failure(
16134 NodeFailureCode::BackendOperationFailed,
16135 "native session preview is unavailable for this session identity",
16136 ));
16137 }
16138 let preview = shared
16139 .preview_native_session_by_session_id(
16140 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16141 )
16142 .await?;
16143 if preview.session_id != identity.id {
16144 return Err(failure(
16145 NodeFailureCode::SessionRecordConflict,
16146 "native session preview identity changed while loading",
16147 ));
16148 }
16149 shared.revalidate_session_record_preview(&record, &identity)?;
16150 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16151 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16152 record_id: record_id.clone(),
16153 summary: SessionHistorySummaryV1::from(&preview),
16154 });
16155 Ok(NodeResponse::SessionRecordPreviewed {
16156 record_id,
16157 preview,
16158 })
16159 }
16160 NodeRequest::AcquireController { lease_ms } => {
16161 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16162 Ok(NodeResponse::Controller { controller: Some(controller) })
16163 }
16164 NodeRequest::ReleaseController => {
16165 shared.release_controller(connection_id);
16166 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16167 }
16168 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16169 shared.require_controller(connection_id, role)?;
16170 let root = require_windows_path(root)?;
16171 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16172 let workspace = shared.register_workspace(workspace_id, root).await?;
16173 Ok(NodeResponse::WorkspaceRegistered { workspace })
16174 }
16175 NodeRequest::CreateStandaloneWorkspace {
16176 workspace_id,
16177 root,
16178 initial_branch,
16179 } => {
16180 shared.require_controller(connection_id, role)?;
16181 let root = require_windows_path(root)?;
16182 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16183 let workspace = shared
16184 .create_standalone_workspace(workspace_id, root, initial_branch)
16185 .await?;
16186 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16187 }
16188 NodeRequest::UnregisterWorkspace { workspace_id } => {
16189 shared.require_controller(connection_id, role)?;
16190 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16191 shared.unregister_workspace(&workspace_id)?;
16192 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16193 }
16194 NodeRequest::CreateWorktree {
16195 source_workspace_id,
16196 workspace_id,
16197 target_root,
16198 branch,
16199 base,
16200 } => {
16201 shared.require_controller(connection_id, role)?;
16202 let target_root = require_windows_path(target_root)?;
16203 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16204 let (worktree, workspace) = shared
16205 .create_worktree(
16206 source_workspace_id,
16207 workspace_id,
16208 target_root,
16209 branch,
16210 base,
16211 )
16212 .await?;
16213 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16214 }
16215 NodeRequest::RemoveWorktree {
16216 source_workspace_id,
16217 target_root,
16218 } => {
16219 shared.require_controller(connection_id, role)?;
16220 let native_target_root = require_windows_path(target_root.clone())?;
16221 shared.reject_managed_reservation(None, Some(&native_target_root))?;
16222 let workspace_id = shared
16223 .remove_worktree(source_workspace_id, native_target_root)
16224 .await?;
16225 Ok(NodeResponse::WorktreeRemoved {
16226 target_root,
16227 workspace_id,
16228 })
16229 }
16230 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16231 shared.require_controller(connection_id, role)?;
16232 if !terminal_size.is_valid() {
16233 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16234 }
16235 if let Some(prompt) = initial_prompt.as_deref() {
16236 validate_node_text("spawn initial prompt", prompt)?;
16237 }
16238 match shared
16239 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16240 .await
16241 {
16242 Ok(session) => {
16243 shared.mark_spawn_provisional(session.clone());
16244 Ok(NodeResponse::SpawnAccepted { session })
16245 }
16246 Err(err) => {
16247 tracing::warn!(
16248 %provider,
16249 %workspace_id,
16250 ?mode,
16251 code = ?err.code,
16252 message = %err.message,
16253 "session spawn refused"
16254 );
16255 Err(err)
16256 }
16257 }
16258 }
16259 NodeRequest::SpawnSpec { spec } => {
16260 shared.require_controller(connection_id, role)?;
16261 let receipt = shared.spawn_from_spec(spec).await?;
16262 Ok(NodeResponse::SpawnSpecAccepted { receipt })
16263 }
16264 NodeRequest::SpawnManagedWorktree { request } => {
16265 shared.require_controller(connection_id, role)?;
16266 let receipt = shared.spawn_managed_worktree(request).await?;
16267 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16268 }
16269 NodeRequest::SpawnManagedWorktreeV2 { request } => {
16270 shared.require_controller(connection_id, role)?;
16271 let receipt = shared.spawn_managed_worktree_v2(request).await?;
16272 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16273 }
16274 NodeRequest::CleanupManagedWorktree { lease_id } => {
16275 shared.require_controller(connection_id, role)?;
16276 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16277 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16278 }
16279 NodeRequest::DiscoverHistory { session, limit } => {
16280 controlled_session(shared, connection_id, role, &session)?;
16281 let candidates = shared.discover_history(&session, limit).await?;
16282 Ok(NodeResponse::HistoryDiscovered {
16283 session,
16284 candidates,
16285 })
16286 }
16287 NodeRequest::LoadHistory {
16288 session,
16289 candidate_id,
16290 } => {
16291 controlled_session(shared, connection_id, role, &session)?;
16292 let loaded = shared
16293 .load_history(&session, candidate_id)
16294 .await?;
16295 Ok(NodeResponse::HistoryLoaded {
16296 session,
16297 session_id: loaded.session_id,
16298 message_count: loaded.message_count,
16299 completed_turn_count: loaded.completed_turn_count,
16300 })
16301 }
16302 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16303 shared.require_controller(connection_id, role)?;
16304 let context = shared
16305 .export_context_pack_for_session_record(&record_id, &session, false)
16306 .await?;
16307 Ok(NodeResponse::ContextPackForSessionRecordExported {
16308 record_id,
16309 session,
16310 context,
16311 })
16312 }
16313 NodeRequest::ExportContextPack { session } => {
16314 controlled_session(shared, connection_id, role, &session)?;
16315 let context = shared.export_context_pack(&session).await?;
16316 Ok(NodeResponse::ContextPackExported { context })
16317 }
16318 NodeRequest::ForgetContextPack { context_id } => {
16319 shared.require_controller(connection_id, role)?;
16320 shared.forget_context_pack(&context_id)?;
16321 Ok(NodeResponse::ContextPackForgotten { context_id })
16322 }
16323 NodeRequest::ResolveDurableContextPack { context_id } => {
16324 let context = shared
16328 .context_catalog
16329 .read()
16330 .unwrap_or_else(|poisoned| poisoned.into_inner())
16331 .get(&context_id)
16332 .map(|pack| pack.receipt().clone())
16333 .ok_or_else(|| {
16334 failure(
16335 NodeFailureCode::UnknownContextPack,
16336 "context pack is not present",
16337 )
16338 })?;
16339 Ok(NodeResponse::DurableContextPackResolved { context })
16340 }
16341 NodeRequest::ReadContextPack { digest } => {
16342 let (id, byte_len, bytes) = shared
16347 .context_catalog
16348 .read()
16349 .unwrap_or_else(|poisoned| poisoned.into_inner())
16350 .get_by_digest(&digest)
16351 .map(|pack| {
16352 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16353 })
16354 .ok_or_else(|| {
16355 failure(
16356 NodeFailureCode::UnknownContextPack,
16357 &format!("context pack {digest} not on this node"),
16358 )
16359 })?;
16360 tracing::debug!(
16361 digest = digest.as_str(),
16362 byte_len,
16363 "read context pack bytes",
16364 );
16365 Ok(NodeResponse::ContextPackBytesRead {
16366 pack: ContextPackBytesRead {
16367 digest,
16368 id,
16369 byte_len,
16370 bytes,
16371 },
16372 })
16373 }
16374 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16375 let provider = controlled_session(shared, connection_id, role, &session)?;
16376 if let Some(prompt) = initial_prompt.as_deref() {
16377 validate_node_text("resume initial prompt", prompt)?;
16378 }
16379 let runtime_requirement = if initial_prompt.is_some() {
16380 ProviderRuntimeRequirement::ResumeWithPrompt
16381 } else {
16382 ProviderRuntimeRequirement::Resume
16383 };
16384 shared.require_session_runtime_policy(&session, runtime_requirement)?;
16385 let runtime_policy = shared
16386 .admit_provider_runtime(&provider, runtime_requirement)
16387 .await?;
16388 let working_directory = shared.bound_workspace_root(&session)?;
16389 let request = ResumeLaunchRequest {
16390 working_directory,
16391 terminal_size,
16392 initial_prompt,
16393 };
16394 request
16395 .validate()
16396 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16397 let command = shared.prepare_command(ControlCommand::Resume {
16398 instance_id: session.session.instance_id,
16399 target: ResumeTarget::CurrentProvider,
16400 runtime_policy,
16401 request,
16402 });
16403 shared.arm_resume(&session, command.id, runtime_policy)?;
16404 let dispatch = shared.dispatch_envelope(command);
16405 if let Err(error) = dispatch {
16406 shared.clear_armed_resume(&session);
16407 return Err(error);
16408 }
16409 Ok(NodeResponse::Accepted)
16410 }
16411 NodeRequest::RenameSessionRecord {
16412 record_id,
16413 display_name,
16414 } => {
16415 shared.require_controller(connection_id, role)?;
16416 let record = shared.rename_session_record(&record_id, display_name)?;
16417 Ok(NodeResponse::SessionRecordUpdated { record })
16418 }
16419 NodeRequest::SetSessionTask {
16420 record_id,
16421 expected_revision,
16422 target,
16423 } => {
16424 shared.require_controller(connection_id, role)?;
16425 let record = shared.set_session_task(&record_id, expected_revision, target)?;
16426 Ok(NodeResponse::SessionRecordUpdated { record })
16427 }
16428 NodeRequest::IndexProviderSession {
16429 workspace_id,
16430 provider,
16431 identity,
16432 display_name,
16433 } => {
16434 shared.require_controller(connection_id, role)?;
16435 let record = shared.index_provider_session(
16436 workspace_id,
16437 provider,
16438 identity,
16439 display_name,
16440 )?;
16441 Ok(NodeResponse::ProviderSessionIndexed { record })
16442 }
16443 NodeRequest::ResumeSessionRecord {
16444 record_id,
16445 terminal_size,
16446 initial_prompt,
16447 } => {
16448 shared.require_controller(connection_id, role)?;
16449 if let Some(prompt) = initial_prompt.as_deref() {
16450 validate_node_text("managed resume initial prompt", prompt)?;
16451 }
16452 let (record, session) = shared
16453 .resume_session_record(
16454 &record_id,
16455 terminal_size,
16456 initial_prompt,
16457 )
16458 .await?;
16459 Ok(NodeResponse::SessionRecordResumed { record, session })
16460 }
16461 NodeRequest::ForgetSessionRecord { record_id } => {
16462 shared.require_controller(connection_id, role)?;
16463 shared.forget_session_record(&record_id).await?;
16464 Ok(NodeResponse::SessionRecordForgotten { record_id })
16465 }
16466 NodeRequest::Prompt { session, text } => {
16467 let agent_id = controlled_session(shared, connection_id, role, &session)?;
16468 validate_node_text("prompt", &text)?;
16469 shared.require_session_runtime_policy(
16470 &session,
16471 ProviderRuntimeRequirement::SemanticPrompt,
16472 )?;
16473 let framing = prompt_framing(&agent_id);
16474 shared
16475 .dispatch_input_bounded(
16476 &session,
16477 InputAction::SubmitPrompt(PromptPayload { text, framing }),
16478 )
16479 .await?;
16480 Ok(NodeResponse::Accepted)
16481 }
16482 NodeRequest::Paste { session, text } => {
16483 controlled_session(shared, connection_id, role, &session)?;
16484 validate_node_text("paste", &text)?;
16485 shared.require_session_runtime_policy(
16486 &session,
16487 ProviderRuntimeRequirement::SemanticPrompt,
16488 )?;
16489 shared
16490 .dispatch_input_bounded(
16491 &session,
16492 InputAction::InsertDraft(PromptPayload {
16493 text,
16494 framing: PromptFraming::BracketedPaste,
16495 }),
16496 )
16497 .await?;
16498 Ok(NodeResponse::Accepted)
16499 }
16500 NodeRequest::Input { session, text } => {
16501 controlled_session(shared, connection_id, role, &session)?;
16502 validate_node_text("terminal input", &text)?;
16503 shared
16504 .dispatch_input_bounded(
16505 &session,
16506 InputAction::TerminalText(TerminalText { text }),
16507 )
16508 .await?;
16509 Ok(NodeResponse::Accepted)
16510 }
16511 NodeRequest::TerminalBytes { session, bytes } => {
16512 controlled_session(shared, connection_id, role, &session)?;
16513 shared.sessionize_spawn(session.session.instance_id);
16514 let action = terminal_bytes_action(bytes)?;
16515 shared
16516 .dispatch_input_bounded(&session, action)
16517 .await?;
16518 Ok(NodeResponse::Accepted)
16519 }
16520 NodeRequest::TerminalControl { session, control } => {
16521 controlled_session(shared, connection_id, role, &session)?;
16522 shared
16523 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16524 .await?;
16525 Ok(NodeResponse::Accepted)
16526 }
16527 NodeRequest::Resize { session, size } => {
16528 controlled_session(shared, connection_id, role, &session)?;
16529 shared.sessionize_spawn(session.session.instance_id);
16530 if !size.is_valid() {
16531 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16532 }
16533 shared.dispatch_resize_bounded(&session, size).await?;
16534 Ok(NodeResponse::Accepted)
16535 }
16536 NodeRequest::Interrupt { session } => {
16537 controlled_session(shared, connection_id, role, &session)?;
16538 shared
16539 .dispatch_input_bounded(
16540 &session,
16541 InputAction::TerminalControl(TerminalControl::Interrupt),
16542 )
16543 .await?;
16544 Ok(NodeResponse::Accepted)
16545 }
16546 NodeRequest::Stop { session, force } => {
16547 controlled_session(shared, connection_id, role, &session)?;
16548 shared.sessionize_spawn(session.session.instance_id);
16549 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16550 Ok(NodeResponse::Accepted)
16551 }
16552 NodeRequest::Remove { session } => {
16553 controlled_session(shared, connection_id, role, &session)?;
16554 shared.remove_session(&session).await?;
16555 Ok(NodeResponse::Accepted)
16556 }
16557 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16558 shared.require_controller(connection_id, role)?;
16559 let snapshot = shared.require_acp_session(&session)?;
16560 let interaction = snapshot
16561 .provider
16562 .interactions
16563 .iter()
16564 .find(|interaction| {
16565 interaction_correlation(
16566 session.session.instance_id,
16567 session.session.generation,
16568 interaction.id.0,
16569 ) == correlation_id
16570 })
16571 .ok_or_else(|| failure(
16572 NodeFailureCode::InvalidRequest,
16573 "unknown interaction correlation",
16574 ))?;
16575 if interaction.status != ProviderInteractionStatus::Pending {
16576 return Err(failure(
16577 NodeFailureCode::BackendOperationFailed,
16578 "interaction has already been resolved",
16579 ));
16580 }
16581 response
16582 .validate_for(interaction.interaction_kind)
16583 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16584 let interaction_id = interaction.id;
16585 let response_kind = response.kind();
16586 let response_outcome = response.outcome();
16587 shared.dispatch(ControlCommand::ResolveInteraction {
16588 instance_id: session.session.instance_id,
16589 generation: session.session.generation,
16590 interaction_id,
16591 response,
16592 })?;
16593 tracing::info!(
16599 node_id = %shared.node_id,
16600 session = ?session.session,
16601 correlation_id = %correlation_id,
16602 interaction_id = interaction_id.0,
16603 response_kind = ?response_kind,
16604 outcome = ?response_outcome,
16605 "interaction resolved",
16606 );
16607 Ok(NodeResponse::Accepted)
16608 }
16609 NodeRequest::SetSessionMode { session, mode_id } => {
16610 shared.require_controller(connection_id, role)?;
16611 shared.require_acp_session(&session)?;
16612 shared.dispatch(ControlCommand::SetSessionMode {
16613 instance_id: session.session.instance_id,
16614 mode_id,
16615 })?;
16616 Ok(NodeResponse::Accepted)
16617 }
16618 NodeRequest::SetSessionConfigOption {
16619 session,
16620 option_id,
16621 value_json,
16622 } => {
16623 shared.require_controller(connection_id, role)?;
16624 shared.require_acp_session(&session)?;
16625 shared.dispatch(ControlCommand::SetSessionConfigOption {
16626 instance_id: session.session.instance_id,
16627 option_id,
16628 value_json,
16629 })?;
16630 Ok(NodeResponse::Accepted)
16631 }
16632 NodeRequest::SetSessionModel { session, model_id } => {
16633 shared.require_controller(connection_id, role)?;
16634 shared.require_acp_session(&session)?;
16635 shared.dispatch(ControlCommand::SetSessionModel {
16636 instance_id: session.session.instance_id,
16637 model_id,
16638 })?;
16639 Ok(NodeResponse::Accepted)
16640 }
16641 NodeRequest::Shutdown => {
16642 shared.require_controller(connection_id, role)?;
16643 shared.begin_shutdown_locked().await?;
16644 Ok(NodeResponse::ShuttingDown)
16645 }
16646 }
16647}
16648
16649fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16650 shared.require_controller(connection_id, role)?;
16651 shared.validate_address(session)
16652}
16653
16654fn select_history_candidate(
16660 candidates: &[HistoryCandidateSummary],
16661 provider_session_id: Option<&str>,
16662) -> Result<String, NodeFailure> {
16663 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16664 Some(id) => candidate.session_id_hint == id,
16665 None => true,
16666 });
16667 let candidate = matches.next().ok_or_else(|| {
16668 failure(
16669 NodeFailureCode::SessionRecordConflict,
16670 if provider_session_id.is_some() {
16671 "no history candidate matches the managed provider session"
16672 } else {
16673 "no history candidate exists for the managed session"
16674 },
16675 )
16676 })?;
16677 if matches.next().is_some() {
16678 return Err(failure(
16679 NodeFailureCode::SessionRecordConflict,
16680 if provider_session_id.is_some() {
16681 "history identity is ambiguous for the managed provider session"
16682 } else {
16683 "history is ambiguous without a verified provider session identity"
16684 },
16685 ));
16686 }
16687 Ok(candidate.id.clone())
16688}
16689
16690fn resolved_spawn_profile_summary(
16691 profile: &SpawnProfileDefaults,
16692 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16693) -> Option<SpawnProfileSummary> {
16694 let environment_profile = match profile.environment_profile_id.as_ref() {
16695 None => None,
16696 Some(profile_id) => {
16697 let binding = environment_profiles.get(profile_id)?;
16698 if binding.provider != profile.provider
16699 || binding.native_profile_id(profile.mode).is_none()
16700 {
16701 return None;
16702 }
16703 Some(ResolvedEnvironmentProfileReceipt {
16704 profile_id: binding.id.clone(),
16705 profile_revision: binding.revision.clone(),
16706 network_allowlist: None,
16707 browser_profile_id: None,
16708 })
16709 }
16710 };
16711 Some(SpawnProfileSummary {
16712 id: profile.profile_id.clone(),
16713 revision: profile.revision.clone(),
16714 environment_profile,
16715 })
16716}
16717
16718fn session_record_export_target_matches(
16737 expected: &ManagedSessionRecord,
16738 current: &ManagedSessionRecord,
16739 session: &SessionAddress,
16740 allow_clean_detachment: bool,
16741) -> bool {
16742 if current == expected && current.active_session.as_ref() == Some(session) {
16743 return current.state == ManagedSessionState::Live;
16744 }
16745 allow_clean_detachment
16746 && current.state == ManagedSessionState::Dormant
16747 && current.active_session.is_none()
16748 && current.record_id == expected.record_id
16749 && current.display_name == expected.display_name
16750 && current.provider == expected.provider
16751 && current.mode == expected.mode
16752 && current.workspace_id == expected.workspace_id
16753 && current.canonical_root == expected.canonical_root
16754 && current.provider_session == expected.provider_session
16755 && current.environment_profile == expected.environment_profile
16756 && current.bundle == expected.bundle
16757 && current.context_id == expected.context_id
16758 && current.context == expected.context
16759 && current.exported_context == expected.exported_context
16760 && current.task_binding == expected.task_binding
16761 && current.created_at_unix_ms == expected.created_at_unix_ms
16762 && current.last_error == expected.last_error
16763}
16764
16765fn session_record_context_export_binding_is_exact(
16766 expected: &ManagedSessionRecord,
16767 current: &ManagedSessionRecord,
16768 identity: Option<&ProviderSessionIdentity>,
16769 session: &SessionAddress,
16770 binding: &SessionBinding,
16771 provider: &AgentId,
16772 current_root: &str,
16773 allow_clean_detachment: bool,
16774) -> bool {
16775 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
16776 && current.provider_session.as_ref() == identity
16777 && current.workspace_id == session.workspace_id
16778 && provider == ¤t.provider
16779 && binding.workspace_id == session.workspace_id
16780 && binding.generation == session.session.generation
16781 && binding.record_id.as_ref() == Some(¤t.record_id)
16782 && platform::roots_equal(
16783 current_root,
16784 windows_path_text(¤t.canonical_root),
16785 )
16786}
16787
16788fn session_record_context_export_source_is_usable(
16789 record: &ManagedSessionRecord,
16790 status: &SessionStatus,
16791 allow_clean_detachment: bool,
16792) -> bool {
16793 (record.state == ManagedSessionState::Live
16794 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
16795 && context_pack_source_status_is_usable(status)
16796}
16797
16798fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
16799 PromptFraming::BracketedPaste
16800}
16801
16802fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
16803 if text.len() > MAX_NODE_TEXT_BYTES {
16804 return Err(failure(
16805 NodeFailureCode::InvalidRequest,
16806 &format!(
16807 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
16808 text.len(),
16809 ),
16810 ));
16811 }
16812 Ok(())
16813}
16814
16815fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
16816 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
16817 return Err(failure(
16818 NodeFailureCode::InvalidRequest,
16819 &format!(
16820 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
16821 bytes.len(),
16822 ),
16823 ));
16824 }
16825 Ok(())
16826}
16827
16828fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
16829 validate_terminal_bytes(&bytes)?;
16830 Ok(InputAction::TerminalBytes(bytes))
16831}
16832
16833fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
16834 if root.is_empty()
16835 || root.len() > MAX_WORKSPACE_ROOT_BYTES
16836 || root.chars().any(char::is_control)
16837 {
16838 return Err(failure(
16839 NodeFailureCode::InvalidWorkspaceRoot,
16840 &format!(
16841 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
16842 ),
16843 ));
16844 }
16845 Ok(())
16846}
16847
16848fn canonical_standalone_workspace_candidate(
16849 workspace_id: &WorkspaceId,
16850 root: &str,
16851) -> Result<String, NodeServerError> {
16852 let path = Path::new(root);
16853 match std::fs::symlink_metadata(path) {
16854 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
16855 .map(|workspace| workspace.canonical_root),
16856 Err(error) if error.kind() == io::ErrorKind::NotFound => {
16857 if !path.is_absolute() {
16858 return Err(NodeServerError::InvalidWorkspaceRoot {
16859 workspace_id: workspace_id.clone(),
16860 path: root.to_owned(),
16861 message: "path is not absolute".to_owned(),
16862 });
16863 }
16864 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16865 workspace_id: workspace_id.clone(),
16866 path: root.to_owned(),
16867 message: "path has no standalone directory name".to_owned(),
16868 })?;
16869 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16870 workspace_id: workspace_id.clone(),
16871 path: root.to_owned(),
16872 message: "path has no parent directory".to_owned(),
16873 })?;
16874 let canonical = std::fs::canonicalize(parent)
16875 .map(|parent| parent.join(name))
16876 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
16877 workspace_id: workspace_id.clone(),
16878 path: root.to_owned(),
16879 message: error.to_string(),
16880 })?;
16881 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
16882 NodeServerError::InvalidWorkspaceRoot {
16883 workspace_id: workspace_id.clone(),
16884 path: path.to_string_lossy().into_owned(),
16885 message: "canonical path is not valid Unicode".to_owned(),
16886 }
16887 })?;
16888 let canonical_root = platform::normalize_canonical_root(canonical_root);
16889 validate_workspace_root(workspace_id, &canonical_root)?;
16890 Ok(canonical_root)
16891 }
16892 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
16893 workspace_id: workspace_id.clone(),
16894 path: root.to_owned(),
16895 message: error.to_string(),
16896 }),
16897 }
16898}
16899
16900fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
16901 let Some(revision) = revision else {
16902 return Ok(());
16903 };
16904 if revision.is_empty()
16905 || revision.len() > MAX_REPOSITORY_PATH_BYTES
16906 || revision.chars().any(char::is_control)
16907 {
16908 return Err(failure(
16909 NodeFailureCode::InvalidRequest,
16910 &format!(
16911 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
16912 ),
16913 ));
16914 }
16915 Ok(())
16916}
16917
16918fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16919 let code = match error.kind {
16920 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
16921 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
16922 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
16923 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
16924 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
16925 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
16926 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
16927 };
16928 failure(code, &error.message)
16929}
16930
16931fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
16932 let code = match error.kind {
16933 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
16934 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
16935 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
16936 NodeFailureCode::BackendOperationFailed
16937 }
16938 StandaloneWorkspaceErrorKind::RecoveryRequired => {
16939 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
16940 }
16941 };
16942 failure(code, error.message)
16943}
16944
16945fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16946 let (code, message) = match error.kind {
16947 GitWorktreeErrorKind::Invalid => (
16948 NodeFailureCode::InvalidRequest,
16949 "managed Git input is invalid",
16950 ),
16951 GitWorktreeErrorKind::NotRepository => (
16952 NodeFailureCode::NotGitRepository,
16953 "managed source is not a Git repository",
16954 ),
16955 GitWorktreeErrorKind::Conflict => (
16956 NodeFailureCode::WorktreeConflict,
16957 "managed Git identity conflicts with existing repository state",
16958 ),
16959 GitWorktreeErrorKind::Protected => (
16960 NodeFailureCode::ManagedWorktreeOwnershipConflict,
16961 "managed worktree ownership validation failed",
16962 ),
16963 GitWorktreeErrorKind::Dirty => (
16964 NodeFailureCode::WorktreeDirty,
16965 "managed worktree contains uncommitted changes",
16966 ),
16967 GitWorktreeErrorKind::Locked => (
16968 NodeFailureCode::WorktreeLocked,
16969 "managed worktree is locked",
16970 ),
16971 GitWorktreeErrorKind::Failed => (
16972 NodeFailureCode::BackendOperationFailed,
16973 "managed Git operation failed",
16974 ),
16975 };
16976 NodeFailure { code, message: message.to_owned() }
16977}
16978
16979fn managed_spawn_receipt(
16980 resolved: &ResolvedSpawnSpec,
16981 incarnation_id: NodeIncarnationId,
16982 session: SessionAddress,
16983 lease: ManagedWorktreeLeaseSnapshot,
16984 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
16985 bundle: Option<ResolvedBundleReceipt>,
16986 context: Option<ResolvedContextPackReceipt>,
16987) -> ManagedWorktreeSpawnReceipt {
16988 let mut managed = resolved.clone();
16989 managed.target.worktree_id = Some(lease.workspace_id.clone());
16990 ManagedWorktreeSpawnReceipt {
16991 spawn: managed.receipt_with_materialization(
16992 incarnation_id,
16993 session,
16994 environment_profile,
16995 bundle,
16996 context,
16997 ),
16998 lease,
16999 }
17000}
17001
17002fn validate_selected_worktree(
17003 source_root: &str,
17004 selected_root: &str,
17005 worktrees: &[NativeGitWorktreeSnapshot],
17006) -> Result<(), NodeFailure> {
17007 if !worktrees
17008 .iter()
17009 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17010 {
17011 return Err(failure(
17012 NodeFailureCode::WorktreeProtected,
17013 "source workspace is not an authoritative non-bare Git worktree root",
17014 ));
17015 }
17016 if !worktrees.iter().any(|worktree| {
17017 worktree_paths_equal(&worktree.path, selected_root)
17018 && !worktree.is_main
17019 && !worktree.is_bare
17020 }) {
17021 return Err(failure(
17022 NodeFailureCode::WorktreeProtected,
17023 "selected workspace is not an eligible Git worktree in the source repository",
17024 ));
17025 }
17026 Ok(())
17027}
17028
17029fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17030 let code = match error.kind() {
17031 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17032 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17033 NodeFailureCode::RepositoryPathUnsafe
17034 }
17035 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17036 WorkspaceFileReadErrorKind::NotRegularFile => {
17037 NodeFailureCode::RepositoryFileNotRegular
17038 }
17039 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17040 NodeFailureCode::RepositoryFileReadFailed
17041 }
17042 WorkspaceFileReadErrorKind::RevisionConflict => {
17043 NodeFailureCode::RepositoryFileRevisionConflict
17044 }
17045 WorkspaceFileReadErrorKind::AlreadyExists
17046 | WorkspaceFileReadErrorKind::ParentNotFound
17047 | WorkspaceFileReadErrorKind::ParentNotDirectory
17048 | WorkspaceFileReadErrorKind::Canceled => {
17049 NodeFailureCode::RepositoryFileReadFailed
17050 }
17051 };
17052 failure(code, "repository file read failed")
17053}
17054
17055fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17056 let code = match error.kind() {
17057 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17058 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17059 NodeFailureCode::RepositoryPathUnsafe
17060 }
17061 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17062 WorkspaceFileReadErrorKind::NotRegularFile => {
17063 NodeFailureCode::RepositoryFileNotRegular
17064 }
17065 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17066 NodeFailureCode::RepositoryFileWriteFailed
17067 }
17068 WorkspaceFileReadErrorKind::RevisionConflict => {
17069 NodeFailureCode::RepositoryFileRevisionConflict
17070 }
17071 WorkspaceFileReadErrorKind::AlreadyExists
17072 | WorkspaceFileReadErrorKind::ParentNotFound
17073 | WorkspaceFileReadErrorKind::ParentNotDirectory
17074 | WorkspaceFileReadErrorKind::Canceled => {
17075 NodeFailureCode::RepositoryFileWriteFailed
17076 }
17077 };
17078 failure(code, "repository file write failed")
17079}
17080
17081async fn settle_workspace_entry_create<T>(
17082 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17083 commit_state: Arc<AtomicU8>,
17084 deadline: Duration,
17085 timeout_message: &'static str,
17086 task_failure_message: &'static str,
17087) -> Result<T, NodeFailure> {
17088 let joined = match timeout(deadline, &mut task).await {
17089 Ok(joined) => joined,
17090 Err(_) => {
17091 if commit_state
17092 .compare_exchange(
17093 WORKSPACE_ENTRY_CREATE_PENDING,
17094 WORKSPACE_ENTRY_CREATE_CANCELED,
17095 Ordering::AcqRel,
17096 Ordering::Acquire,
17097 )
17098 .is_ok()
17099 {
17100 return Err(failure(
17101 NodeFailureCode::RepositoryEntryCreateTimedOut,
17102 timeout_message,
17103 ));
17104 }
17105 debug_assert_eq!(
17106 commit_state.load(Ordering::Acquire),
17107 WORKSPACE_ENTRY_CREATE_COMMITTING,
17108 );
17109 task.await
17110 }
17111 };
17112 joined
17113 .map_err(|_| {
17114 failure(
17115 NodeFailureCode::RepositoryEntryCreateFailed,
17116 task_failure_message,
17117 )
17118 })?
17119 .map_err(workspace_entry_create_failure)
17120}
17121
17122fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17123 let code = match error.kind() {
17124 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17125 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17126 NodeFailureCode::RepositoryPathUnsafe
17127 }
17128 WorkspaceFileReadErrorKind::AlreadyExists => {
17129 NodeFailureCode::RepositoryEntryAlreadyExists
17130 }
17131 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17132 NodeFailureCode::RepositoryParentNotFound
17133 }
17134 WorkspaceFileReadErrorKind::ParentNotDirectory
17135 | WorkspaceFileReadErrorKind::NotRegularFile => {
17136 NodeFailureCode::RepositoryParentNotDirectory
17137 }
17138 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17139 NodeFailureCode::RepositoryEntryCreateFailed
17140 }
17141 WorkspaceFileReadErrorKind::RevisionConflict => {
17142 NodeFailureCode::RepositoryEntryCreateFailed
17143 }
17144 WorkspaceFileReadErrorKind::Canceled => {
17145 NodeFailureCode::RepositoryEntryCreateTimedOut
17146 }
17147 };
17148 failure(code, "repository entry create failed")
17149}
17150
17151fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17152 let value = digest(&SHA256, bytes)
17153 .as_ref()
17154 .iter()
17155 .map(|byte| format!("{byte:02x}"))
17156 .collect();
17157 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17158}
17159
17160fn managed_spawn_request_digest_v2(
17161 request: &ManagedWorktreeSpawnRequestV2,
17162) -> Result<String, NodeFailure> {
17163 let encoded = serde_json::to_vec(request).map_err(|_| {
17164 failure(
17165 NodeFailureCode::InvalidRequest,
17166 "managed worktree V2 request could not be canonicalized",
17167 )
17168 })?;
17169 let mut value = String::with_capacity(71);
17170 value.push_str("sha256:");
17171 for byte in digest(&SHA256, &encoded).as_ref() {
17172 use std::fmt::Write as _;
17173 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17174 }
17175 Ok(value)
17176}
17177
17178fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17179 let code = match error {
17180 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17181 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17182 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17183 NativeSessionPreviewError::UnsupportedProvider
17184 | NativeSessionPreviewError::PreviewUnavailable
17185 | NativeSessionPreviewError::SessionNotFound
17186 | NativeSessionPreviewError::AmbiguousSession => {
17187 NodeFailureCode::BackendOperationFailed
17188 }
17189 };
17190 failure(code, "native session preview failed")
17191}
17192
17193fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17194 let code = match error.kind() {
17195 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17196 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17197 };
17198 failure(code, "host directory browse failed")
17199}
17200
17201fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17202 let code = match error {
17203 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17204 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17205 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17206 NodeFailureCode::DeliveryStageConflict
17207 }
17208 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17209 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17210 NodeFailureCode::DeliveryChunkOutOfOrder
17211 }
17212 DeliveryStoreError::BlobDigestMismatch => {
17213 NodeFailureCode::DeliveryBlobDigestMismatch
17214 }
17215 DeliveryStoreError::BundleDigestMismatch => {
17216 NodeFailureCode::DeliveryBundleDigestMismatch
17217 }
17218 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17219 DeliveryStoreError::Unavailable
17220 | DeliveryStoreError::Corrupt
17221 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17222 };
17223 failure(code, "delivery operation failed")
17224}
17225
17226fn validate_workspace_root(
17227 workspace_id: &WorkspaceId,
17228 canonical_root: &str,
17229) -> Result<(), NodeServerError> {
17230 #[cfg(windows)]
17231 if canonical_root.starts_with(r"\\") {
17232 return Err(NodeServerError::InvalidWorkspaceRoot {
17233 workspace_id: workspace_id.clone(),
17234 path: canonical_root.to_owned(),
17235 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17236 });
17237 }
17238 if !platform::workspace_root_supported(canonical_root)
17239 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17240 {
17241 return Err(NodeServerError::InvalidWorkspaceRoot {
17242 workspace_id: workspace_id.clone(),
17243 path: canonical_root.to_owned(),
17244 message: "canonical path must be absolute, bounded, and free of control characters"
17245 .to_owned(),
17246 });
17247 }
17248 Ok(())
17249}
17250
17251fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17252 NodeFailure {
17253 code,
17254 message: node_failure_category(code).to_owned(),
17255 }
17256}
17257
17258fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17259 let code = match error {
17260 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17261 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17262 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17263 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17264 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17265 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17266 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17267 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17268 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17269 };
17270 failure(code, "harness MCP proxy operation failed")
17271}
17272
17273static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17277
17278fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17285 let trace = launch.trace.as_ref()?;
17286 let trace_dir = std::env::var(&trace.dir_env).ok()?;
17287 if trace_dir.is_empty() {
17288 return None;
17289 }
17290 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17291 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17292 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17293}
17294
17295fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17296 let remaining = deadline.saturating_duration_since(Instant::now());
17297 if remaining.is_zero() {
17298 Err(failure(
17299 NodeFailureCode::SpawnDeadlineExceeded,
17300 "spawn deadline elapsed",
17301 ))
17302 } else {
17303 Ok(remaining)
17304 }
17305}
17306
17307fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17308 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17309 match deadline {
17310 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17311 None => Ok(bounded),
17312 }
17313}
17314
17315fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17316 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17317 failure(
17318 NodeFailureCode::SpawnDeadlineExceeded,
17319 "spawn dispatch exceeded the spawn deadline",
17320 )
17321 } else {
17322 error
17323 }
17324}
17325
17326fn spawn_runtime_capabilities(
17327 capabilities: &SpawnRequiredCapabilities,
17328) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17329 capabilities
17330 .iter()
17331 .map(|capability| match capability.as_str() {
17332 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17333 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17334 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17335 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17336 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17337 }
17338 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17339 _ => Err(failure(
17340 NodeFailureCode::UnsupportedSpawnCapability,
17341 "spawn required capability is unknown",
17342 )),
17343 })
17344 .collect()
17345}
17346
17347fn persistence_failure(_error: io::Error) -> NodeFailure {
17348 NodeFailure {
17349 code: NodeFailureCode::BackendOperationFailed,
17350 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17351 }
17352}
17353
17354fn node_failure_category(code: NodeFailureCode) -> &'static str {
17355 match code {
17356 NodeFailureCode::InvalidRequest => "invalid-request",
17357 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17358 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17359 NodeFailureCode::ReservationNotFound => "reservation-not-found",
17360 NodeFailureCode::ReservationConflict => "reservation-conflict",
17361 NodeFailureCode::ReservationExpired => "reservation-expired",
17362 NodeFailureCode::BindingMismatch => "binding-mismatch",
17363 NodeFailureCode::NotActivated => "not-activated",
17364 NodeFailureCode::CallNotFound => "call-not-found",
17365 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17366 NodeFailureCode::ResponseTooLarge => "response-too-large",
17367 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17368 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17369 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17370 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17371 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17372 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17373 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17374 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17375 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17376 NodeFailureCode::Unauthorized => "unauthorized",
17377 NodeFailureCode::ObserverReadOnly => "observer-read-only",
17378 NodeFailureCode::ControllerBusy => "controller-busy",
17379 NodeFailureCode::ControllerRequired => "controller-required",
17380 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17381 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17382 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17383 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17384 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17385 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17386 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17387 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17388 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17389 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17390 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17391 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17392 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17393 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17394 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17395 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17396 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17397 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17398 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17399 NodeFailureCode::GitReadFailed => "git-read-failed",
17400 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17401 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17402 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17403 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17404 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17405 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17406 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17407 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17408 NodeFailureCode::TurnInFlight => "turn-in-flight",
17409 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17410 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17411 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17412 "unsupported-network-allowlist-mapping"
17413 }
17414 NodeFailureCode::BrowserStationProbeUnavailable => {
17415 "browser-station-probe-unavailable"
17416 }
17417 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17418 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17419 NodeFailureCode::EnvironmentProfileBindingMismatch => {
17420 "environment-profile-binding-mismatch"
17421 }
17422 NodeFailureCode::UnknownBundle => "unknown-bundle",
17423 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17424 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17425 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17426 NodeFailureCode::ContextPackBusy => "context-pack-busy",
17427 NodeFailureCode::ContextPackMaterializationFailed => {
17428 "context-pack-materialization-failed"
17429 }
17430 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17431 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17432 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17433 NodeFailureCode::WorkspaceBusy => "workspace-busy",
17434 NodeFailureCode::LastWorkspace => "last-workspace",
17435 NodeFailureCode::NotGitRepository => "not-git-repository",
17436 NodeFailureCode::WorktreeConflict => "worktree-conflict",
17437 NodeFailureCode::WorktreeProtected => "worktree-protected",
17438 NodeFailureCode::WorktreeDirty => "worktree-dirty",
17439 NodeFailureCode::WorktreeLocked => "worktree-locked",
17440 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17441 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17442 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17443 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17444 "managed-worktree-profile-revision-mismatch"
17445 }
17446 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17447 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17448 "standalone-workspace-recovery-required"
17449 }
17450 NodeFailureCode::UnknownSession => "unknown-session",
17451 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17452 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17453 NodeFailureCode::SessionRecordBusy => "session-record-busy",
17454 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17455 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17456 NodeFailureCode::WorkspaceRegistrationRequired => {
17457 "workspace-registration-required"
17458 }
17459 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17460 NodeFailureCode::StaleGeneration => "stale-generation",
17461 NodeFailureCode::BackendBusy => "backend-busy",
17462 NodeFailureCode::BackendDisconnected => "backend-disconnected",
17463 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17464 NodeFailureCode::ShuttingDown => "shutting-down",
17465 }
17466}
17467
17468fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17469 NodeServerError::DurableState(io::Error::new(error.kind(), category))
17470}
17471
17472fn durable_state_load_error(error: io::Error) -> NodeServerError {
17473 let category = match session_registry::state_load_refusal(&error) {
17474 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17475 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17476 }
17477 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17478 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17479 }
17480 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17481 DURABLE_STATE_CONFLICT_ERROR
17482 }
17483 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17484 };
17485 durable_state_server_error(error, category)
17486}
17487
17488fn unix_time_ms() -> u64 {
17489 std::time::SystemTime::now()
17490 .duration_since(std::time::UNIX_EPOCH)
17491 .unwrap_or_default()
17492 .as_millis()
17493 .min(u64::MAX as u128) as u64
17494}
17495
17496fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17497 if record.provider_session.is_some() {
17498 ManagedSessionState::Dormant
17499 } else {
17500 ManagedSessionState::Unavailable
17501 }
17502}
17503
17504fn active_record_state(
17505 identity_admitted: bool,
17506 provider_session_is_known: bool,
17507) -> ManagedSessionState {
17508 if identity_admitted && !provider_session_is_known {
17509 ManagedSessionState::IdentityPending
17510 } else {
17511 ManagedSessionState::Live
17512 }
17513}
17514
17515fn managed_worktree_record_holders(
17516 records: &[ManagedSessionRecord],
17517) -> Vec<(SessionRecordId, WorkspaceId)> {
17518 records
17519 .iter()
17520 .filter(|record| record.provider_session.is_some())
17521 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17522 .collect()
17523}
17524
17525fn managed_spawn_record_ownership(
17526 binding: &SessionBinding,
17527 effective_runtime_policy: ProviderRuntimePolicy,
17528) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17529 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17530 if effective_runtime_policy.provider_session_identity {
17531 (binding.record_id.clone(), None)
17532 } else {
17533 (None, binding.record_id.clone())
17534 }
17535}
17536
17537#[derive(Debug, Error)]
17538pub enum NodeServerError {
17539 #[error("node endpoint must be a bounded absolute local transport path")]
17540 InvalidEndpoint,
17541 #[error("node access token must contain 1..=4096 bytes")]
17542 InvalidAccessToken,
17543 #[error("node HTTP observer must listen on a loopback address: {0}")]
17544 InvalidApiListen(std::net::SocketAddr),
17545 #[error("node browser bridge must listen on a loopback address: {0}")]
17546 InvalidBridgeListen(std::net::SocketAddr),
17547 #[error("node browser bridge token must contain 1..=4096 bytes")]
17548 InvalidBridgeToken,
17549 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17550 BridgeUnderlayRequiresBridgeListen,
17551 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17552 BridgeUnderlayRequiresBridgeToken,
17553 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17554 InvalidBridgeUnderlayToken,
17555 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17556 InvalidBridgeUnderlayKey,
17557 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17558 InvalidCallHome(std::net::SocketAddr),
17559 #[error("node could not reach its call-home relay: {0}")]
17560 CallHomeTransport(#[source] std::io::Error),
17561 #[error("node durable state path must be an absolute file path: {0}")]
17562 InvalidStatePath(String),
17563 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17564 InvalidHarnessMcpHelper,
17565 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17566 InvalidNetworkAllowlistCatalogPath(String),
17567 #[error("network allowlist catalog could not be read: {0}")]
17568 NetworkAllowlistCatalogIo(#[source] std::io::Error),
17569 #[error("network allowlist catalog line {line}: {message}")]
17570 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17571 #[error("network allowlist catalog: {0}")]
17572 InvalidNetworkAllowlistCatalog(String),
17573 #[error("network allowlist catalog exceeds the {max}-entry limit")]
17574 NetworkAllowlistCatalogCapacity { max: usize },
17575 #[error("the local state directory is unavailable; supply an explicit state path")]
17576 LocalStateDirectoryUnavailable,
17577 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17578 LocalRuntimeDirectoryUnavailable,
17579 #[error("node durable state failed: {0}")]
17580 DurableState(io::Error),
17581 #[error("node requires at least one configured workspace")]
17582 NoWorkspaces,
17583 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17584 InvalidWorkspaceRoot {
17585 workspace_id: WorkspaceId,
17586 path: String,
17587 message: String,
17588 },
17589 #[error("duplicate workspace ID: {0}")]
17590 DuplicateWorkspaceId(WorkspaceId),
17591 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17592 DuplicateWorkspaceRoot {
17593 first: WorkspaceId,
17594 second: WorkspaceId,
17595 root: String,
17596 },
17597 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17598 ManagedWorktreeProfileRequired(WorkspaceId),
17599 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17600 ManagedWorktreeProfileModeMismatch(WorkspaceId),
17601 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17602 DuplicateManagedWorktreeProfile {
17603 workspace_id: WorkspaceId,
17604 profile_id: WorktreeProfileId,
17605 },
17606 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17607 ManagedWorktreeProfileCapacity {
17608 workspace_id: WorkspaceId,
17609 max: usize,
17610 },
17611 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17612 InvalidManagedWorktreeProfile {
17613 workspace_id: WorkspaceId,
17614 profile_id: WorktreeProfileId,
17615 message: String,
17616 },
17617 #[error("duplicate node environment profile: {0}")]
17618 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17619 #[error("node environment profile provider is unavailable: {0}")]
17620 EnvironmentProfileProviderUnavailable(AgentId),
17621 #[error("node environment profile capacity is {max}")]
17622 EnvironmentProfileCapacity { max: usize },
17623 #[error("node environment profiles reuse native profile ID '{0}'")]
17624 DuplicateNativeEnvironmentProfile(String),
17625 #[error("node native environment profile is invalid: {0}")]
17626 NativeEnvironmentProfile(String),
17627 #[error("node session-environment materialization root must be absolute")]
17628 InvalidSessionEnvironmentRoot,
17629 #[error("node session-environment materializer is unavailable")]
17630 SessionEnvironmentMaterializerRequired,
17631 #[error("node session-environment materializer failed to initialize")]
17632 SessionEnvironmentMaterializer,
17633 #[error("node bundle catalog is invalid: {0}")]
17634 BundleCatalog(String),
17635 #[error("node delivery store failed to initialize: {0}")]
17636 DeliveryStore(String),
17637 #[error("node context pack store failed to initialize")]
17638 ContextPackStore,
17639 #[error("active agent registry failed: {0}")]
17640 Registry(String),
17641 #[error("node provider contract manifest is invalid: {0}")]
17642 ProviderContractManifest(String),
17643 #[error("named pipe I/O failed: {0}")]
17644 Io(#[from] io::Error),
17645 #[error("node HTTP observer failed: {0}")]
17646 HttpApi(io::Error),
17647 #[error("node browser bridge failed: {0}")]
17648 BridgeApi(io::Error),
17649 #[error(transparent)]
17650 Frame(#[from] FrameError),
17651 #[error("node handshake failed: {0}")]
17652 Handshake(String),
17653 #[error("node authentication frame was not received before the bounded deadline")]
17654 AuthenticationTimedOut,
17655 #[error("node authentication primitive failed: {0}")]
17656 Authentication(String),
17657 #[error("node incarnation identity generation failed: {0}")]
17658 IncarnationIdentity(String),
17659 #[error("node pre-authentication limiter is closed")]
17660 PreauthClosed,
17661 #[error("node authenticated connection limit was reached")]
17662 AuthenticatedConnectionLimit,
17663 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17664 SignalStreamClosed,
17665 #[error("node stop-all dispatch failed: {0:?}")]
17666 ShutdownDispatch(NodeFailure),
17667 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17668 ShutdownTimedOut { active_native_sessions: usize },
17669}
17670
17671impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17672 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17673 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17674 match error {
17675 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17676 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17677 E::InvalidEntry { line, message } => {
17678 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17679 }
17680 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17681 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17682 }
17683 }
17684}
17685
17686#[cfg(test)]
17687mod agent_stream_blocked_tests {
17688 use super::*;
17689 use gate4agent_types::{
17690 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17691 ProviderSource,
17692 };
17693
17694 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17695 ProviderSource {
17696 family,
17697 binding: AdapterBinding::new(
17698 AdapterId::new(adapter).unwrap(),
17699 "fixture/v1",
17700 AdapterVerification::SyntheticFixture,
17701 )
17702 .unwrap(),
17703 }
17704 }
17705
17706 fn provider_control_event_at(
17707 family: AdapterFamily,
17708 adapter: &str,
17709 source_sequence: u64,
17710 provider_event: ProviderEvent,
17711 ) -> ControlEvent {
17712 ControlEvent {
17713 sequence: 41,
17714 command_id: None,
17715 instance_id: AgentInstanceId(7),
17716 generation: SessionGeneration(3),
17717 event: ControlEventKind::ProviderEvent {
17718 sequence: 8,
17719 source: provider_source(family, adapter),
17720 source_sequence,
17721 event: provider_event,
17722 },
17723 }
17724 }
17725
17726 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17727 provider_control_event_at(family, "fixture", 9, provider_event)
17728 }
17729
17730 fn denied_host_request(
17731 by: ProviderHostDecisionAuthority,
17732 reason: Option<String>,
17733 ) -> ControlEvent {
17734 provider_control_event_at(
17735 AdapterFamily::Acp,
17736 "claude-code",
17737 2,
17738 ProviderEvent::HostRequestObserved {
17739 method: "terminal/create".to_owned(),
17740 params_json: String::new(),
17741 decision: ProviderHostRequestDecision::Denied { by },
17742 outcome: ProviderHostRequestOutcome::Executed,
17743 reason,
17744 },
17745 )
17746 }
17747
17748 fn blocked(
17749 event: &ControlEvent,
17750 ) -> (
17751 Option<String>,
17752 String,
17753 BlockAuthorityV1,
17754 Option<String>,
17755 String,
17756 Option<String>,
17757 ) {
17758 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17759 chunk.validate().expect("minted chunk must validate");
17760 let AgentStreamChunkKindV1::Blocked {
17761 correlation_id,
17762 tool_class,
17763 authority,
17764 reason_kind,
17765 reason,
17766 help,
17767 } = chunk.kind
17768 else {
17769 panic!("expected AgentStreamChunkKindV1::Blocked");
17770 };
17771 (correlation_id, tool_class, authority, reason_kind, reason, help)
17772 }
17773
17774 #[test]
17778 fn the_enabled_provider_matrix_is_exact() {
17779 let registry = active_registry().unwrap();
17780 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
17781 assert_eq!(
17782 providers
17783 .iter()
17784 .map(|contract| contract.provider.as_str())
17785 .collect::<Vec<_>>(),
17786 vec!["shell", "claude", "codex", "grok", "kimi"],
17788 );
17789 assert!(adapters.iter().all(|contract| {
17790 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
17791 }));
17792 }
17793
17794 #[test]
17797 fn gate_denied_host_request_mints_a_blocked_chunk() {
17798 let gate_text =
17799 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
17800 let event = denied_host_request(
17801 ProviderHostDecisionAuthority::Gate,
17802 Some(gate_text.to_owned()),
17803 );
17804 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
17805 assert_eq!(chunk.source_sequence, 2);
17806 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
17807 assert_eq!(authority, BlockAuthorityV1::HostGate);
17808 assert_eq!(reason, gate_text);
17809 assert_eq!(correlation_id, None);
17810 assert_eq!(reason_kind, None);
17811 assert_eq!(help, None);
17812 assert_eq!(tool_class, "Write");
17813 }
17814
17815 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.";
17816
17817 #[test]
17823 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
17824 let event = provider_control_event(
17825 AdapterFamily::Acp,
17826 ProviderEvent::ToolCompleted {
17827 id: "toolu_9".to_owned(),
17828 output: CLASSIFIER_SAMPLE.to_owned(),
17829 is_error: true,
17830 duration_ms: None,
17831 agent_id: None,
17832 non_execution_kind: Some("permission-rule".to_owned()),
17833 },
17834 );
17835 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
17836 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
17837 assert_eq!(reason, "Blocked by classifier");
17838 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
17839 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
17840 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
17841 }
17842
17843 #[test]
17848 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
17849 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.";
17850 let event = provider_control_event(
17851 AdapterFamily::Acp,
17852 ProviderEvent::SessionEnded {
17853 result: "JSON-RPC error -32603: Internal error".to_owned(),
17854 cost_usd: None,
17855 is_error: true,
17856 stop_reason: Some(ProviderStopReason::ProviderError {
17857 code: -32603,
17858 message: message.to_owned(),
17859 vendor_code: Some("usageLimitExceeded".to_owned()),
17860 }),
17861 },
17862 );
17863 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
17864 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
17865 assert_eq!(reason, message);
17866 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
17867 assert_eq!(tool_class, "Session");
17868 }
17869
17870 #[test]
17873 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
17874 let event = provider_control_event(
17875 AdapterFamily::Acp,
17876 ProviderEvent::SessionEnded {
17877 result: "refusal".to_owned(),
17878 cost_usd: None,
17879 is_error: true,
17880 stop_reason: Some(ProviderStopReason::Refusal),
17881 },
17882 );
17883 let (_, _, authority, _, _, _) = blocked(&event);
17884 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
17885 }
17886
17887 #[test]
17891 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
17892 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
17893 let event = provider_control_event(
17894 AdapterFamily::Acp,
17895 ProviderEvent::ToolCompleted {
17896 id: "toolu_1".to_owned(),
17897 output: "command exited with status 1".to_owned(),
17898 is_error: true,
17899 duration_ms: Some(12),
17900 agent_id: None,
17901 non_execution_kind,
17902 },
17903 );
17904 assert!(agent_stream_chunk(&event).is_none());
17905 }
17906 let narrative = provider_control_event(
17907 AdapterFamily::Acp,
17908 ProviderEvent::ToolCompleted {
17909 id: "toolu_2".to_owned(),
17910 output: "the harness blocked the write, approval needed".to_owned(),
17911 is_error: false,
17912 duration_ms: Some(3),
17913 agent_id: None,
17914 non_execution_kind: None,
17915 },
17916 );
17917 assert!(agent_stream_chunk(&narrative).is_none());
17918 }
17919
17920 #[test]
17924 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
17925 for (decision, outcome) in [
17926 (
17927 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17928 ProviderHostRequestOutcome::Executed,
17929 ),
17930 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
17931 (
17932 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17933 ProviderHostRequestOutcome::Failed {
17934 error: "terminal/create spawn failed: os error 3".to_owned(),
17935 },
17936 ),
17937 ] {
17938 let event = provider_control_event_at(
17939 AdapterFamily::Acp,
17940 "claude-code",
17941 3,
17942 ProviderEvent::HostRequestObserved {
17943 method: "fs/read_text_file".to_owned(),
17944 params_json: String::new(),
17945 decision: decision.clone(),
17946 outcome,
17947 reason: None,
17948 },
17949 );
17950 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
17951 }
17952 }
17953
17954 #[test]
17958 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
17959 for (by, expected_authority, expected_reason) in [
17960 (
17961 ProviderHostDecisionAuthority::Operator,
17962 BlockAuthorityV1::Operator,
17963 "declined by the operator",
17964 ),
17965 (
17966 ProviderHostDecisionAuthority::DeadlinePolicy,
17967 BlockAuthorityV1::HostDeadline,
17968 "denied by host policy after the operator deadline expired",
17969 ),
17970 ] {
17971 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
17972 assert_eq!(authority, expected_authority);
17973 assert_eq!(reason, expected_reason);
17974 }
17975 }
17976
17977 #[test]
17981 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
17982 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
17983 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
17984 ProviderHostDecisionAuthority::Policy,
17985 Some(oversized),
17986 ));
17987 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
17988 }
17989}
17990
17991#[cfg(test)]
17992mod standalone_capability_platform_tests {
17993 use super::*;
17994
17995 #[test]
17996 fn standalone_workspace_capability_requires_windows_identity_guards() {
17997 let capabilities = baseline_capabilities().unwrap();
17998 let advertised = capabilities.iter().any(|capability| {
17999 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18000 });
18001 assert_eq!(advertised, cfg!(windows));
18002
18003 let request = NodeRequest::CreateStandaloneWorkspace {
18004 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18005 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18006 initial_branch: None,
18007 };
18008 assert_eq!(
18009 request_uses_unnegotiated_capability(&request, &capabilities),
18010 !cfg!(windows),
18011 );
18012 }
18013}
18014
18015
18016#[cfg(test)]
18017mod station_knobs_resolve_tests {
18018 use super::*;
18019 use gate4agent_catalog::EnvMutation;
18020 use gate4agent_runtime_native::{
18021 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18022 NativeLaunchProfileId,
18023 };
18024 use std::ffi::OsString;
18025 use std::sync::Arc;
18026
18027 struct EmptyEnvironmentResolver;
18028
18029 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18030 fn resolve_child_environment(
18031 &self,
18032 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18033 Ok(vec![EnvMutation {
18034 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18035 value: None,
18036 }])
18037 }
18038 }
18039
18040 fn agent(value: &str) -> AgentId {
18041 AgentId::new(value).unwrap()
18042 }
18043
18044 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18045 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18046 let profile_revision =
18047 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18048 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18049 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18050 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18051 provider: agent("claude"),
18052 mode: SessionMode::Pty,
18053 terminal_size: gate4agent_types::TerminalSize {
18054 rows: 24,
18055 columns: 80,
18056 },
18057 prompt: None,
18058 bundle_id: None,
18059 context_id: None,
18060 environment_profile_id: Some(profile_id.clone()),
18061 };
18062 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18063 let catalog = active_registry().unwrap();
18064 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18065 let control = runtime.native_launch_profile_control();
18066 let node_profile = NodeEnvironmentProfile::new(
18067 profile_id.clone(),
18068 profile_revision,
18069 agent("claude"),
18070 [NativeLaunchProfile::new(
18071 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18072 agent("claude"),
18073 TransportKind::Pty,
18074 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18075 Arc::new(EmptyEnvironmentResolver),
18076 )
18077 .unwrap()],
18078 )
18079 .unwrap();
18080 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18081 for native_profile in native_profiles {
18082 runtime.upsert_native_launch_profile(native_profile).unwrap();
18083 }
18084 let workspace = WorkspaceConfig::new(
18085 WorkspaceId::new("primary").unwrap(),
18086 std::env::current_dir().unwrap(),
18087 )
18088 .unwrap();
18089 let shared = NodeShared::new_with_incarnation(
18090 handle,
18091 "fixture-token".to_owned(),
18092 NodeId::new("node-station-knobs-test").unwrap(),
18093 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18094 vec![workspace],
18095 vec![agent("claude")],
18096 ProviderRuntimeStatuses::default(),
18097 None,
18098 Vec::new(),
18099 Vec::new(),
18100 spawn_profiles,
18101 Some(control),
18102 None,
18103 None,
18104 None,
18105 Vec::new(),
18106 Vec::new(),
18107 Vec::new(),
18108 Vec::new(),
18109 None,
18110 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18111 );
18112 shared
18113 .environment_profiles
18114 .write()
18115 .unwrap_or_else(|poisoned| poisoned.into_inner())
18116 .insert(binding.id.clone(), binding);
18117 (shared, spawn_defaults)
18118 }
18119
18120 #[test]
18123 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18124 let (shared, defaults) = station_knobs_fixture();
18125 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18126 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18127 let spec = crate::protocol::SpawnSpec {
18128 target: crate::protocol::SpawnTarget {
18129 node_id: shared.node_id.clone(),
18130 workspace_id: WorkspaceId::new("primary").unwrap(),
18131 worktree_id: None,
18132 },
18133 profile_id: defaults.profile_id.clone(),
18134 expected_profile_revision: defaults.revision.clone(),
18135 overrides: crate::protocol::SpawnOverrides {
18136 network_allowlist: Some(unknown),
18137 browser_profile_id: Some(browser),
18138 ..crate::protocol::SpawnOverrides::default()
18139 },
18140 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18141 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18142 "station-knobs-unknown-allowlist",
18143 )
18144 .unwrap(),
18145 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18146 };
18147 let resolved = spec.resolve(&defaults).unwrap();
18148 let failure = shared
18149 .resolve_environment_profile(&resolved)
18150 .expect_err("unknown allowlist must refuse");
18151 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18152 }
18153
18154 #[test]
18160 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18161 let (shared, defaults) = station_knobs_fixture();
18162 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18163 #[cfg(not(feature = "dig2-station-probe"))]
18164 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18165 shared
18166 .network_allowlist_catalog
18167 .write()
18168 .unwrap_or_else(|poisoned| poisoned.into_inner())
18169 .insert_id(allowlist.clone())
18170 .unwrap();
18171 let spec = crate::protocol::SpawnSpec {
18172 target: crate::protocol::SpawnTarget {
18173 node_id: shared.node_id.clone(),
18174 workspace_id: WorkspaceId::new("primary").unwrap(),
18175 worktree_id: None,
18176 },
18177 profile_id: defaults.profile_id.clone(),
18178 expected_profile_revision: defaults.revision.clone(),
18179 overrides: crate::protocol::SpawnOverrides {
18180 network_allowlist: Some(allowlist.clone()),
18181 #[cfg(not(feature = "dig2-station-probe"))]
18182 browser_profile_id: Some(browser.clone()),
18183 ..crate::protocol::SpawnOverrides::default()
18184 },
18185 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18186 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18187 .unwrap(),
18188 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18189 };
18190 let resolved = spec.resolve(&defaults).unwrap();
18191 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18192 #[cfg(not(feature = "dig2-station-probe"))]
18193 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18194 let receipt = shared
18195 .resolve_environment_profile(&resolved)
18196 .expect("resolve ok")
18197 .expect("env profile present");
18198 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18199 #[cfg(not(feature = "dig2-station-probe"))]
18200 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18201 #[cfg(feature = "dig2-station-probe")]
18202 assert_eq!(receipt.browser_profile_id, None);
18203 assert_eq!(receipt.profile_id.as_str(), "local-claude");
18204
18205 let omitted = crate::protocol::SpawnSpec {
18206 overrides: crate::protocol::SpawnOverrides::default(),
18207 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18208 .unwrap(),
18209 ..spec
18210 };
18211 let resolved_omit = omitted.resolve(&defaults).unwrap();
18212 let bare = shared
18213 .resolve_environment_profile(&resolved_omit)
18214 .expect("resolve ok")
18215 .expect("env profile present");
18216 assert_eq!(bare.network_allowlist, None);
18217 assert_eq!(bare.browser_profile_id, None);
18218 }
18219
18220 #[cfg(feature = "dig2-station-probe")]
18225 #[test]
18226 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18227 let (shared, defaults) = station_knobs_fixture();
18228 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18229 let spec = crate::protocol::SpawnSpec {
18230 target: crate::protocol::SpawnTarget {
18231 node_id: shared.node_id.clone(),
18232 workspace_id: WorkspaceId::new("primary").unwrap(),
18233 worktree_id: None,
18234 },
18235 profile_id: defaults.profile_id.clone(),
18236 expected_profile_revision: defaults.revision.clone(),
18237 overrides: crate::protocol::SpawnOverrides {
18238 browser_profile_id: Some(browser),
18239 ..crate::protocol::SpawnOverrides::default()
18240 },
18241 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18242 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18243 "station-knobs-probe-refuse",
18244 )
18245 .unwrap(),
18246 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18247 };
18248 let resolved = spec.resolve(&defaults).unwrap();
18249 let failure = shared
18250 .resolve_environment_profile(&resolved)
18251 .expect_err("browser_profile with failing probe must refuse");
18252 #[cfg(windows)]
18253 {
18254 assert!(
18255 matches!(
18256 failure.code,
18257 NodeFailureCode::BrowserStationUnreachable
18258 | NodeFailureCode::BrowserStationProbeUnavailable
18259 ),
18260 "unexpected code {:?}",
18261 failure.code
18262 );
18263 }
18264 #[cfg(not(windows))]
18265 {
18266 assert_eq!(
18267 failure.code,
18268 NodeFailureCode::BrowserStationProbeUnavailable
18269 );
18270 }
18271 }
18272
18273 #[cfg(feature = "dig2-station-probe")]
18275 #[test]
18276 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18277 let (shared, defaults) = station_knobs_fixture();
18278 let spec = crate::protocol::SpawnSpec {
18279 target: crate::protocol::SpawnTarget {
18280 node_id: shared.node_id.clone(),
18281 workspace_id: WorkspaceId::new("primary").unwrap(),
18282 worktree_id: None,
18283 },
18284 profile_id: defaults.profile_id.clone(),
18285 expected_profile_revision: defaults.revision.clone(),
18286 overrides: crate::protocol::SpawnOverrides::default(),
18287 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18288 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18289 "station-knobs-probe-omit",
18290 )
18291 .unwrap(),
18292 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18293 };
18294 let resolved = spec.resolve(&defaults).unwrap();
18295 let receipt = shared
18296 .resolve_environment_profile(&resolved)
18297 .expect("omit browser_profile must not probe")
18298 .expect("env profile present");
18299 assert_eq!(receipt.browser_profile_id, None);
18300 }
18301
18302 #[cfg(feature = "dig2-station-probe")]
18306 #[test]
18307 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18308 let (shared, _defaults) = station_knobs_fixture();
18309 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18310 let workspace_id = WorkspaceId::new("primary").unwrap();
18311 let address_a = SessionAddress {
18312 workspace_id: workspace_id.clone(),
18313 session: SessionKey {
18314 instance_id: AgentInstanceId(101),
18315 generation: SessionGeneration(1),
18316 },
18317 };
18318 let address_b = SessionAddress {
18319 workspace_id: workspace_id.clone(),
18320 session: SessionKey {
18321 instance_id: AgentInstanceId(102),
18322 generation: SessionGeneration(1),
18323 },
18324 };
18325 let guard_a = crate::dig2_station_lease::acquire_lease(
18326 &shared.browser_station_leases,
18327 profile.clone(),
18328 address_a.session.instance_id,
18329 1,
18330 )
18331 .expect("first lease");
18332 let busy = crate::dig2_station_lease::acquire_lease(
18333 &shared.browser_station_leases,
18334 profile.clone(),
18335 address_b.session.instance_id,
18336 2,
18337 )
18338 .expect_err("second holder must refuse");
18339 assert!(matches!(
18340 busy,
18341 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18342 ));
18343 shared.bind_session_with_policy(
18345 &address_a,
18346 ProviderRuntimePolicy::raw_pty(),
18347 None,
18348 );
18349 guard_a.retain();
18350 assert!(shared.remove_binding(&address_a).is_some());
18351 crate::dig2_station_lease::acquire_lease(
18352 &shared.browser_station_leases,
18353 profile,
18354 address_b.session.instance_id,
18355 3,
18356 )
18357 .expect("reacquire after clear")
18358 .retain();
18359 assert!(
18360 crate::dig2_station_lease::release_lease_for_holder(
18361 &shared.browser_station_leases,
18362 address_b.session.instance_id,
18363 )
18364 );
18365 }
18366
18367 #[cfg(not(feature = "dig2-station-probe"))]
18370 #[test]
18371 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18372 let (shared, defaults) = station_knobs_fixture();
18373 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18374 let spec = crate::protocol::SpawnSpec {
18375 target: crate::protocol::SpawnTarget {
18376 node_id: shared.node_id.clone(),
18377 workspace_id: WorkspaceId::new("primary").unwrap(),
18378 worktree_id: None,
18379 },
18380 profile_id: defaults.profile_id.clone(),
18381 expected_profile_revision: defaults.revision.clone(),
18382 overrides: crate::protocol::SpawnOverrides {
18383 browser_profile_id: Some(browser.clone()),
18384 ..crate::protocol::SpawnOverrides::default()
18385 },
18386 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18387 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18388 .unwrap(),
18389 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18390 };
18391 let resolved = spec.resolve(&defaults).unwrap();
18392 let receipt = shared
18393 .resolve_environment_profile(&resolved)
18394 .expect("stub resolve")
18395 .expect("env profile");
18396 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18397 }
18398
18399
18400 #[test]
18403 fn network_allowlist_catalog_loads_from_temp_file() {
18404 let unique = std::time::SystemTime::now()
18405 .duration_since(std::time::UNIX_EPOCH)
18406 .unwrap()
18407 .as_nanos();
18408 let path = std::env::temp_dir().join(format!(
18409 "gate4agent-node-allowlist-config-{}-{unique}.txt",
18410 std::process::id(),
18411 ));
18412 std::fs::write(
18413 &path,
18414 "# opaque station allowlist ids\negress-default\nlab-egress\n",
18415 )
18416 .unwrap();
18417 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18418 .expect("temp catalog file must load");
18419 let _ = std::fs::remove_file(&path);
18420 assert_eq!(loaded.len(), 2);
18421 let (shared, defaults) = station_knobs_fixture();
18422 {
18423 let mut catalog = shared
18424 .network_allowlist_catalog
18425 .write()
18426 .unwrap_or_else(|poisoned| poisoned.into_inner());
18427 *catalog = loaded;
18428 }
18429 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18430 let spec = crate::protocol::SpawnSpec {
18431 target: crate::protocol::SpawnTarget {
18432 node_id: shared.node_id.clone(),
18433 workspace_id: WorkspaceId::new("primary").unwrap(),
18434 worktree_id: None,
18435 },
18436 profile_id: defaults.profile_id.clone(),
18437 expected_profile_revision: defaults.revision.clone(),
18438 overrides: crate::protocol::SpawnOverrides {
18439 network_allowlist: Some(allowlist.clone()),
18440 ..crate::protocol::SpawnOverrides::default()
18441 },
18442 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18443 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18444 "station-knobs-catalog-file",
18445 )
18446 .unwrap(),
18447 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18448 };
18449 let resolved = spec.resolve(&defaults).unwrap();
18450 let receipt = shared
18451 .resolve_environment_profile(&resolved)
18452 .expect("registered via file load")
18453 .expect("env profile present");
18454 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18455 }
18456
18457 #[test]
18460 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18461 let (shared, _defaults) = station_knobs_fixture();
18462 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18463 shared
18464 .network_allowlist_catalog
18465 .write()
18466 .unwrap_or_else(|poisoned| poisoned.into_inner())
18467 .insert_id(allowlist.clone())
18468 .unwrap();
18469 let snapshot = shared.snapshot();
18470 let inventory = snapshot
18471 .launch_inventory
18472 .expect("launch inventory present");
18473 let ids = inventory
18474 .network_allowlists
18475 .expect("non-empty catalog surfaces ids");
18476 assert_eq!(ids, vec![allowlist]);
18477 }
18478
18479 #[test]
18483 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18484 let (shared, defaults) = station_knobs_fixture();
18485 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18486 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18487 id: allowlist.clone(),
18488 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18489 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18490 peer: "127.0.0.1:443".to_owned(),
18491 }],
18492 provider_native: Some(
18493 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18494 codex_network_access: Some(true),
18495 },
18496 ),
18497 };
18498 shared
18499 .network_allowlist_catalog
18500 .write()
18501 .unwrap_or_else(|poisoned| poisoned.into_inner())
18502 .insert(entry)
18503 .unwrap();
18504 let spec = crate::protocol::SpawnSpec {
18505 target: crate::protocol::SpawnTarget {
18506 node_id: shared.node_id.clone(),
18507 workspace_id: WorkspaceId::new("primary").unwrap(),
18508 worktree_id: None,
18509 },
18510 profile_id: defaults.profile_id.clone(),
18511 expected_profile_revision: defaults.revision.clone(),
18512 overrides: crate::protocol::SpawnOverrides {
18513 network_allowlist: Some(allowlist),
18514 ..crate::protocol::SpawnOverrides::default()
18515 },
18516 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18517 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18518 "station-knobs-unsupported-mapping",
18519 )
18520 .unwrap(),
18521 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18522 };
18523 let resolved = spec.resolve(&defaults).unwrap();
18524 assert_eq!(resolved.provider.as_str(), "claude");
18525 let failure = shared
18526 .resolve_environment_profile(&resolved)
18527 .expect_err("claude + codex_network_access must refuse");
18528 assert_eq!(
18529 failure.code,
18530 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18531 );
18532 }
18533
18534 #[test]
18538 fn resolve_codex_network_access_overlay_moderate_only() {
18539 let (shared, defaults) = station_knobs_fixture();
18540 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18541 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18542 id: allowlist.clone(),
18543 permits: Vec::new(),
18544 provider_native: Some(
18545 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18546 codex_network_access: Some(true),
18547 },
18548 ),
18549 };
18550 shared
18551 .network_allowlist_catalog
18552 .write()
18553 .unwrap_or_else(|poisoned| poisoned.into_inner())
18554 .insert(entry)
18555 .unwrap();
18556
18557 let overlay = shared
18558 .resolve_codex_network_access_overlay_args(
18559 &agent("codex"),
18560 SessionMode::Pty,
18561 ApprovalLevel::Moderate,
18562 Some(&allowlist),
18563 )
18564 .expect("Moderate Codex maps network_access");
18565 assert_eq!(
18566 overlay,
18567 vec![
18568 std::ffi::OsString::from("-c"),
18569 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18570 ]
18571 );
18572
18573 let acp = shared
18574 .resolve_codex_network_access_overlay_args(
18575 &agent("codex"),
18576 SessionMode::Acp,
18577 ApprovalLevel::Moderate,
18578 Some(&allowlist),
18579 )
18580 .unwrap();
18581 assert_eq!(acp, overlay);
18582
18583 let inline = shared
18584 .resolve_codex_network_access_overlay_args(
18585 &agent("codex"),
18586 SessionMode::Inline,
18587 ApprovalLevel::Moderate,
18588 Some(&allowlist),
18589 )
18590 .unwrap_err();
18591 assert_eq!(
18592 inline.code,
18593 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18594 );
18595
18596 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18597 let spec = crate::protocol::SpawnSpec {
18598 target: crate::protocol::SpawnTarget {
18599 node_id: shared.node_id.clone(),
18600 workspace_id: WorkspaceId::new("primary").unwrap(),
18601 worktree_id: None,
18602 },
18603 profile_id: defaults.profile_id.clone(),
18604 expected_profile_revision: defaults.revision.clone(),
18605 overrides: crate::protocol::SpawnOverrides {
18606 provider: crate::protocol::SpawnOverride::Set {
18607 value: agent("codex"),
18608 },
18609 approval_level: Some(level),
18610 network_allowlist: Some(allowlist.clone()),
18611 ..crate::protocol::SpawnOverrides::default()
18612 },
18613 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18614 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18615 "codex-net-refuse-{}",
18616 match level {
18617 ApprovalLevel::ReadOnly => "ro",
18618 ApprovalLevel::FullAuto => "fa",
18619 _ => "other",
18620 }
18621 ))
18622 .unwrap(),
18623 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18624 };
18625 let resolved = spec.resolve(&defaults).unwrap();
18626 let failure = shared
18627 .resolve_environment_profile(&resolved)
18628 .expect_err("RO/FullAuto must refuse");
18629 assert_eq!(
18630 failure.code,
18631 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18632 );
18633 }
18634 }
18635
18636 #[test]
18640 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18641 let (shared, _defaults) = station_knobs_fixture();
18642 let allowlist =
18643 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18644 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18645 id: allowlist.clone(),
18646 permits: vec![
18647 crate::network_allowlist_catalog::NetworkPermitSketch {
18648 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18649 peer: "github.com:443".to_owned(),
18650 },
18651 crate::network_allowlist_catalog::NetworkPermitSketch {
18652 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18653 peer: "*.npmjs.org".to_owned(),
18654 },
18655 ],
18656 provider_native: None,
18657 };
18658 shared
18659 .network_allowlist_catalog
18660 .write()
18661 .unwrap_or_else(|poisoned| poisoned.into_inner())
18662 .insert(entry)
18663 .unwrap();
18664
18665 let unique = std::time::SystemTime::now()
18666 .duration_since(std::time::UNIX_EPOCH)
18667 .unwrap()
18668 .as_nanos();
18669 let provider_home = std::env::temp_dir().join(format!(
18670 "gate4agent-claude-provider-home-{}-{unique}",
18671 std::process::id(),
18672 ));
18673 std::fs::create_dir_all(&provider_home).unwrap();
18674
18675 let overlay = shared
18676 .resolve_network_allowlist_overlay_args(
18677 &agent("claude"),
18678 SessionMode::Pty,
18679 ApprovalLevel::Moderate,
18680 Some(&allowlist),
18681 Some(provider_home.as_path()),
18682 )
18683 .expect("Claude PTY settings overlay on unix");
18684 let settings_path = provider_home.join(
18685 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18686 );
18687 assert_eq!(
18688 overlay,
18689 vec![
18690 std::ffi::OsString::from("--settings"),
18691 std::ffi::OsString::from(settings_path.as_os_str()),
18692 ]
18693 );
18694 let body = std::fs::read_to_string(&settings_path).unwrap();
18695 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18696 assert_eq!(value["sandbox"]["enabled"], true);
18697 assert_eq!(
18698 value["sandbox"]["network"]["allowedDomains"],
18699 serde_json::json!(["github.com", "*.npmjs.org"])
18700 );
18701 assert!(settings_path.starts_with(&provider_home));
18702
18703 let acp = shared
18704 .resolve_network_allowlist_overlay_args(
18705 &agent("claude"),
18706 SessionMode::Acp,
18707 ApprovalLevel::Moderate,
18708 Some(&allowlist),
18709 Some(provider_home.as_path()),
18710 )
18711 .expect_err("Claude ACP must refuse settings overlay");
18712 assert_eq!(
18713 acp.code,
18714 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18715 );
18716
18717 let no_home = shared
18718 .resolve_network_allowlist_overlay_args(
18719 &agent("claude"),
18720 SessionMode::Pty,
18721 ApprovalLevel::Moderate,
18722 Some(&allowlist),
18723 None,
18724 )
18725 .expect_err("Claude overlay requires ProviderHome");
18726 assert_eq!(
18727 no_home.code,
18728 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18729 );
18730
18731 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18732 shared
18733 .network_allowlist_catalog
18734 .write()
18735 .unwrap_or_else(|poisoned| poisoned.into_inner())
18736 .insert_id(bare.clone())
18737 .unwrap();
18738 let empty = shared
18739 .resolve_network_allowlist_overlay_args(
18740 &agent("claude"),
18741 SessionMode::Pty,
18742 ApprovalLevel::Moderate,
18743 Some(&bare),
18744 Some(provider_home.as_path()),
18745 )
18746 .unwrap();
18747 assert!(empty.is_empty());
18748
18749 let _ = std::fs::remove_dir_all(&provider_home);
18750 }
18751
18752 #[test]
18754 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18755 let (shared, _defaults) = station_knobs_fixture();
18756 let allowlist =
18757 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18758 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18759 id: allowlist.clone(),
18760 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18761 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18762 peer: "example.com:443".to_owned(),
18763 }],
18764 provider_native: None,
18765 };
18766 shared
18767 .network_allowlist_catalog
18768 .write()
18769 .unwrap_or_else(|poisoned| poisoned.into_inner())
18770 .insert(entry)
18771 .unwrap();
18772 for provider in ["kimi", "grok"] {
18773 let overlay = shared
18774 .resolve_network_allowlist_overlay_args(
18775 &agent(provider),
18776 SessionMode::Pty,
18777 ApprovalLevel::Moderate,
18778 Some(&allowlist),
18779 None,
18780 )
18781 .unwrap();
18782 assert!(
18783 overlay.is_empty(),
18784 "{provider} must not invent network argv from permits"
18785 );
18786 }
18787 }
18788
18789 #[test]
18791 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
18792 let unique = std::time::SystemTime::now()
18793 .duration_since(std::time::UNIX_EPOCH)
18794 .unwrap()
18795 .as_nanos();
18796 let path = std::env::temp_dir().join(format!(
18797 "gate4agent-node-allowlist-json-{}-{unique}.json",
18798 std::process::id(),
18799 ));
18800 std::fs::write(
18801 &path,
18802 r#"{
18803 "schema_version": 2,
18804 "entries": [
18805 {
18806 "id": "egress-default",
18807 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
18808 },
18809 { "id": "lab-egress" }
18810 ]
18811 }"#,
18812 )
18813 .unwrap();
18814 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18815 .expect("json v2 catalog must load");
18816 let _ = std::fs::remove_file(&path);
18817 assert_eq!(loaded.len(), 2);
18818 let entry = loaded
18819 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
18820 .unwrap();
18821 assert_eq!(entry.permits.len(), 1);
18822 let (shared, defaults) = station_knobs_fixture();
18823 {
18824 let mut catalog = shared
18825 .network_allowlist_catalog
18826 .write()
18827 .unwrap_or_else(|poisoned| poisoned.into_inner());
18828 *catalog = loaded;
18829 }
18830 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18831 let spec = crate::protocol::SpawnSpec {
18832 target: crate::protocol::SpawnTarget {
18833 node_id: shared.node_id.clone(),
18834 workspace_id: WorkspaceId::new("primary").unwrap(),
18835 worktree_id: None,
18836 },
18837 profile_id: defaults.profile_id.clone(),
18838 expected_profile_revision: defaults.revision.clone(),
18839 overrides: crate::protocol::SpawnOverrides {
18840 network_allowlist: Some(allowlist.clone()),
18841 ..crate::protocol::SpawnOverrides::default()
18842 },
18843 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18844 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18845 "station-knobs-catalog-json",
18846 )
18847 .unwrap(),
18848 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18849 };
18850 let resolved = spec.resolve(&defaults).unwrap();
18851 let receipt = shared
18852 .resolve_environment_profile(&resolved)
18853 .expect("permits-only entry ok for claude")
18854 .expect("env profile present");
18855 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18856 let inventory = shared
18857 .snapshot()
18858 .launch_inventory
18859 .expect("launch inventory present");
18860 let ids = inventory.network_allowlists.expect("ids surface");
18861 assert_eq!(
18862 ids,
18863 vec![
18864 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
18865 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
18866 ]
18867 );
18868 }
18869
18870
18871}
18872
18873
18874#[cfg(test)]
18875mod workspace_environment_collision_tests {
18876 use super::*;
18877 use crate::session_environment::{
18878 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
18879 };
18880 use gate4agent_catalog::EnvMutation;
18881 use gate4agent_runtime_native::{
18882 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18883 NativeLaunchProfileId,
18884 };
18885 use std::ffi::OsString;
18886 use std::sync::Arc;
18887
18888 struct EmptyEnvironmentResolver;
18889
18890 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18891 fn resolve_child_environment(
18892 &self,
18893 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18894 Ok(vec![EnvMutation {
18895 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18896 value: None,
18897 }])
18898 }
18899 }
18900
18901 fn agent(value: &str) -> AgentId {
18902 AgentId::new(value).unwrap()
18903 }
18904
18905 fn claude_config_dir_collision_fixture() -> (
18907 NodeShared,
18908 ResolvedEnvironmentProfileReceipt,
18909 ResolvedEnvironmentProfileReceipt,
18910 ) {
18911 let catalog = active_registry().unwrap();
18912 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18913 let control = runtime.native_launch_profile_control();
18914 let workspace = WorkspaceConfig::new(
18915 WorkspaceId::new("primary").unwrap(),
18916 std::env::current_dir().unwrap(),
18917 )
18918 .unwrap();
18919 let shared = NodeShared::new_with_incarnation(
18920 handle,
18921 "fixture-token".to_owned(),
18922 NodeId::new("node-env-collision-test").unwrap(),
18923 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18924 vec![workspace],
18925 vec![agent("claude")],
18926 ProviderRuntimeStatuses::default(),
18927 None,
18928 Vec::new(),
18929 Vec::new(),
18930 SpawnProfileRegistry::default(),
18931 Some(control),
18932 None,
18933 None,
18934 None,
18935 Vec::new(),
18936 Vec::new(),
18937 Vec::new(),
18938 Vec::new(),
18939 None,
18940 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18941 );
18942
18943 let mut receipts = Vec::new();
18944 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
18945 let profile_id =
18946 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
18947 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
18948 "claude-config-dir-{suffix}-r1"
18949 ))
18950 .unwrap();
18951 let materialization = NodeSessionMaterializationProfile::new(
18952 Vec::new(),
18953 vec![NodeSessionPathBinding::new(
18954 "CLAUDE_CONFIG_DIR",
18955 NodeSessionPathClass::ProviderHome,
18956 )
18957 .unwrap()],
18958 Vec::new(),
18959 )
18960 .unwrap();
18961 let node_profile = NodeEnvironmentProfile::new_with_materialization(
18962 profile_id.clone(),
18963 profile_revision.clone(),
18964 agent("claude"),
18965 [NativeLaunchProfile::new(
18966 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
18967 .unwrap(),
18968 agent("claude"),
18969 TransportKind::Pty,
18970 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18971 Arc::new(EmptyEnvironmentResolver),
18972 )
18973 .unwrap()],
18974 Some(materialization),
18975 )
18976 .unwrap();
18977 let (binding, native_profiles, materialization) = node_profile.into_parts();
18978 for native_profile in native_profiles {
18979 runtime.upsert_native_launch_profile(native_profile).unwrap();
18980 }
18981 shared
18982 .environment_profiles
18983 .write()
18984 .unwrap_or_else(|poisoned| poisoned.into_inner())
18985 .insert(binding.id.clone(), binding);
18986 shared
18987 .environment_materialization_profiles
18988 .write()
18989 .unwrap_or_else(|poisoned| poisoned.into_inner())
18990 .insert(profile_id.clone(), materialization.unwrap());
18991 receipts.push(ResolvedEnvironmentProfileReceipt {
18992 profile_id,
18993 profile_revision,
18994 network_allowlist: None,
18995 browser_profile_id: None,
18996 });
18997 }
18998 let profile_b = receipts.pop().unwrap();
18999 let profile_a = receipts.pop().unwrap();
19000 (shared, profile_a, profile_b)
19001 }
19002
19003 #[test]
19004 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19005 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19006 let primary = WorkspaceId::new("primary").unwrap();
19007 let worktree = WorkspaceId::new("worktree-b").unwrap();
19008 let address = SessionAddress {
19009 workspace_id: primary.clone(),
19010 session: SessionKey {
19011 instance_id: AgentInstanceId(41),
19012 generation: SessionGeneration(1),
19013 },
19014 };
19015 shared.bind_session_with_policy(
19016 &address,
19017 ProviderRuntimePolicy::raw_pty(),
19018 Some(profile_a.clone()),
19019 );
19020
19021 let refused = shared
19022 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19023 .unwrap_err();
19024 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19025
19026 let refused_none = shared
19028 .ensure_workspace_environment_profile_compatible(&primary, None)
19029 .unwrap_err();
19030 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19031
19032 shared
19034 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19035 .unwrap();
19036
19037 shared
19039 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19040 .unwrap();
19041 }
19042
19043 #[tokio::test]
19044 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19045 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19046 let primary = WorkspaceId::new("primary").unwrap();
19047 let address = SessionAddress {
19048 workspace_id: primary.clone(),
19049 session: SessionKey {
19050 instance_id: AgentInstanceId(41),
19051 generation: SessionGeneration(1),
19052 },
19053 };
19054 shared.bind_session_with_policy(
19055 &address,
19056 ProviderRuntimePolicy::raw_pty(),
19057 Some(profile_a),
19058 );
19059
19060 let failure = shared
19061 .spawn_session_with_deadline(
19062 primary,
19063 agent("claude"),
19064 SessionMode::Pty,
19065 gate4agent_types::TerminalSize {
19066 rows: 24,
19067 columns: 80,
19068 },
19069 None,
19070 Some(profile_b),
19071 None,
19072 None,
19073 None,
19074 None,
19075 SpawnRecordPolicy::ProviderIdentityOnly,
19076 Some(Instant::now() + Duration::from_secs(30)),
19077 &[],
19078 None,
19079 ApprovalLevel::default(),
19080 None,
19081 None,
19082 )
19083 .await
19084 .unwrap_err();
19085 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19086 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19088 assert!(shared.handle.snapshot().sessions.is_empty());
19089 }
19090}
19091
19092#[cfg(test)]
19093mod spawn_characterization_tests {
19094 use super::*;
19095 use crate::session_environment::{
19096 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19097 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19098 NodeSessionPathClass,
19099 };
19100 use gate4agent_catalog::EnvMutation;
19101 use gate4agent_runtime_native::{
19102 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19103 NativeLaunchProfile, NativeLaunchProfileId,
19104 };
19105 use std::ffi::OsString;
19106 use std::sync::Arc;
19107
19108 struct EmptyEnvironmentResolver;
19109
19110 struct FixtureSecretResolver {
19111 deny: Arc<AtomicBool>,
19112 }
19113
19114 impl NodeSecretResolver for FixtureSecretResolver {
19115 fn resolve(
19116 &self,
19117 _reference: &NodeSecretReference,
19118 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19119 if self.deny.load(Ordering::Acquire) {
19120 Err(NodeSecretResolveError::Denied)
19121 } else {
19122 NodeSecretValue::text("fixture-secret")
19123 .map_err(|_| NodeSecretResolveError::Unavailable)
19124 }
19125 }
19126 }
19127
19128 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19129 fn resolve_child_environment(
19130 &self,
19131 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19132 Ok(vec![EnvMutation {
19133 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19134 value: None,
19135 }])
19136 }
19137 }
19138
19139 fn agent(value: &str) -> AgentId {
19140 AgentId::new(value).unwrap()
19141 }
19142
19143 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19144 let unique = std::time::SystemTime::now()
19145 .duration_since(std::time::UNIX_EPOCH)
19146 .unwrap()
19147 .as_nanos();
19148 std::env::temp_dir().join(format!(
19149 "gate4agent-node-{label}-{}-{unique}",
19150 std::process::id(),
19151 ))
19152 }
19153
19154 fn materialization_test_shared(
19155 deny_secret: bool,
19156 ) -> (
19157 NodeShared,
19158 NativeRuntime,
19159 ResolvedEnvironmentProfileReceipt,
19160 PathBuf,
19161 Arc<AtomicBool>,
19162 ) {
19163 let workspace = WorkspaceConfig::new(
19164 WorkspaceId::new("primary").unwrap(),
19165 std::env::current_dir().unwrap(),
19166 )
19167 .unwrap();
19168 materialization_test_shared_with_workspace(deny_secret, workspace)
19169 }
19170
19171 fn materialization_test_shared_with_workspace(
19172 deny_secret: bool,
19173 workspace: WorkspaceConfig,
19174 ) -> (
19175 NodeShared,
19176 NativeRuntime,
19177 ResolvedEnvironmentProfileReceipt,
19178 PathBuf,
19179 Arc<AtomicBool>,
19180 ) {
19181 let root = temporary_workspace_root(if deny_secret {
19182 "materialization-denied"
19183 } else {
19184 "materialization-ready"
19185 });
19186 std::fs::create_dir_all(&root).unwrap();
19187 let materialization_root = root.join("session-environments");
19188 let deny = Arc::new(AtomicBool::new(deny_secret));
19189 let resolver = Arc::new(FixtureSecretResolver {
19190 deny: Arc::clone(&deny),
19191 });
19192 let materializer = SessionEnvironmentMaterializer::new(
19193 materialization_root,
19194 resolver,
19195 )
19196 .unwrap();
19197 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19198 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19199 "materialized-claude-r1",
19200 )
19201 .unwrap();
19202 let materialization = NodeSessionMaterializationProfile::new(
19203 vec![NodeSessionEnvironmentMutation::SetSecret {
19204 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19205 reference: NodeSecretReference::new("fixture-token").unwrap(),
19206 }],
19207 vec![NodeSessionPathBinding::new(
19208 "GATE4AGENT_PROVIDER_HOME",
19209 NodeSessionPathClass::ProviderHome,
19210 )
19211 .unwrap()],
19212 vec![NodeSessionFile::secret(
19213 NodeSessionPathClass::Config,
19214 "auth/token",
19215 NodeSecretReference::new("fixture-token").unwrap(),
19216 )
19217 .unwrap()],
19218 )
19219 .unwrap();
19220 let catalog = active_registry().unwrap();
19221 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19222 let control = runtime.native_launch_profile_control();
19223 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19224 profile_id.clone(),
19225 profile_revision.clone(),
19226 agent("claude"),
19227 [NativeLaunchProfile::new(
19228 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19229 agent("claude"),
19230 TransportKind::Pty,
19231 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19232 Arc::new(EmptyEnvironmentResolver),
19233 )
19234 .unwrap()],
19235 Some(materialization),
19236 )
19237 .unwrap();
19238 let (binding, native_profiles, materialization) = node_profile.into_parts();
19239 for native_profile in native_profiles {
19240 runtime.upsert_native_launch_profile(native_profile).unwrap();
19241 }
19242 let shared = NodeShared::new_with_incarnation(
19243 handle,
19244 "fixture-token".to_owned(),
19245 NodeId::new("node-materialization-test").unwrap(),
19246 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19247 vec![workspace],
19248 vec![agent("claude")],
19249 ProviderRuntimeStatuses::default(),
19250 None,
19251 Vec::new(),
19252 Vec::new(),
19253 SpawnProfileRegistry::default(),
19254 Some(control),
19255 None,
19256 Some(materializer),
19257 None,
19258 Vec::new(),
19259 Vec::new(),
19260 Vec::new(),
19261 Vec::new(),
19262 None,
19263 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19264 );
19265 shared
19266 .environment_profiles
19267 .write()
19268 .unwrap_or_else(|poisoned| poisoned.into_inner())
19269 .insert(binding.id.clone(), binding);
19270 shared
19271 .environment_materialization_profiles
19272 .write()
19273 .unwrap_or_else(|poisoned| poisoned.into_inner())
19274 .insert(profile_id.clone(), materialization.unwrap());
19275 (
19276 shared,
19277 runtime,
19278 ResolvedEnvironmentProfileReceipt {
19279 profile_id,
19280 profile_revision,
19281 network_allowlist: None,
19282 browser_profile_id: None,
19283 },
19284 root,
19285 deny,
19286 )
19287 }
19288
19289
19290 async fn characterization_spawn(
19293 shared: &NodeShared,
19294 mode: SessionMode,
19295 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19296 required: &[ProviderRuntimeCapability],
19297 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19298 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19299 shared
19300 .spawn_session_with_deadline(
19301 WorkspaceId::new("primary").unwrap(),
19302 agent("claude"),
19303 mode,
19304 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19305 None,
19306 environment_profile,
19307 None,
19308 None,
19309 None,
19310 Some(ProviderRuntimePolicy::raw_pty()),
19311 SpawnRecordPolicy::ProviderIdentityOnly,
19312 Some(Instant::now() + Duration::from_secs(30)),
19313 required,
19314 harness_mcp,
19315 ApprovalLevel::default(),
19316 None,
19317 None,
19318 )
19319 .await
19320 }
19321
19322 fn assert_no_session_state(shared: &NodeShared) {
19323 assert!(shared.handle.snapshot().sessions.is_empty());
19324 assert!(shared
19325 .session_bindings
19326 .lock()
19327 .unwrap_or_else(|poisoned| poisoned.into_inner())
19328 .is_empty());
19329 assert!(shared
19330 .materializations
19331 .lock()
19332 .unwrap_or_else(|poisoned| poisoned.into_inner())
19333 .is_empty());
19334 }
19335
19336 #[tokio::test]
19337 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19338 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19339 let error = characterization_spawn(
19340 &shared,
19341 SessionMode::Pty,
19342 None,
19343 &[ProviderRuntimeCapability::SemanticReadiness],
19344 None,
19345 )
19346 .await
19347 .unwrap_err();
19348 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19349 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19350 assert_no_session_state(&shared);
19351 drop(shared);
19352 drop(runtime);
19353 std::fs::remove_dir_all(root).unwrap();
19354 }
19355
19356 #[tokio::test]
19357 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19358 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19359 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19360 .await
19361 .unwrap_err();
19362 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19363 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19364 assert_no_session_state(&shared);
19365 drop(shared);
19366 drop(runtime);
19367 std::fs::remove_dir_all(root).unwrap();
19368 }
19369
19370 #[test]
19371 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19372 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19373 let address = SessionAddress {
19374 workspace_id: WorkspaceId::new("primary").unwrap(),
19375 session: SessionKey {
19376 instance_id: AgentInstanceId(77),
19377 generation: SessionGeneration(1),
19378 },
19379 };
19380 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19381 let removed = shared.remove_binding(&address).expect("binding removed");
19382 assert!(removed.environment_profile.is_some());
19383 assert!(shared.remove_binding(&address).is_none());
19384 assert_no_session_state(&shared);
19385 drop(shared);
19386 drop(runtime);
19387 std::fs::remove_dir_all(root).unwrap();
19388 }
19389
19390 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19391 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19392 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19393 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19394 .expect("review current test executable as fixture helper");
19395 let registry = HarnessMcpProxyRegistry::new(helper);
19396 let spec = crate::protocol::SpawnSpec {
19397 target: crate::protocol::SpawnTarget {
19398 node_id: NodeId::new("node-characterization").unwrap(),
19399 workspace_id: WorkspaceId::new("primary").unwrap(),
19400 worktree_id: None,
19401 },
19402 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19403 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19404 overrides: crate::protocol::SpawnOverrides::default(),
19405 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19406 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19407 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19408 };
19409 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19410 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19411 let launch = HarnessMcpLaunchV1 {
19412 server_name: "fixture-harness".to_owned(),
19413 args: vec!["--session-proxy".to_owned()],
19414 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19415 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19416 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19417 trace: None,
19418 scrub_env: Vec::new(),
19419 };
19420 let expires = unix_time_ms().saturating_add(30_000);
19421 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19422 registry
19423 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19424 .unwrap()
19425 }
19426
19427 #[tokio::test]
19428 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19429 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19430 let prepared = characterization_prepared_harness("claude").await;
19431 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19432 .await
19433 .unwrap_err();
19434 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19436 let mismatched = characterization_prepared_harness("codex").await;
19437 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19438 .await
19439 .unwrap_err();
19440 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19441 assert_no_session_state(&shared);
19442 drop(shared);
19443 drop(runtime);
19444 std::fs::remove_dir_all(root).unwrap();
19445 }
19446
19447 #[tokio::test]
19448 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19449 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19450 shared.native_launch_profile_control = None;
19451 let prepared = characterization_prepared_harness("claude").await;
19452 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19453 .await
19454 .unwrap_err();
19455 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19456 assert_no_session_state(&shared);
19457 drop(shared);
19458 drop(runtime);
19459 std::fs::remove_dir_all(root).unwrap();
19460 }
19461}
19462
19463#[cfg(all(test, windows))]
19464mod tests {
19465 use super::*;
19466 use gate4agent_types::ProviderActivity;
19467 use crate::session_environment::{
19468 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19469 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19470 NodeSessionPathClass,
19471 };
19472 use gate4agent_catalog::EnvMutation;
19473 use gate4agent_runtime_native::{
19474 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19475 NativeLaunchProfile, NativeLaunchProfileId,
19476 };
19477 use std::ffi::OsString;
19478 use std::sync::Arc;
19479
19480 struct EmptyEnvironmentResolver;
19481
19482 struct FixtureSecretResolver {
19483 deny: Arc<AtomicBool>,
19484 }
19485
19486 impl NodeSecretResolver for FixtureSecretResolver {
19487 fn resolve(
19488 &self,
19489 _reference: &NodeSecretReference,
19490 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19491 if self.deny.load(Ordering::Acquire) {
19492 Err(NodeSecretResolveError::Denied)
19493 } else {
19494 NodeSecretValue::text("fixture-secret")
19495 .map_err(|_| NodeSecretResolveError::Unavailable)
19496 }
19497 }
19498 }
19499
19500 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19501 fn resolve_child_environment(
19502 &self,
19503 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19504 Ok(vec![EnvMutation {
19505 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19506 value: None,
19507 }])
19508 }
19509 }
19510
19511 fn agent(value: &str) -> AgentId {
19512 AgentId::new(value).unwrap()
19513 }
19514
19515 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19516 let catalog = active_registry().unwrap();
19517 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19518 let workspace = WorkspaceConfig::new(
19519 WorkspaceId::new("primary").unwrap(),
19520 std::env::current_dir().unwrap(),
19521 )
19522 .unwrap()
19523 .with_worktree_service_mode(mode);
19524 NodeShared::new(
19525 handle,
19526 "fixture-token".to_owned(),
19527 NodeId::new("node-terminal-test").unwrap(),
19528 vec![workspace],
19529 vec![agent("claude")],
19530 )
19531 }
19532
19533 fn terminal_test_shared() -> NodeShared {
19534 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19535 }
19536
19537 #[cfg(feature = "fixture")]
19538 #[test]
19539 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19540 let probe = SpawnManagedWorktreeV2FailureProbe::default();
19541 assert_eq!(probe.latest_code(), None);
19542
19543 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19544 assert_eq!(
19545 probe.latest_code(),
19546 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19547 );
19548
19549 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19550 assert_eq!(
19551 probe.latest_code(),
19552 Some(NodeFailureCode::SpawnDeadlineExceeded),
19553 );
19554
19555 probe.record(None);
19556 assert_eq!(probe.latest_code(), None);
19557 }
19558
19559 #[test]
19560 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19561 let source = temporary_workspace_root("launch-inventory-source");
19562 let allocation = temporary_workspace_root("launch-inventory-allocation");
19563 std::fs::create_dir_all(&source).unwrap();
19564 std::fs::create_dir_all(&allocation).unwrap();
19565 let workspace_id = WorkspaceId::new("primary").unwrap();
19566 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19567 .unwrap()
19568 .with_worktree_service_mode(WorktreeServiceMode::Managed);
19569 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19570 workspace = workspace
19571 .with_managed_worktree_profile(
19572 ManagedWorktreeProfile::new(
19573 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19574 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19575 &allocation,
19576 "gate4agent",
19577 "HEAD",
19578 ManagedWorktreeRetention::RemoveWhenReleased,
19579 )
19580 .unwrap(),
19581 )
19582 .unwrap();
19583 }
19584 let overflow = workspace.clone().with_managed_worktree_profile(
19585 ManagedWorktreeProfile::new(
19586 WorktreeProfileId::new("overflow").unwrap(),
19587 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19588 &allocation,
19589 "gate4agent",
19590 "HEAD",
19591 ManagedWorktreeRetention::Retain,
19592 )
19593 .unwrap(),
19594 );
19595 assert!(matches!(
19596 overflow,
19597 Err(NodeServerError::ManagedWorktreeProfileCapacity {
19598 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19599 ..
19600 }),
19601 ));
19602
19603 let catalog = active_registry().unwrap();
19604 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19605 let shared = NodeShared::new(
19606 handle,
19607 "fixture-token".to_owned(),
19608 NodeId::new("node-launch-inventory").unwrap(),
19609 vec![workspace],
19610 vec![agent("claude")],
19611 );
19612 let snapshot = shared.snapshot();
19613 let launch = snapshot.launch_inventory.as_ref().unwrap();
19614 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19615 assert!(launch.bundles.as_ref().unwrap().is_empty());
19616 let profiles = snapshot.workspaces[0]
19617 .managed_worktree_profiles
19618 .as_ref()
19619 .unwrap();
19620 assert_eq!(
19621 snapshot.workspaces[0].worktree_service_mode,
19622 Some(WorktreeServiceMode::Managed),
19623 );
19624 assert_eq!(
19625 profiles.profiles.len(),
19626 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19627 );
19628 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19629 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19630 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19631 }
19632 let legacy = snapshot_for_wire(
19633 &shared,
19634 true,
19635 true,
19636 false,
19637 false,
19638 true,
19639 false,
19640 true,
19641 false,
19642 true,
19643 );
19644 assert!(legacy.launch_inventory.is_none());
19645 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19646
19647 drop(shared);
19648 std::fs::remove_dir_all(source).unwrap();
19649 std::fs::remove_dir_all(allocation).unwrap();
19650 }
19651
19652 #[test]
19653 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19654 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19655 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19656 }));
19657 let source = gate4agent_types::ProviderSource {
19658 family: AdapterFamily::PtySemantic,
19659 binding: AdapterBinding::new(
19660 gate4agent_types::AdapterId::new("claude").unwrap(),
19661 "fixture/v1",
19662 gate4agent_types::AdapterVerification::SyntheticFixture,
19663 )
19664 .unwrap(),
19665 };
19666 let provider = ProviderSnapshot {
19667 sequence: 17,
19668 session: Some(ProviderSessionIdentity {
19669 key: ProviderSessionKey::SessionId,
19670 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19671 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
19672 }),
19673 model: Some("PRIVATE_MODEL".to_owned()),
19674 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
19675 completed_turns: 3,
19676 usage: gate4agent_types::TokenUsage {
19677 input_tokens: 10,
19678 output_tokens: 20,
19679 cache_read_tokens: 30,
19680 cache_write_tokens: 40,
19681 reasoning_tokens: 50,
19682 context_window: Some(200_000),
19683 },
19684 lead_activity: ProviderActivity::WaitingForInput,
19685 activity: ProviderActivity::WaitingForInput,
19686 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
19687 active_tools: (0..9)
19688 .map(|index| gate4agent_types::ActiveProviderTool {
19689 id: format!("private-tool-id-{index}"),
19690 name: if index == 0 {
19691 format!(" Read\u{0000}{}", "x".repeat(80))
19692 } else {
19693 format!("Tool-{index}")
19694 },
19695 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
19696 })
19697 .collect(),
19698 interactions: vec![gate4agent_types::ProviderInteraction {
19699 id: gate4agent_types::ProviderInteractionId(17),
19700 source: source.clone(),
19701 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
19702 interaction_kind: ProviderInteractionKind::Approval,
19703 tool_name: " Approve\u{0007}Tool ".to_owned(),
19704 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
19705 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
19706 resume_lead_activity: Some(ProviderActivity::Working),
19707 status: ProviderInteractionStatus::Pending,
19708 }],
19709 subagents: vec![gate4agent_types::ProviderSubagent {
19710 source: source.clone(),
19711 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
19712 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
19713 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
19714 }],
19715 sources: vec![gate4agent_types::ProviderSourceCursor {
19716 source,
19717 sequence: 17,
19718 gap_count: 2,
19719 stale: true,
19720 }],
19721 last_event: Some(ProviderEvent::Error {
19722 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
19723 }),
19724 gap_count: 2,
19725 stale: true,
19726 };
19727 let address = terminal_address(3);
19728 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
19729 assert_eq!(entry.address, address);
19730 assert_eq!(entry.progress.provider_sequence, 17);
19731 assert_eq!(entry.progress.active_tool_count, 9);
19732 assert_eq!(
19733 entry.progress.active_tool_labels.len(),
19734 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
19735 );
19736 assert_eq!(entry.progress.subagent_count, 1);
19737 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
19738 assert!(entry.progress.truncated);
19739 let encoded = serde_json::to_string(&entry).unwrap();
19740 for forbidden in [
19741 "PRIVATE_PROVIDER_SESSION",
19742 "PRIVATE_PROVIDER_PATH",
19743 "PRIVATE_MODEL",
19744 "PRIVATE_TOOL_INVENTORY",
19745 "PRIVATE_CURRENT_PROMPT",
19746 "PRIVATE_TOOL_INPUT_JSON",
19747 "PRIVATE_PROVIDER_REQUEST",
19748 "PRIVATE_INTERACTION_PROMPT",
19749 "PRIVATE_INTERACTION_AGENT",
19750 "PRIVATE_SUBAGENT_ID",
19751 "PRIVATE_SUBAGENT_TYPE",
19752 "PRIVATE_SUBAGENT_DESCRIPTION",
19753 "PRIVATE_PROVIDER_ERROR",
19754 "input_json",
19755 "current_prompt",
19756 "session_id",
19757 ] {
19758 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
19759 }
19760
19761 let snapshot = NodeSnapshot {
19762 node_id: NodeId::new("node-progress-test").unwrap(),
19763 enabled_providers: vec![agent("claude")],
19764 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19765 workspaces: Vec::new(),
19766 session_records: Vec::new(),
19767 managed_worktrees: Vec::new(),
19768 launch_inventory: None,
19769 agent_progress: vec![entry],
19770 };
19771 let mut legacy_reply = ResponseEnvelope {
19772 request_id: 1,
19773 result: Ok(NodeResponse::Snapshot {
19774 event_sequence: 0,
19775 controller: None,
19776 snapshot: snapshot.clone(),
19777 }),
19778 };
19779 project_response_without_agent_progress(&mut legacy_reply);
19780 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
19781 panic!("agent progress projection changed response kind");
19782 };
19783 assert!(legacy.agent_progress.is_empty());
19784 assert_eq!(snapshot.agent_progress.len(), 1);
19785 }
19786
19787 fn terminal_address(generation: u64) -> SessionAddress {
19788 SessionAddress {
19789 workspace_id: WorkspaceId::new("primary").unwrap(),
19790 session: SessionKey {
19791 instance_id: AgentInstanceId(41),
19792 generation: SessionGeneration(generation),
19793 },
19794 }
19795 }
19796
19797 fn spawn_spec_fixture() -> SpawnSpec {
19798 SpawnSpec {
19799 target: crate::protocol::SpawnTarget {
19800 node_id: NodeId::new("node-terminal-test").unwrap(),
19801 workspace_id: WorkspaceId::new("primary").unwrap(),
19802 worktree_id: None,
19803 },
19804 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19805 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
19806 .unwrap(),
19807 overrides: crate::protocol::SpawnOverrides::default(),
19808 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19809 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
19810 required_capabilities: SpawnRequiredCapabilities::default(),
19811 }
19812 }
19813
19814 fn environment_profile_test_shared(
19815 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
19816 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
19817 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19818 "local-claude-r1",
19819 )
19820 .unwrap();
19821 let spawn_profiles = SpawnProfileRegistry::new([
19822 crate::protocol::SpawnProfileDefaults {
19823 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19824 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
19825 provider: agent("claude"),
19826 mode: SessionMode::Pty,
19827 terminal_size: gate4agent_types::TerminalSize {
19828 rows: 24,
19829 columns: 80,
19830 },
19831 prompt: None,
19832 bundle_id: None,
19833 context_id: None,
19834 environment_profile_id: Some(profile_id.clone()),
19835 },
19836 ])
19837 .unwrap();
19838 let catalog = active_registry().unwrap();
19839 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19840 let control = runtime.native_launch_profile_control();
19841 let node_profile = NodeEnvironmentProfile::new(
19842 profile_id.clone(),
19843 profile_revision.clone(),
19844 agent("claude"),
19845 [NativeLaunchProfile::new(
19846 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
19847 agent("claude"),
19848 TransportKind::Pty,
19849 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19850 Arc::new(EmptyEnvironmentResolver),
19851 )
19852 .unwrap()],
19853 )
19854 .unwrap();
19855 let (binding, native_profiles, _materialization) = node_profile.into_parts();
19856 for native_profile in native_profiles {
19857 runtime.upsert_native_launch_profile(native_profile).unwrap();
19858 }
19859 let workspace = WorkspaceConfig::new(
19860 WorkspaceId::new("primary").unwrap(),
19861 std::env::current_dir().unwrap(),
19862 )
19863 .unwrap();
19864 let shared = NodeShared::new_with_incarnation(
19865 handle,
19866 "fixture-token".to_owned(),
19867 NodeId::new("node-terminal-test").unwrap(),
19868 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19869 vec![workspace],
19870 vec![agent("claude")],
19871 ProviderRuntimeStatuses::default(),
19872 None,
19873 Vec::new(),
19874 Vec::new(),
19875 spawn_profiles,
19876 Some(control),
19877 None,
19878 None,
19879 None,
19880 Vec::new(),
19881 Vec::new(),
19882 Vec::new(),
19883 Vec::new(),
19884 None,
19885 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19886 );
19887 shared
19888 .environment_profiles
19889 .write()
19890 .unwrap_or_else(|poisoned| poisoned.into_inner())
19891 .insert(binding.id.clone(), binding);
19892 (
19893 shared,
19894 runtime,
19895 ResolvedEnvironmentProfileReceipt {
19896 profile_id,
19897 profile_revision,
19898 network_allowlist: None,
19899 browser_profile_id: None,
19900 },
19901 )
19902 }
19903
19904 fn materialization_test_shared(
19905 deny_secret: bool,
19906 ) -> (
19907 NodeShared,
19908 NativeRuntime,
19909 ResolvedEnvironmentProfileReceipt,
19910 PathBuf,
19911 Arc<AtomicBool>,
19912 ) {
19913 let workspace = WorkspaceConfig::new(
19914 WorkspaceId::new("primary").unwrap(),
19915 std::env::current_dir().unwrap(),
19916 )
19917 .unwrap();
19918 materialization_test_shared_with_workspace(deny_secret, workspace)
19919 }
19920
19921 fn materialization_test_shared_with_workspace(
19922 deny_secret: bool,
19923 workspace: WorkspaceConfig,
19924 ) -> (
19925 NodeShared,
19926 NativeRuntime,
19927 ResolvedEnvironmentProfileReceipt,
19928 PathBuf,
19929 Arc<AtomicBool>,
19930 ) {
19931 let root = temporary_workspace_root(if deny_secret {
19932 "materialization-denied"
19933 } else {
19934 "materialization-ready"
19935 });
19936 std::fs::create_dir_all(&root).unwrap();
19937 let materialization_root = root.join("session-environments");
19938 let deny = Arc::new(AtomicBool::new(deny_secret));
19939 let resolver = Arc::new(FixtureSecretResolver {
19940 deny: Arc::clone(&deny),
19941 });
19942 let materializer = SessionEnvironmentMaterializer::new(
19943 materialization_root,
19944 resolver,
19945 )
19946 .unwrap();
19947 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19948 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19949 "materialized-claude-r1",
19950 )
19951 .unwrap();
19952 let materialization = NodeSessionMaterializationProfile::new(
19953 vec![NodeSessionEnvironmentMutation::SetSecret {
19954 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19955 reference: NodeSecretReference::new("fixture-token").unwrap(),
19956 }],
19957 vec![NodeSessionPathBinding::new(
19958 "GATE4AGENT_PROVIDER_HOME",
19959 NodeSessionPathClass::ProviderHome,
19960 )
19961 .unwrap()],
19962 vec![NodeSessionFile::secret(
19963 NodeSessionPathClass::Config,
19964 "auth/token",
19965 NodeSecretReference::new("fixture-token").unwrap(),
19966 )
19967 .unwrap()],
19968 )
19969 .unwrap();
19970 let catalog = active_registry().unwrap();
19971 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19972 let control = runtime.native_launch_profile_control();
19973 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19974 profile_id.clone(),
19975 profile_revision.clone(),
19976 agent("claude"),
19977 [NativeLaunchProfile::new(
19978 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19979 agent("claude"),
19980 TransportKind::Pty,
19981 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19982 Arc::new(EmptyEnvironmentResolver),
19983 )
19984 .unwrap()],
19985 Some(materialization),
19986 )
19987 .unwrap();
19988 let (binding, native_profiles, materialization) = node_profile.into_parts();
19989 for native_profile in native_profiles {
19990 runtime.upsert_native_launch_profile(native_profile).unwrap();
19991 }
19992 let shared = NodeShared::new_with_incarnation(
19993 handle,
19994 "fixture-token".to_owned(),
19995 NodeId::new("node-materialization-test").unwrap(),
19996 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19997 vec![workspace],
19998 vec![agent("claude")],
19999 ProviderRuntimeStatuses::default(),
20000 None,
20001 Vec::new(),
20002 Vec::new(),
20003 SpawnProfileRegistry::default(),
20004 Some(control),
20005 None,
20006 Some(materializer),
20007 None,
20008 Vec::new(),
20009 Vec::new(),
20010 Vec::new(),
20011 Vec::new(),
20012 None,
20013 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20014 );
20015 shared
20016 .environment_profiles
20017 .write()
20018 .unwrap_or_else(|poisoned| poisoned.into_inner())
20019 .insert(binding.id.clone(), binding);
20020 shared
20021 .environment_materialization_profiles
20022 .write()
20023 .unwrap_or_else(|poisoned| poisoned.into_inner())
20024 .insert(profile_id.clone(), materialization.unwrap());
20025 (
20026 shared,
20027 runtime,
20028 ResolvedEnvironmentProfileReceipt {
20029 profile_id,
20030 profile_revision,
20031 network_allowlist: None,
20032 browser_profile_id: None,
20033 },
20034 root,
20035 deny,
20036 )
20037 }
20038
20039 #[cfg(feature = "fixture")]
20040 #[derive(Clone, Copy)]
20041 enum CodexHomeProfile {
20042 Missing,
20043 WrongClass,
20044 Exact,
20045 }
20046
20047 #[cfg(feature = "fixture")]
20048 fn codex_bundle_test_shared(
20049 home_profile: CodexHomeProfile,
20050 ) -> (
20051 NodeShared,
20052 NativeRuntime,
20053 ResolvedEnvironmentProfileReceipt,
20054 NodeBundle,
20055 PathBuf,
20056 ) {
20057 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20058 const CLAUDE_MANIFEST: &[u8] =
20059 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20060 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20061 const DIGEST: &str =
20062 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20063
20064 let (shared, mut runtime, _claude_receipt, root, _deny) =
20065 materialization_test_shared(false);
20066 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20067 CodexHomeProfile::Missing => "codex-home-missing",
20068 CodexHomeProfile::WrongClass => "codex-home-wrong",
20069 CodexHomeProfile::Exact => "codex-home-exact",
20070 })
20071 .unwrap();
20072 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20073 "codex-home-r1",
20074 )
20075 .unwrap();
20076 let materialization = match home_profile {
20077 CodexHomeProfile::Missing => None,
20078 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20079 Some(NodeSessionMaterializationProfile::new(
20080 Vec::new(),
20081 vec![NodeSessionPathBinding::new(
20082 "CODEX_HOME",
20083 if matches!(home_profile, CodexHomeProfile::Exact) {
20084 NodeSessionPathClass::ProviderHome
20085 } else {
20086 NodeSessionPathClass::Config
20087 },
20088 )
20089 .unwrap()],
20090 Vec::new(),
20091 )
20092 .unwrap())
20093 }
20094 };
20095 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20096 profile_id.clone(),
20097 profile_revision.clone(),
20098 agent("codex"),
20099 [NativeLaunchProfile::new(
20100 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20101 agent("codex"),
20102 TransportKind::Pty,
20103 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20104 Arc::new(EmptyEnvironmentResolver),
20105 )
20106 .unwrap()],
20107 materialization,
20108 )
20109 .unwrap();
20110 let (binding, native_profiles, materialization) = node_profile.into_parts();
20111 for native_profile in native_profiles {
20112 runtime.upsert_native_launch_profile(native_profile).unwrap();
20113 }
20114 shared
20115 .environment_profiles
20116 .write()
20117 .unwrap_or_else(|poisoned| poisoned.into_inner())
20118 .insert(binding.id.clone(), binding);
20119 if let Some(materialization) = materialization {
20120 shared
20121 .environment_materialization_profiles
20122 .write()
20123 .unwrap_or_else(|poisoned| poisoned.into_inner())
20124 .insert(profile_id.clone(), materialization);
20125 }
20126
20127 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20128 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20129 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20130 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20131 std::fs::write(
20132 bundle_source.join(".claude-plugin/plugin.json"),
20133 CLAUDE_MANIFEST,
20134 )
20135 .unwrap();
20136 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20137 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20138 let bundle = NodeBundle::new(
20139 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20140 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20141 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20142 &bundle_source,
20143 )
20144 .unwrap();
20145 *shared
20146 .bundle_catalog
20147 .write()
20148 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20149 BundleCatalog::new([bundle.clone()]).unwrap();
20150 (
20151 shared,
20152 runtime,
20153 ResolvedEnvironmentProfileReceipt {
20154 profile_id,
20155 profile_revision,
20156 network_allowlist: None,
20157 browser_profile_id: None,
20158 },
20159 bundle,
20160 root,
20161 )
20162 }
20163
20164 #[cfg(feature = "fixture")]
20165 #[test]
20166 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20167 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20168 let (shared, runtime, receipt, bundle, root) =
20169 codex_bundle_test_shared(home_profile);
20170 let error = shared
20171 .bundle_layout(
20172 &agent("codex"),
20173 SessionMode::Pty,
20174 Some(&receipt),
20175 &bundle,
20176 )
20177 .unwrap_err();
20178 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20179 assert!(shared
20180 .materializations
20181 .lock()
20182 .unwrap_or_else(|poisoned| poisoned.into_inner())
20183 .is_empty());
20184 let materialization_root = root.join("session-environments");
20185 assert!(!std::fs::read_dir(&materialization_root)
20186 .unwrap()
20187 .filter_map(Result::ok)
20188 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20189 drop(shared);
20190 drop(runtime);
20191 let _ = std::fs::remove_dir_all(root);
20192 }
20193 }
20194
20195 #[cfg(feature = "fixture")]
20196 #[test]
20197 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20198 let (shared, runtime, receipt, bundle, root) =
20199 codex_bundle_test_shared(CodexHomeProfile::Exact);
20200 let address = SessionAddress {
20201 workspace_id: WorkspaceId::new("primary").unwrap(),
20202 session: SessionKey {
20203 instance_id: AgentInstanceId(310),
20204 generation: SessionGeneration(1),
20205 },
20206 };
20207 let (overlay, guard) = shared
20208 .prepare_session_materialization(
20209 &address,
20210 &agent("codex"),
20211 SessionMode::Pty,
20212 Some(&receipt),
20213 Some(&bundle.receipt()),
20214 None,
20215 None,
20216 )
20217 .unwrap()
20218 .unwrap();
20219 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20220 let ownership = shared
20221 .materializations
20222 .lock()
20223 .unwrap_or_else(|poisoned| poisoned.into_inner())
20224 .get(guard.id().unwrap())
20225 .unwrap()
20226 .clone();
20227 let (profile, _, _) = shared
20228 .materialization_profile(
20229 &agent("codex"),
20230 SessionMode::Pty,
20231 Some(&receipt),
20232 Some(&bundle.receipt()),
20233 None,
20234 )
20235 .unwrap()
20236 .unwrap();
20237 let environment = shared
20238 .session_environment_materializer
20239 .as_ref()
20240 .unwrap()
20241 .resolve_environment(&ownership, &profile)
20242 .unwrap();
20243 assert!(environment.iter().any(|mutation| {
20244 mutation.key == OsString::from("CODEX_HOME")
20245 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20246 }));
20247 assert_eq!(
20248 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20249 .unwrap(),
20250 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20251 );
20252 assert!(std::fs::read_dir(ownership.bundle_root())
20253 .unwrap()
20254 .next()
20255 .is_none());
20256 drop(guard);
20257 drop(shared);
20258 drop(runtime);
20259 let _ = std::fs::remove_dir_all(root);
20260 }
20261
20262 #[cfg(feature = "fixture")]
20263 #[test]
20264 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20265 let (shared, runtime, receipt, bundle, root) =
20266 codex_bundle_test_shared(CodexHomeProfile::Exact);
20267 let bundle_receipt = bundle.receipt();
20268 let address = SessionAddress {
20269 workspace_id: WorkspaceId::new("primary").unwrap(),
20270 session: SessionKey {
20271 instance_id: AgentInstanceId(311),
20272 generation: SessionGeneration(1),
20273 },
20274 };
20275 let (_overlay, guard) = shared
20276 .prepare_session_materialization(
20277 &address,
20278 &agent("codex"),
20279 SessionMode::Pty,
20280 Some(&receipt),
20281 Some(&bundle_receipt),
20282 None,
20283 None,
20284 )
20285 .unwrap()
20286 .unwrap();
20287 let materialization_id = guard.id().unwrap().clone();
20288 let skill_path = shared
20289 .materializations
20290 .lock()
20291 .unwrap_or_else(|poisoned| poisoned.into_inner())
20292 .get(&materialization_id)
20293 .unwrap()
20294 .provider_home()
20295 .join("skills/review-code/SKILL.md");
20296 let record_id = shared
20297 .bind_spawn_session_with_materialization(
20298 &address,
20299 agent("codex"),
20300 SessionMode::Pty,
20301 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20302 SpawnRecordPolicy::ProviderIdentityOnly,
20303 Some(receipt.clone()),
20304 Some(bundle_receipt.clone()),
20305 None,
20306 Some(materialization_id.clone()),
20307 )
20308 .unwrap()
20309 .unwrap();
20310 guard.retain();
20311 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20312
20313 let error = match shared.resolve_record_materialization(
20314 &record_id,
20315 &agent("codex"),
20316 SessionMode::Pty,
20317 Some(&receipt),
20318 Some(&bundle_receipt),
20319 None,
20320 ) {
20321 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20322 Err(error) => error,
20323 };
20324 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20325 assert_eq!(
20326 shared
20327 .materializations
20328 .lock()
20329 .unwrap_or_else(|poisoned| poisoned.into_inner())
20330 .get(&materialization_id)
20331 .unwrap()
20332 .state(),
20333 MaterializationState::RecoveryRequired,
20334 );
20335 drop(shared);
20336 drop(runtime);
20337 let _ = std::fs::remove_dir_all(root);
20338 }
20339
20340 fn bind_record_owned_materialization_fixture(
20341 shared: &NodeShared,
20342 receipt: &ResolvedEnvironmentProfileReceipt,
20343 instance_id: u64,
20344 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20345 let address = SessionAddress {
20346 workspace_id: WorkspaceId::new("primary").unwrap(),
20347 session: SessionKey {
20348 instance_id: AgentInstanceId(instance_id),
20349 generation: SessionGeneration(1),
20350 },
20351 };
20352 let (overlay, materialization_guard) = shared
20353 .prepare_session_materialization(
20354 &address,
20355 &agent("claude"),
20356 SessionMode::Pty,
20357 Some(receipt),
20358 None,
20359 None,
20360 None,
20361 )
20362 .unwrap()
20363 .unwrap();
20364 let materialization_id = materialization_guard.id().unwrap().clone();
20365 let materialization_root = shared
20366 .materializations
20367 .lock()
20368 .unwrap_or_else(|poisoned| poisoned.into_inner())
20369 .get(&materialization_id)
20370 .unwrap()
20371 .root()
20372 .to_path_buf();
20373 let selection = shared
20374 .select_environment_profile(
20375 address.session.instance_id,
20376 &agent("claude"),
20377 SessionMode::Pty,
20378 Some(receipt),
20379 )
20380 .unwrap()
20381 .unwrap();
20382 assert!(shared
20383 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20384 .unwrap()
20385 .is_none());
20386 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20387 let record_id = shared
20388 .bind_spawn_session_with_materialization(
20389 &address,
20390 agent("claude"),
20391 SessionMode::Pty,
20392 policy,
20393 SpawnRecordPolicy::ProviderIdentityOnly,
20394 Some(receipt.clone()),
20395 None,
20396 None,
20397 Some(materialization_id.clone()),
20398 )
20399 .unwrap()
20400 .unwrap();
20401 selection.retain();
20402 materialization_guard.retain();
20403 (address, record_id, materialization_id, materialization_root)
20404 }
20405
20406 #[test]
20407 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20408 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20409 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20410 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20411 }));
20412 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20413 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20414 let mut spec = spawn_spec_fixture();
20415 spec.expected_profile_revision =
20416 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20417 assert!(matches!(
20418 &spec.overrides.environment_profile_id,
20419 crate::protocol::SpawnOverride::Inherit,
20420 ));
20421 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20422 .requires_child_environment_profile_capability());
20423 assert!(request_requires_child_environment_profile(
20424 &shared,
20425 &NodeRequest::SpawnSpec { spec: spec.clone() },
20426 ));
20427
20428 let address = terminal_address(1);
20429 shared.bind_session_with_policy(
20430 &address,
20431 ProviderRuntimePolicy::raw_pty(),
20432 Some(environment_profile.clone()),
20433 );
20434 assert!(request_requires_child_environment_profile(
20435 &shared,
20436 &NodeRequest::Input {
20437 session: address.clone(),
20438 text: "x".to_owned(),
20439 },
20440 ));
20441
20442 let mut record = record("claude", "profile-record");
20443 record.active_session = Some(address.clone());
20444 record.environment_profile = Some(environment_profile.clone());
20445 let record_id = record.record_id.clone();
20446 shared.insert_record(record.clone()).unwrap();
20447 assert!(request_requires_child_environment_profile(
20448 &shared,
20449 &NodeRequest::ResumeSessionRecord {
20450 record_id,
20451 terminal_size: gate4agent_types::TerminalSize {
20452 rows: 24,
20453 columns: 80,
20454 },
20455 initial_prompt: None,
20456 },
20457 ));
20458
20459 let snapshot = snapshot_for_wire(
20460 &shared, false, true, true, true, false, false, false, false, true,
20461 );
20462 assert_eq!(snapshot.session_records.len(), 1);
20463 assert!(snapshot.session_records[0].environment_profile.is_none());
20464 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20465 sequence: 1,
20466 event: NodeEvent::SessionRecordUpserted { record },
20467 });
20468 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20469 panic!("record projection changed the event kind");
20470 };
20471 assert!(record.environment_profile.is_none());
20472
20473 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20474 let mut reply = ResponseEnvelope {
20475 request_id: 1,
20476 result: Ok(NodeResponse::SpawnSpecAccepted {
20477 receipt: resolved.receipt_with_environment(
20478 shared.incarnation_id,
20479 address,
20480 Some(environment_profile),
20481 ),
20482 }),
20483 };
20484 project_response_without_child_environment_profile(&mut reply);
20485 assert_eq!(
20486 reply.result.unwrap_err().code,
20487 NodeFailureCode::UnsupportedCapability,
20488 );
20489 }
20490
20491 #[test]
20492 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20493 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20494 let snapshot = shared.snapshot();
20495 let profiles = snapshot
20496 .launch_inventory
20497 .as_ref()
20498 .and_then(|inventory| inventory.spawn_profiles.as_ref())
20499 .unwrap();
20500 assert_eq!(profiles.len(), 1);
20501 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20502 assert!(snapshot.requires_child_environment_profile_capability());
20503
20504 let mut legacy = snapshot.clone();
20505 clear_snapshot_child_environment_profiles(&mut legacy);
20506 assert!(legacy
20507 .launch_inventory
20508 .as_ref()
20509 .unwrap()
20510 .spawn_profiles
20511 .as_ref()
20512 .unwrap()[0]
20513 .environment_profile
20514 .is_none());
20515 assert!(!legacy.requires_child_environment_profile_capability());
20516
20517 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20518 let bindings = shared
20519 .environment_profiles
20520 .read()
20521 .unwrap_or_else(|poisoned| poisoned.into_inner())
20522 .clone();
20523 let mut provider_mismatch = bindings.clone();
20524 provider_mismatch
20525 .values_mut()
20526 .next()
20527 .unwrap()
20528 .provider = agent("codex");
20529 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20530 let mut mode_mismatch = profile;
20531 mode_mismatch.mode = SessionMode::Inline;
20532 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20533
20534 shared
20535 .environment_profiles
20536 .write()
20537 .unwrap_or_else(|poisoned| poisoned.into_inner())
20538 .clear();
20539 assert!(shared
20540 .snapshot()
20541 .launch_inventory
20542 .unwrap()
20543 .spawn_profiles
20544 .unwrap()
20545 .is_empty());
20546 }
20547
20548 #[test]
20549 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20550 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20551 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20552 }));
20553 let shared = terminal_test_shared();
20554 let bundle = ResolvedBundleReceipt {
20555 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20556 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20557 digest: crate::protocol::SpawnBundleDigest::new(format!(
20558 "sha256:{}",
20559 "0".repeat(64),
20560 ))
20561 .unwrap(),
20562 };
20563 let mut bundled = record("claude", "bundle-record");
20564 bundled.bundle = Some(bundle.clone());
20565 let record_id = bundled.record_id.clone();
20566 shared.insert_record(bundled.clone()).unwrap();
20567 assert!(request_requires_session_bundle(
20568 &shared,
20569 &NodeRequest::ForgetSessionRecord {
20570 record_id: record_id.clone(),
20571 },
20572 ));
20573
20574 let snapshot = snapshot_for_wire(
20575 &shared, false, true, true, true, true, false, false, false, true,
20576 );
20577 assert!(snapshot.session_records[0].bundle.is_none());
20578 let launch = snapshot.launch_inventory.as_ref().unwrap();
20579 assert!(launch.spawn_profiles.is_some());
20580 assert!(launch.bundles.is_none());
20581 let event = project_event_without_session_bundle(NodeEventEnvelope {
20582 sequence: 1,
20583 event: NodeEvent::SessionRecordUpserted { record: bundled },
20584 });
20585 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20586 panic!("record projection changed the event kind");
20587 };
20588 assert!(record.bundle.is_none());
20589
20590 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20591 let mut reply = ResponseEnvelope {
20592 request_id: 1,
20593 result: Ok(NodeResponse::SpawnSpecAccepted {
20594 receipt: resolved.receipt_with_materialization(
20595 shared.incarnation_id,
20596 terminal_address(17),
20597 None,
20598 Some(bundle),
20599 None,
20600 ),
20601 }),
20602 };
20603 project_response_without_session_bundle(&mut reply);
20604 assert_eq!(
20605 reply.result.unwrap_err().code,
20606 NodeFailureCode::UnsupportedCapability,
20607 );
20608 }
20609
20610 #[test]
20611 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20612 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20613 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20614 }));
20615 let context_id = SpawnContextId::new("context-review").unwrap();
20616 let context = ResolvedContextPackReceipt {
20617 id: context_id.clone(),
20618 digest: crate::protocol::SpawnContextDigest::new(format!(
20619 "sha256:{}",
20620 "c".repeat(64),
20621 ))
20622 .unwrap(),
20623 lineage: ContextPackLineageReceipt {
20624 source_node_id: NodeId::new("node-source").unwrap(),
20625 source_session: terminal_address(1),
20626 source_provider: agent("kimi"),
20627 },
20628 source_message_count: 2,
20629 retained_message_count: 2,
20630 byte_len: 256,
20631 truncated: false,
20632 };
20633 assert!(context.is_valid());
20634 let mut bound_record = record("codex", "context-record");
20635 bound_record.context_id = Some(context_id);
20636 bound_record.context = Some(context.clone());
20637 let session = gate4agent_types::SessionSnapshot {
20638 instance_id: AgentInstanceId(91),
20639 agent_id: agent("kimi"),
20640 transport: TransportKind::Pty,
20641 generation: SessionGeneration(3),
20642 status: SessionStatus::Running,
20643 pending_operation: None,
20644 pending_input: None,
20645 process_id: Some(9001),
20646 terminal_size: None,
20647 terminal_frame: None,
20648 terminal_stale: None,
20649 session_options: None,
20650 capabilities: gate4agent_types::CapabilitySnapshot::default(),
20651 history: gate4agent_types::HistorySnapshot {
20652 pending: None,
20653 candidates: vec![HistoryCandidateSummary {
20654 id: "candidate-opaque".to_owned(),
20655 session_id_hint: "session-hint".to_owned(),
20656 modified_at_unix_ms: Some(1),
20657 }],
20658 loaded_candidate_id: Some("candidate-opaque".to_owned()),
20659 loaded: Some(HistorySessionRecord {
20660 session_id: "private-vendor-session".to_owned(),
20661 title: Some("private title".to_owned()),
20662 cwd: Some(r"C:\private\history-root".to_owned()),
20663 model: Some("private-model".to_owned()),
20664 message_count: 2,
20665 completed_turn_count: None,
20666 total_tokens: 19,
20667 messages: vec![
20668 gate4agent_types::HistoryMessageRecord {
20669 role: gate4agent_types::HistoryMessageRole::User,
20670 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20671 },
20672 gate4agent_types::HistoryMessageRecord {
20673 role: gate4agent_types::HistoryMessageRole::Assistant,
20674 text: "private answer".to_owned(),
20675 },
20676 ],
20677 }),
20678 last_error: None,
20679 },
20680 resume: gate4agent_types::ResumeSnapshot::default(),
20681 foreground: gate4agent_types::ForegroundSnapshot::default(),
20682 provider: gate4agent_types::ProviderSnapshot::default(),
20683 screen_state: gate4agent_types::PtyScreenState::default(),
20684 };
20685 let original = NodeSnapshot {
20686 node_id: NodeId::new("node-terminal-test").unwrap(),
20687 enabled_providers: vec![agent("kimi"), agent("codex")],
20688 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20689 workspaces: vec![WorkspaceSnapshot {
20690 workspace_id: WorkspaceId::new("primary").unwrap(),
20691 canonical_root: opaque_windows_path(
20692 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20693 ),
20694 sessions: vec![session],
20695 worktree_service_mode: None,
20696 managed_worktree_profiles: None,
20697 }],
20698 session_records: vec![bound_record],
20699 managed_worktrees: Vec::new(),
20700 launch_inventory: None,
20701 agent_progress: Vec::new(),
20702 };
20703
20704 let mut negotiated = original.clone();
20705 project_snapshot_history_for_wire(&mut negotiated, true);
20706 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
20707 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
20708 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
20709 assert!(!serde_json::to_string(&negotiated)
20710 .unwrap()
20711 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
20712
20713 let mut legacy = original;
20714 project_snapshot_history_for_wire(&mut legacy, false);
20715 clear_snapshot_context_packs(&mut legacy);
20716 assert_eq!(
20717 legacy.workspaces[0].sessions[0].history,
20718 gate4agent_types::HistorySnapshot::default(),
20719 );
20720 assert!(legacy.session_records[0].context_id.is_none());
20721 assert!(legacy.session_records[0].context.is_none());
20722 let encoded = serde_json::to_string(&legacy).unwrap();
20723 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
20724 assert!(!encoded.contains("context-review"));
20725
20726 let mut reply = ResponseEnvelope {
20727 request_id: 7,
20728 result: Ok(NodeResponse::ContextPackExported { context }),
20729 };
20730 project_response_without_context_pack(&mut reply);
20731 assert_eq!(
20732 reply.result.unwrap_err().code,
20733 NodeFailureCode::UnsupportedCapability,
20734 );
20735 }
20736
20737 #[test]
20738 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
20739 let shared = terminal_test_shared();
20740 let history = HistorySessionRecord {
20741 session_id: "fixture-history".to_owned(),
20742 title: None,
20743 cwd: None,
20744 model: None,
20745 message_count: 1,
20746 completed_turn_count: None,
20747 total_tokens: 0,
20748 messages: vec![gate4agent_types::HistoryMessageRecord {
20749 role: gate4agent_types::HistoryMessageRole::User,
20750 text: "bounded handoff".to_owned(),
20751 }],
20752 };
20753 let pack = NodeContextPack::export(
20754 ContextPackLineageReceipt {
20755 source_node_id: shared.node_id.clone(),
20756 source_session: terminal_address(1),
20757 source_provider: agent("claude"),
20758 },
20759 &history,
20760 )
20761 .unwrap();
20762 let context = shared
20763 .context_catalog
20764 .write()
20765 .unwrap_or_else(|poisoned| poisoned.into_inner())
20766 .insert(pack)
20767 .unwrap();
20768 let mut retained = record("codex", "context-retained-record");
20769 retained.context_id = Some(context.id.clone());
20770 retained.context = Some(context.clone());
20771 let record_id = retained.record_id.clone();
20772 shared.insert_record(retained).unwrap();
20773 assert_eq!(
20774 shared.forget_context_pack(&context.id).unwrap_err().code,
20775 NodeFailureCode::ContextPackBusy,
20776 );
20777 assert!(shared
20778 .context_catalog
20779 .read()
20780 .unwrap_or_else(|poisoned| poisoned.into_inner())
20781 .get(&context.id)
20782 .is_some());
20783 shared
20784 .session_records
20785 .lock()
20786 .unwrap_or_else(|poisoned| poisoned.into_inner())
20787 .records
20788 .remove(&record_id);
20789
20790 let mut spec = spawn_spec_fixture();
20791 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
20792 value: context.id.clone(),
20793 };
20794 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20795 let receipt = resolved.receipt_with_materialization(
20796 shared.incarnation_id,
20797 terminal_address(2),
20798 None,
20799 None,
20800 Some(context.clone()),
20801 );
20802 assert!(receipt.context_binding_is_valid());
20803 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
20804
20805 shared.forget_context_pack(&context.id).unwrap();
20806 assert!(shared
20807 .context_catalog
20808 .read()
20809 .unwrap_or_else(|poisoned| poisoned.into_inner())
20810 .get(&context.id)
20811 .is_none());
20812 assert!(shared
20813 .replay_spawn_spec(&spec, Instant::now())
20814 .unwrap()
20815 .is_none());
20816 }
20817
20818 #[test]
20819 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
20820 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20821 let address = terminal_address(1);
20822 let selection = shared
20823 .select_environment_profile(
20824 address.session.instance_id,
20825 &agent("claude"),
20826 SessionMode::Pty,
20827 Some(&environment_profile),
20828 )
20829 .unwrap()
20830 .unwrap();
20831 shared.bind_session_with_policy(
20832 &address,
20833 ProviderRuntimePolicy::raw_pty(),
20834 Some(environment_profile.clone()),
20835 );
20836 selection.retain();
20837 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20838 assert!(matches!(
20839 runtime.remove_native_launch_profile(&native_id),
20840 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20841 ));
20842 assert!(shared.remove_binding(&address).is_some());
20843 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20844
20845 let mismatched = ResolvedEnvironmentProfileReceipt {
20846 profile_id: environment_profile.profile_id,
20847 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
20848 "local-claude-r2",
20849 )
20850 .unwrap(),
20851 network_allowlist: None,
20852 browser_profile_id: None,
20853 };
20854 let error = match shared.select_environment_profile(
20855 AgentInstanceId(42),
20856 &agent("claude"),
20857 SessionMode::Pty,
20858 Some(&mismatched),
20859 ) {
20860 Ok(_) => panic!("mismatched environment profile revision was selected"),
20861 Err(error) => error,
20862 };
20863 assert_eq!(
20864 error.code,
20865 NodeFailureCode::EnvironmentProfileBindingMismatch,
20866 );
20867 assert!(!shared
20868 .session_bindings
20869 .lock()
20870 .unwrap_or_else(|poisoned| poisoned.into_inner())
20871 .contains_key(&AgentInstanceId(42)));
20872 }
20873
20874 #[test]
20875 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
20876 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20877 let address = terminal_address(1);
20878 let selection = shared
20879 .select_environment_profile(
20880 address.session.instance_id,
20881 &agent("claude"),
20882 SessionMode::Pty,
20883 Some(&environment_profile),
20884 )
20885 .unwrap()
20886 .unwrap();
20887 let overlay = NativeInstanceLaunchOverlay::new(
20888 agent("claude"),
20889 TransportKind::Pty,
20890 vec![EnvMutation {
20891 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
20892 value: Some(OsString::from("fixture-secret")),
20893 }],
20894 vec![
20895 OsString::from("--plugin-dir"),
20896 std::env::current_dir().unwrap().into_os_string(),
20897 ],
20898 )
20899 .unwrap();
20900 let instance_overlay = shared
20901 .install_prepared_launch_overlay(
20902 address.session.instance_id,
20903 PreparedNativeLaunchOverlay::Instance(overlay),
20904 )
20905 .unwrap()
20906 .unwrap();
20907 let bundle = ResolvedBundleReceipt {
20908 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
20909 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
20910 digest: crate::protocol::SpawnBundleDigest::new(format!(
20911 "sha256:{}",
20912 "0".repeat(64),
20913 ))
20914 .unwrap(),
20915 };
20916 shared.bind_session_with_materialization(
20917 &address,
20918 ProviderRuntimePolicy::raw_pty(),
20919 Some(environment_profile),
20920 Some(bundle),
20921 None,
20922 None,
20923 );
20924 selection.retain();
20925 instance_overlay.retain();
20926
20927 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20928 assert!(matches!(
20929 runtime.remove_native_launch_profile(&native_id),
20930 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20931 ));
20932 assert!(shared.remove_binding(&address).is_some());
20933 assert!(!shared
20934 .native_launch_profile_control
20935 .as_ref()
20936 .unwrap()
20937 .clear_native_instance_launch_overlay(address.session.instance_id));
20938 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20939 }
20940
20941 #[test]
20942 fn session_environment_materialization_failure_is_pre_child_and_durable() {
20943 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20944 let address = SessionAddress {
20945 workspace_id: WorkspaceId::new("primary").unwrap(),
20946 session: SessionKey {
20947 instance_id: AgentInstanceId(201),
20948 generation: SessionGeneration(1),
20949 },
20950 };
20951 let error = match shared.prepare_session_materialization(
20952 &address,
20953 &agent("claude"),
20954 SessionMode::Pty,
20955 Some(&receipt),
20956 None,
20957 None,
20958 None,
20959 ) {
20960 Ok(_) => panic!("denied local reference unexpectedly materialized"),
20961 Err(error) => error,
20962 };
20963 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20964 assert!(shared.handle.snapshot().sessions.is_empty());
20965 assert!(shared
20966 .session_bindings
20967 .lock()
20968 .unwrap_or_else(|poisoned| poisoned.into_inner())
20969 .is_empty());
20970 assert!(shared
20971 .materializations
20972 .lock()
20973 .unwrap_or_else(|poisoned| poisoned.into_inner())
20974 .is_empty());
20975 drop(shared);
20976 drop(runtime);
20977 std::fs::remove_dir_all(root).unwrap();
20978 }
20979
20980 #[test]
20981 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
20982 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20983 let address = SessionAddress {
20984 workspace_id: WorkspaceId::new("primary").unwrap(),
20985 session: SessionKey {
20986 instance_id: AgentInstanceId(202),
20987 generation: SessionGeneration(1),
20988 },
20989 };
20990 let (overlay, materialization_guard) = shared
20991 .prepare_session_materialization(
20992 &address,
20993 &agent("claude"),
20994 SessionMode::Pty,
20995 Some(&receipt),
20996 None,
20997 None,
20998 None,
20999 )
21000 .unwrap()
21001 .unwrap();
21002 let materialization_id = materialization_guard.id().unwrap().clone();
21003 let materialization_root = shared
21004 .materializations
21005 .lock()
21006 .unwrap_or_else(|poisoned| poisoned.into_inner())
21007 .get(&materialization_id)
21008 .unwrap()
21009 .root()
21010 .to_path_buf();
21011 let selection = shared
21012 .select_environment_profile(
21013 address.session.instance_id,
21014 &agent("claude"),
21015 SessionMode::Pty,
21016 Some(&receipt),
21017 )
21018 .unwrap()
21019 .unwrap();
21020 assert!(shared
21021 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21022 .unwrap()
21023 .is_none());
21024 shared.bind_session_with_materialization(
21025 &address,
21026 ProviderRuntimePolicy::raw_pty(),
21027 Some(receipt),
21028 None,
21029 None,
21030 Some(materialization_id),
21031 );
21032 selection.retain();
21033 materialization_guard.retain();
21034
21035 assert!(materialization_root.is_dir());
21036 assert!(shared
21037 .session_bindings
21038 .lock()
21039 .unwrap_or_else(|poisoned| poisoned.into_inner())
21040 .contains_key(&address.session.instance_id));
21041
21042 let removed = shared.remove_binding(&address).unwrap();
21043 shared
21044 .cleanup_session_owned_materialization(&address, &removed)
21045 .unwrap();
21046 assert!(!materialization_root.exists());
21047 assert!(shared
21048 .materializations
21049 .lock()
21050 .unwrap_or_else(|poisoned| poisoned.into_inner())
21051 .is_empty());
21052 drop(shared);
21053 drop(runtime);
21054 std::fs::remove_dir_all(root).unwrap();
21055 }
21056
21057 #[tokio::test]
21058 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21059 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21060 let address = SessionAddress {
21061 workspace_id: WorkspaceId::new("primary").unwrap(),
21062 session: SessionKey {
21063 instance_id: AgentInstanceId(203),
21064 generation: SessionGeneration(1),
21065 },
21066 };
21067 let (overlay, materialization_guard) = shared
21068 .prepare_session_materialization(
21069 &address,
21070 &agent("claude"),
21071 SessionMode::Pty,
21072 Some(&receipt),
21073 None,
21074 None,
21075 None,
21076 )
21077 .unwrap()
21078 .unwrap();
21079 let materialization_id = materialization_guard.id().unwrap().clone();
21080 let materialization_root = shared
21081 .materializations
21082 .lock()
21083 .unwrap_or_else(|poisoned| poisoned.into_inner())
21084 .get(&materialization_id)
21085 .unwrap()
21086 .root()
21087 .to_path_buf();
21088 let selection = shared
21089 .select_environment_profile(
21090 address.session.instance_id,
21091 &agent("claude"),
21092 SessionMode::Pty,
21093 Some(&receipt),
21094 )
21095 .unwrap()
21096 .unwrap();
21097 assert!(shared
21098 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21099 .unwrap()
21100 .is_none());
21101 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21102 let record_id = shared
21103 .bind_spawn_session_with_materialization(
21104 &address,
21105 agent("claude"),
21106 SessionMode::Pty,
21107 policy,
21108 SpawnRecordPolicy::ProviderIdentityOnly,
21109 Some(receipt.clone()),
21110 None,
21111 None,
21112 Some(materialization_id.clone()),
21113 )
21114 .unwrap()
21115 .unwrap();
21116 selection.retain();
21117 materialization_guard.retain();
21118 assert_eq!(
21119 shared
21120 .materializations
21121 .lock()
21122 .unwrap_or_else(|poisoned| poisoned.into_inner())
21123 .get(&materialization_id)
21124 .unwrap()
21125 .owner(),
21126 &MaterializationOwner::Record {
21127 record_id: record_id.clone(),
21128 },
21129 );
21130
21131 let removed = shared.remove_binding(&address).unwrap();
21132 shared
21133 .cleanup_session_owned_materialization(&address, &removed)
21134 .unwrap();
21135 assert!(materialization_root.is_dir());
21136 assert!(shared
21137 .resolve_record_materialization(
21138 &record_id,
21139 &agent("claude"),
21140 SessionMode::Pty,
21141 Some(&receipt),
21142 None,
21143 None,
21144 )
21145 .unwrap()
21146 .is_some());
21147 {
21148 let mut records = shared
21149 .session_records
21150 .lock()
21151 .unwrap_or_else(|poisoned| poisoned.into_inner());
21152 let record = records.records.get_mut(&record_id).unwrap();
21153 record.active_session = None;
21154 record.state = ManagedSessionState::Unavailable;
21155 }
21156 shared.forget_session_record(&record_id).await.unwrap();
21157 assert!(!materialization_root.exists());
21158 assert!(shared
21159 .materializations
21160 .lock()
21161 .unwrap_or_else(|poisoned| poisoned.into_inner())
21162 .is_empty());
21163 drop(shared);
21164 drop(runtime);
21165 std::fs::remove_dir_all(root).unwrap();
21166 }
21167
21168 #[test]
21169 fn record_materialization_revalidation_failure_persists_recovery_required() {
21170 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21171 let state_path = root.join("node-state-v6.json");
21172 shared.state_path = Some(state_path.clone());
21173 let (_address, _record_id, materialization_id, materialization_root) =
21174 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21175 std::fs::write(
21176 materialization_root.join(".gate4agent-materialization-owner"),
21177 b"tampered",
21178 )
21179 .unwrap();
21180
21181 let error = match shared.resolve_record_materialization(
21182 &_record_id,
21183 &agent("claude"),
21184 SessionMode::Pty,
21185 Some(&receipt),
21186 None,
21187 None,
21188 ) {
21189 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21190 Err(error) => error,
21191 };
21192 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21193 assert_eq!(
21194 shared
21195 .materializations
21196 .lock()
21197 .unwrap_or_else(|poisoned| poisoned.into_inner())
21198 .get(&materialization_id)
21199 .unwrap()
21200 .state(),
21201 MaterializationState::RecoveryRequired,
21202 );
21203 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21204 assert_eq!(loaded.materializations.len(), 1);
21205 assert_eq!(
21206 loaded.materializations[0].state(),
21207 MaterializationState::RecoveryRequired,
21208 );
21209 drop(shared);
21210 drop(runtime);
21211 std::fs::remove_dir_all(root).unwrap();
21212 }
21213
21214 #[test]
21215 fn transient_record_materialization_resolution_failure_remains_ready() {
21216 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21217 let (_address, record_id, materialization_id, _materialization_root) =
21218 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21219 deny.store(true, Ordering::Release);
21220
21221 let error = match shared.resolve_record_materialization(
21222 &record_id,
21223 &agent("claude"),
21224 SessionMode::Pty,
21225 Some(&receipt),
21226 None,
21227 None,
21228 ) {
21229 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21230 Err(error) => error,
21231 };
21232 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21233 assert_eq!(
21234 shared
21235 .materializations
21236 .lock()
21237 .unwrap_or_else(|poisoned| poisoned.into_inner())
21238 .get(&materialization_id)
21239 .unwrap()
21240 .state(),
21241 MaterializationState::Ready,
21242 );
21243 drop(shared);
21244 drop(runtime);
21245 std::fs::remove_dir_all(root).unwrap();
21246 }
21247
21248 #[test]
21249 fn startup_missing_record_materialization_is_not_resumable() {
21250 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21251 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21252 shared
21253 .insert_record(ManagedSessionRecord {
21254 record_id: record_id.clone(),
21255 display_name: "missing materialization".to_owned(),
21256 provider: agent("claude"),
21257 mode: SessionMode::Pty,
21258 state: ManagedSessionState::Dormant,
21259 workspace_id: WorkspaceId::new("primary").unwrap(),
21260 canonical_root: opaque_windows_path(
21261 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21262 ),
21263 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21264 key: gate4agent_types::ProviderSessionKey::SessionId,
21265 id: "provider-session-missing-materialization".to_owned(),
21266 transcript_path: None,
21267 }),
21268 active_session: None,
21269 environment_profile: Some(receipt),
21270 bundle: None,
21271 context_id: None,
21272 context: None,
21273 exported_context: None,
21274 task_binding: None,
21275 created_at_unix_ms: 1,
21276 updated_at_unix_ms: 1,
21277 last_error: None,
21278 })
21279 .unwrap();
21280
21281 shared.reconcile_materializations();
21282 let record = shared.record(&record_id).unwrap();
21283 assert_eq!(record.state, ManagedSessionState::Unavailable);
21284 assert!(record.provider_session.is_none());
21285 assert_eq!(
21286 record.last_error.as_deref(),
21287 Some("environment-profile-unavailable"),
21288 );
21289 drop(shared);
21290 drop(runtime);
21291 std::fs::remove_dir_all(root).unwrap();
21292 }
21293
21294 #[tokio::test]
21295 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21296 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21297 let lease = install_managed_lease(&shared);
21298 let address = SessionAddress {
21299 workspace_id: WorkspaceId::new("primary").unwrap(),
21300 session: SessionKey {
21301 instance_id: AgentInstanceId(204),
21302 generation: SessionGeneration(1),
21303 },
21304 };
21305 let (overlay, materialization_guard) = shared
21306 .prepare_session_materialization(
21307 &address,
21308 &agent("claude"),
21309 SessionMode::Pty,
21310 Some(&receipt),
21311 None,
21312 None,
21313 Some(lease.lease_id.clone()),
21314 )
21315 .unwrap()
21316 .unwrap();
21317 let materialization_id = materialization_guard.id().unwrap().clone();
21318 let materialization_root = shared
21319 .materializations
21320 .lock()
21321 .unwrap_or_else(|poisoned| poisoned.into_inner())
21322 .get(&materialization_id)
21323 .unwrap()
21324 .root()
21325 .to_path_buf();
21326 let selection = shared
21327 .select_environment_profile(
21328 address.session.instance_id,
21329 &agent("claude"),
21330 SessionMode::Pty,
21331 Some(&receipt),
21332 )
21333 .unwrap()
21334 .unwrap();
21335 assert!(shared
21336 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21337 .unwrap()
21338 .is_none());
21339 shared.bind_session_with_materialization(
21340 &address,
21341 ProviderRuntimePolicy::raw_pty(),
21342 Some(receipt),
21343 None,
21344 None,
21345 Some(materialization_id),
21346 );
21347 selection.retain();
21348 materialization_guard.retain();
21349
21350 let blocked = shared
21351 .cleanup_managed_worktree(&lease.lease_id, false)
21352 .await
21353 .unwrap_err();
21354 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21355 assert!(materialization_root.is_dir());
21356 let removed = shared.remove_binding(&address).unwrap();
21357 shared
21358 .cleanup_session_owned_materialization(&address, &removed)
21359 .unwrap();
21360 assert!(!materialization_root.exists());
21361 drop(shared);
21362 drop(runtime);
21363 std::fs::remove_dir_all(root).unwrap();
21364 }
21365
21366 #[tokio::test]
21367 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21368 let git_root = temporary_workspace_root("raw-managed-inventory");
21369 let source = git_root.join("source");
21370 let allocation = git_root.join("allocation");
21371 std::fs::create_dir_all(&source).unwrap();
21372 std::fs::create_dir_all(&allocation).unwrap();
21373 run_test_git(&source, &["init"]);
21374 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21375 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21376 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21377 run_test_git(&source, &["add", "seed.txt"]);
21378 run_test_git(&source, &["commit", "-m", "seed"]);
21379 let profile = ManagedWorktreeProfile::new(
21380 WorktreeProfileId::new("default").unwrap(),
21381 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21382 &allocation,
21383 "gate4agent",
21384 "HEAD",
21385 ManagedWorktreeRetention::RemoveWhenReleased,
21386 )
21387 .unwrap();
21388 let workspace = WorkspaceConfig::new(
21389 WorkspaceId::new("primary").unwrap(),
21390 &source,
21391 )
21392 .unwrap()
21393 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21394 .with_managed_worktree_profile(profile.clone())
21395 .unwrap();
21396 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21397 materialization_test_shared_with_workspace(false, workspace);
21398 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21399 let source_text = source.to_string_lossy().into_owned();
21400 let target_text = target.to_string_lossy().into_owned();
21401 let created = create_git_worktree(
21402 &source_text,
21403 &target_text,
21404 "gate4agent/mw-raw-inventory",
21405 Some("HEAD"),
21406 )
21407 .await
21408 .unwrap();
21409 let lease = ManagedWorktreeLeaseRecord {
21410 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21411 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21412 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21413 profile_id: profile.profile_id().clone(),
21414 profile_revision: profile.revision().clone(),
21415 target_root: created.path.clone(),
21416 branch: "gate4agent/mw-raw-inventory".to_owned(),
21417 base_commit: created.head.clone(),
21418 expected_head: Some(created.head.clone()),
21419 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21420 state: ManagedWorktreeLeaseState::Ready,
21421 session_holders: Vec::new(),
21422 record_holders: Vec::new(),
21423 cleanup_failure: None,
21424 created_at_unix_ms: 1,
21425 updated_at_unix_ms: 1,
21426 };
21427 shared
21428 .workspaces
21429 .write()
21430 .unwrap_or_else(|poisoned| poisoned.into_inner())
21431 .insert(lease.workspace_id.clone(), created.path.clone());
21432 *shared
21433 .managed_worktrees
21434 .lock()
21435 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21436 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21437 let address = SessionAddress {
21438 workspace_id: lease.workspace_id.clone(),
21439 session: SessionKey {
21440 instance_id: AgentInstanceId(205),
21441 generation: SessionGeneration(1),
21442 },
21443 };
21444 let (overlay, materialization_guard) = shared
21445 .prepare_session_materialization(
21446 &address,
21447 &agent("claude"),
21448 SessionMode::Pty,
21449 Some(&receipt),
21450 None,
21451 None,
21452 Some(lease.lease_id.clone()),
21453 )
21454 .unwrap()
21455 .unwrap();
21456 let materialization_id = materialization_guard.id().unwrap().clone();
21457 let materialization_root = shared
21458 .materializations
21459 .lock()
21460 .unwrap_or_else(|poisoned| poisoned.into_inner())
21461 .get(&materialization_id)
21462 .unwrap()
21463 .root()
21464 .to_path_buf();
21465 let selection = shared
21466 .select_environment_profile(
21467 address.session.instance_id,
21468 &agent("claude"),
21469 SessionMode::Pty,
21470 Some(&receipt),
21471 )
21472 .unwrap()
21473 .unwrap();
21474 assert!(shared
21475 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21476 .unwrap()
21477 .is_none());
21478 let record_id = shared
21479 .bind_spawn_session_with_materialization(
21480 &address,
21481 agent("claude"),
21482 SessionMode::Pty,
21483 ProviderRuntimePolicy::raw_pty(),
21484 SpawnRecordPolicy::Always,
21485 Some(receipt.clone()),
21486 None,
21487 None,
21488 Some(materialization_id.clone()),
21489 )
21490 .unwrap()
21491 .unwrap();
21492 selection.retain();
21493 materialization_guard.retain();
21494 let record = shared.record(&record_id).unwrap();
21495 assert_eq!(record.state, ManagedSessionState::Live);
21496 assert!(record.provider_session.is_none());
21497 assert!(record.environment_profile.is_none());
21498 assert!(record.bundle.is_none());
21499 assert!(record.context_id.is_none());
21500 assert!(record.context.is_none());
21501 assert_eq!(record.active_session.as_ref(), Some(&address));
21502 assert_eq!(
21503 shared
21504 .materializations
21505 .lock()
21506 .unwrap_or_else(|poisoned| poisoned.into_inner())
21507 .get(&materialization_id)
21508 .unwrap()
21509 .owner(),
21510 &MaterializationOwner::Session {
21511 incarnation_id: shared.incarnation_id,
21512 instance_id: address.session.instance_id,
21513 generation: address.session.generation,
21514 },
21515 );
21516 {
21517 let mut bindings = shared
21518 .session_bindings
21519 .lock()
21520 .unwrap_or_else(|poisoned| poisoned.into_inner());
21521 bindings
21522 .get_mut(&address.session.instance_id)
21523 .unwrap()
21524 .managed_worktree_lease_id = Some(lease.lease_id.clone());
21525 }
21526 let lease_snapshot = shared
21527 .managed_worktrees
21528 .lock()
21529 .unwrap_or_else(|poisoned| poisoned.into_inner())
21530 .bind_session(
21531 &lease.lease_id,
21532 ManagedWorktreeSessionHolder {
21533 incarnation_id: shared.incarnation_id,
21534 instance_id: address.session.instance_id,
21535 generation: address.session.generation,
21536 },
21537 None,
21538 unix_time_ms(),
21539 )
21540 .unwrap();
21541 assert_eq!(lease_snapshot.active_session_count, 1);
21542 assert_eq!(lease_snapshot.managed_record_count, 0);
21543 shared.persist_state().unwrap();
21544 assert!(shared
21545 .managed_worktrees
21546 .lock()
21547 .unwrap_or_else(|poisoned| poisoned.into_inner())
21548 .get(&lease.lease_id)
21549 .unwrap()
21550 .record_holders
21551 .is_empty());
21552 shared
21553 .handle
21554 .dispatch(shared.prepare_command(ControlCommand::Register {
21555 instance_id: address.session.instance_id,
21556 agent_id: agent("claude"),
21557 transport: TransportKind::Pty,
21558 }))
21559 .unwrap();
21560 runtime.tick().await;
21561 shared.reconcile_managed_record(
21562 &record_id,
21563 &address,
21564 &ControlEvent {
21565 sequence: 1,
21566 command_id: None,
21567 instance_id: address.session.instance_id,
21568 generation: address.session.generation,
21569 event: ControlEventKind::Removed,
21570 },
21571 false,
21572 false,
21573 );
21574 let remove = shared.remove_session(&address);
21575 let drive_runtime = async {
21576 runtime.tick().await;
21577 };
21578 let (removed, ()) = tokio::join!(remove, drive_runtime);
21579 removed.unwrap();
21580
21581 assert!(!materialization_root.exists());
21582 assert!(!Path::new(&created.path).exists());
21583 assert_eq!(
21584 shared
21585 .managed_worktrees
21586 .lock()
21587 .unwrap_or_else(|poisoned| poisoned.into_inner())
21588 .get(&lease.lease_id)
21589 .unwrap()
21590 .state,
21591 ManagedWorktreeLeaseState::Removed,
21592 );
21593 let renamed = shared
21594 .rename_session_record(&record_id, "raw managed inventory".to_owned())
21595 .unwrap();
21596 assert_eq!(renamed.record_id, record_id);
21597 assert_eq!(renamed.display_name, "raw managed inventory");
21598 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21599 assert!(renamed.active_session.is_none());
21600
21601 drop(shared);
21602 drop(runtime);
21603 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21604 std::fs::remove_dir_all(git_root).unwrap();
21605 }
21606
21607 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21608 let root = std::env::current_dir().unwrap()
21609 .join("managed-test-target")
21610 .to_string_lossy()
21611 .into_owned();
21612 let lease = ManagedWorktreeLeaseRecord {
21613 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21614 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21615 workspace_id: WorkspaceId::new("managed-test").unwrap(),
21616 profile_id: WorktreeProfileId::new("default").unwrap(),
21617 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21618 target_root: root.clone(),
21619 branch: "gate4agent/mw-test".to_owned(),
21620 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21621 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21622 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21623 state: ManagedWorktreeLeaseState::Ready,
21624 session_holders: Vec::new(),
21625 record_holders: Vec::new(),
21626 cleanup_failure: None,
21627 created_at_unix_ms: 1,
21628 updated_at_unix_ms: 1,
21629 };
21630 shared.workspaces.write()
21631 .unwrap_or_else(|poisoned| poisoned.into_inner())
21632 .insert(lease.workspace_id.clone(), root);
21633 *shared.managed_worktrees.lock()
21634 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21635 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21636 lease
21637 }
21638
21639 #[cfg(feature = "fixture")]
21640 #[test]
21641 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21642 let mut shared = terminal_test_shared();
21643 shared.fixture_semantic_hook_policy = true;
21644 let lease = install_managed_lease(&shared);
21645 let address = SessionAddress {
21646 workspace_id: lease.workspace_id.clone(),
21647 session: SessionKey {
21648 instance_id: AgentInstanceId(206),
21649 generation: SessionGeneration(1),
21650 },
21651 };
21652 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21653 let effective_runtime_policy =
21654 shared.effective_spawn_runtime_policy(pre_admission_policy);
21655 assert_ne!(effective_runtime_policy, pre_admission_policy);
21656 assert!(effective_runtime_policy.provider_session_identity);
21657
21658 let record_id = shared
21659 .bind_spawn_session(
21660 &address,
21661 agent("claude"),
21662 SessionMode::Pty,
21663 effective_runtime_policy,
21664 SpawnRecordPolicy::Always,
21665 None,
21666 )
21667 .unwrap()
21668 .unwrap();
21669 assert_eq!(
21670 shared.record(&record_id).unwrap().state,
21671 ManagedSessionState::IdentityPending,
21672 );
21673 let (record_holder, raw_inventory_record_id) = {
21674 let mut bindings = shared
21675 .session_bindings
21676 .lock()
21677 .unwrap_or_else(|poisoned| poisoned.into_inner());
21678 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
21679 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
21680 managed_spawn_record_ownership(binding, effective_runtime_policy)
21681 };
21682 assert_eq!(record_holder.as_ref(), Some(&record_id));
21683 assert!(raw_inventory_record_id.is_none());
21684 let snapshot = shared
21685 .managed_worktrees
21686 .lock()
21687 .unwrap_or_else(|poisoned| poisoned.into_inner())
21688 .bind_session(
21689 &lease.lease_id,
21690 ManagedWorktreeSessionHolder {
21691 incarnation_id: shared.incarnation_id,
21692 instance_id: address.session.instance_id,
21693 generation: address.session.generation,
21694 },
21695 record_holder,
21696 unix_time_ms(),
21697 )
21698 .unwrap();
21699 assert_eq!(snapshot.managed_record_count, 1);
21700 assert_eq!(
21701 shared
21702 .managed_worktrees
21703 .lock()
21704 .unwrap_or_else(|poisoned| poisoned.into_inner())
21705 .get(&lease.lease_id)
21706 .unwrap()
21707 .record_holders,
21708 vec![record_id.clone()],
21709 );
21710
21711 let identity = ProviderSessionIdentity {
21712 key: ProviderSessionKey::SessionId,
21713 id: "monitoring-fixture-managed-session".to_owned(),
21714 transcript_path: None,
21715 };
21716 shared.publish_control(ControlEvent {
21717 sequence: 1,
21718 command_id: None,
21719 instance_id: address.session.instance_id,
21720 generation: address.session.generation,
21721 event: ControlEventKind::ProviderEvent {
21722 sequence: 1,
21723 source: gate4agent_types::ProviderSource {
21724 family: AdapterFamily::Hook,
21725 binding: AdapterBinding::new(
21726 gate4agent_types::AdapterId::new("claude-code").unwrap(),
21727 "fixture/v1",
21728 gate4agent_types::AdapterVerification::SyntheticFixture,
21729 )
21730 .unwrap(),
21731 },
21732 source_sequence: 1,
21733 event: ProviderEvent::SessionIdentityObserved {
21734 identity: identity.clone(),
21735 },
21736 },
21737 });
21738
21739 let live = shared.record(&record_id).unwrap();
21740 assert_eq!(live.state, ManagedSessionState::Live);
21741 assert_eq!(live.provider_session.as_ref(), Some(&identity));
21742 assert_eq!(live.active_session.as_ref(), Some(&address));
21743 assert_eq!(
21744 shared
21745 .managed_worktrees
21746 .lock()
21747 .unwrap_or_else(|poisoned| poisoned.into_inner())
21748 .get(&lease.lease_id)
21749 .unwrap()
21750 .record_holders,
21751 vec![record_id],
21752 );
21753 }
21754
21755 fn managed_v2_test_shared(
21756 allocation_root: &Path,
21757 state_path: Option<PathBuf>,
21758 profile_revision: &str,
21759 ) -> NodeShared {
21760 let profile = ManagedWorktreeProfile::new(
21761 WorktreeProfileId::new("default").unwrap(),
21762 WorktreeProfileRevision::new(profile_revision).unwrap(),
21763 allocation_root,
21764 "gate4agent",
21765 "HEAD",
21766 ManagedWorktreeRetention::RemoveWhenReleased,
21767 )
21768 .unwrap();
21769 let workspace = WorkspaceConfig::new(
21770 WorkspaceId::new("primary").unwrap(),
21771 std::env::current_dir().unwrap(),
21772 )
21773 .unwrap()
21774 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21775 .with_managed_worktree_profile(profile)
21776 .unwrap();
21777 let catalog = active_registry().unwrap();
21778 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21779 let mut shared = NodeShared::new(
21780 handle,
21781 "fixture-token".to_owned(),
21782 NodeId::new("node-terminal-test").unwrap(),
21783 vec![workspace],
21784 vec![agent("claude")],
21785 );
21786 shared.state_path = state_path;
21787 shared
21788 }
21789
21790 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
21791 let mut spawn_spec = spawn_spec_fixture();
21792 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
21793 ManagedWorktreeSpawnRequestV2 {
21794 spawn_spec,
21795 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21796 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
21797 }
21798 }
21799
21800 #[test]
21801 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
21802 let shared = terminal_test_shared();
21803 let mut lease = install_managed_lease(&shared);
21804 let session = SessionAddress {
21805 workspace_id: lease.workspace_id.clone(),
21806 session: SessionKey {
21807 instance_id: AgentInstanceId(73),
21808 generation: SessionGeneration(4),
21809 },
21810 };
21811 lease.state = ManagedWorktreeLeaseState::InUse;
21812 lease.session_holders.push(ManagedWorktreeSessionHolder {
21813 incarnation_id: shared.incarnation_id,
21814 instance_id: session.session.instance_id,
21815 generation: session.session.generation,
21816 });
21817 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21818 assert!(resolved.target.worktree_id.is_none());
21819
21820 let receipt = managed_spawn_receipt(
21821 &resolved,
21822 shared.incarnation_id,
21823 session.clone(),
21824 lease.snapshot(),
21825 None,
21826 None,
21827 None,
21828 );
21829 assert!(resolved.target.worktree_id.is_none());
21830 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
21831 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
21832 assert_eq!(receipt.spawn.session, session);
21833 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
21834 let encoded = serde_json::to_vec(&receipt).unwrap();
21835 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
21836 assert_eq!(decoded, receipt);
21837 }
21838
21839 #[tokio::test]
21840 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
21841 let root = temporary_workspace_root("managed-v2-revision");
21842 let allocation = root.join("allocation");
21843 std::fs::create_dir_all(&allocation).unwrap();
21844 let shared = managed_v2_test_shared(
21845 &allocation,
21846 Some(root.join("node-state.json")),
21847 "v1",
21848 );
21849 let mut request = managed_v2_request("managed-v2-revision");
21850 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
21851
21852 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
21853
21854 assert_eq!(
21855 failure.code,
21856 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21857 );
21858 assert!(shared
21859 .managed_worktrees
21860 .lock()
21861 .unwrap_or_else(|poisoned| poisoned.into_inner())
21862 .records()
21863 .is_empty());
21864 assert!(shared
21865 .managed_spawn_replays
21866 .lock()
21867 .unwrap_or_else(|poisoned| poisoned.into_inner())
21868 .is_empty());
21869 std::fs::remove_dir_all(root).unwrap();
21870 }
21871
21872 #[test]
21873 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
21874 let root = temporary_workspace_root("managed-v2-concurrent");
21875 let allocation = root.join("allocation");
21876 std::fs::create_dir_all(&allocation).unwrap();
21877 let shared = Arc::new(managed_v2_test_shared(
21878 &allocation,
21879 Some(root.join("node-state.json")),
21880 "v1",
21881 ));
21882 let request = managed_v2_request("managed-v2-concurrent");
21883 let barrier = Arc::new(std::sync::Barrier::new(3));
21884 let mut joins = Vec::new();
21885 for _ in 0..2 {
21886 let shared = Arc::clone(&shared);
21887 let request = request.clone();
21888 let barrier = Arc::clone(&barrier);
21889 joins.push(std::thread::spawn(move || {
21890 let digest = managed_spawn_request_digest_v2(&request).unwrap();
21891 barrier.wait();
21892 shared.acquire_durable_managed_spawn_v2(&request, digest)
21893 }));
21894 }
21895 barrier.wait();
21896 let results = joins
21897 .into_iter()
21898 .map(|join| join.join().unwrap())
21899 .collect::<Vec<_>>();
21900 assert_eq!(
21901 results
21902 .iter()
21903 .filter(|result| {
21904 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
21905 })
21906 .count(),
21907 1,
21908 );
21909 assert_eq!(
21910 results
21911 .iter()
21912 .filter(|result| {
21913 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
21914 })
21915 .count(),
21916 1,
21917 );
21918 assert_eq!(
21919 shared
21920 .managed_spawn_replays
21921 .lock()
21922 .unwrap_or_else(|poisoned| poisoned.into_inner())
21923 .len(),
21924 1,
21925 );
21926 assert!(shared
21927 .managed_worktrees
21928 .lock()
21929 .unwrap_or_else(|poisoned| poisoned.into_inner())
21930 .records()
21931 .is_empty());
21932 drop(shared);
21933 std::fs::remove_dir_all(root).unwrap();
21934 }
21935
21936 #[tokio::test]
21937 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
21938 let root = temporary_workspace_root("managed-v2-linked-pending");
21939 let allocation = root.join("allocation");
21940 std::fs::create_dir_all(&allocation).unwrap();
21941 let state_path = root.join("node-state.json");
21942 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21943 let lease = install_managed_lease(&shared);
21944 let request = managed_v2_request("managed-v2-linked-pending");
21945 assert!(matches!(
21946 shared
21947 .acquire_durable_managed_spawn_v2(
21948 &request,
21949 managed_spawn_request_digest_v2(&request).unwrap(),
21950 )
21951 .unwrap(),
21952 DurableManagedSpawnReplayDecision::Reserved,
21953 ));
21954 {
21955 let _transaction = shared
21956 .state_transaction
21957 .lock()
21958 .unwrap_or_else(|poisoned| poisoned.into_inner());
21959 shared
21960 .link_durable_managed_spawn_v2_lease_locked(
21961 &request.spawn_spec.idempotency_key,
21962 &lease.lease_id,
21963 )
21964 .unwrap();
21965 shared.persist_state_locked().unwrap();
21966 }
21967 drop(shared);
21968
21969 let mut loaded = session_registry::load(
21970 Some(&state_path),
21971 &NodeId::new("node-terminal-test").unwrap(),
21972 )
21973 .unwrap();
21974 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21975 *reopened
21976 .managed_worktrees
21977 .lock()
21978 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21979 ManagedWorktreeRegistry::from_records(
21980 std::mem::take(&mut loaded.managed_worktrees),
21981 std::mem::take(&mut loaded.managed_worktree_tombstones),
21982 )
21983 .unwrap();
21984 *reopened
21985 .managed_spawn_replays
21986 .lock()
21987 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21988 .managed_spawn_replays
21989 .into_iter()
21990 .map(|record| (record.idempotency_key.clone(), record))
21991 .collect();
21992 let lease_count = reopened
21993 .managed_worktrees
21994 .lock()
21995 .unwrap_or_else(|poisoned| poisoned.into_inner())
21996 .records()
21997 .len();
21998
21999 let failure = reopened
22000 .spawn_managed_worktree_v2(request)
22001 .await
22002 .unwrap_err();
22003
22004 assert_eq!(
22005 failure.code,
22006 NodeFailureCode::ManagedWorktreeRecoveryRequired,
22007 );
22008 assert_eq!(
22009 reopened
22010 .managed_worktrees
22011 .lock()
22012 .unwrap_or_else(|poisoned| poisoned.into_inner())
22013 .records()
22014 .len(),
22015 lease_count,
22016 );
22017 std::fs::remove_dir_all(root).unwrap();
22018 }
22019
22020 #[tokio::test]
22021 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22022 let root = temporary_workspace_root("managed-v2-reopen");
22023 let allocation = root.join("allocation");
22024 std::fs::create_dir_all(&allocation).unwrap();
22025 let state_path = root.join("node-state.json");
22026 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22027 let lease = install_managed_lease(&shared);
22028 let request = managed_v2_request("managed-v2-reopen");
22029 let session = SessionAddress {
22030 workspace_id: lease.workspace_id.clone(),
22031 session: SessionKey {
22032 instance_id: AgentInstanceId(91),
22033 generation: SessionGeneration(1),
22034 },
22035 };
22036 let snapshot = {
22037 let mut registry = shared
22038 .managed_worktrees
22039 .lock()
22040 .unwrap_or_else(|poisoned| poisoned.into_inner());
22041 let current = registry.get_mut(&lease.lease_id).unwrap();
22042 current.state = ManagedWorktreeLeaseState::InUse;
22043 current.session_holders.push(ManagedWorktreeSessionHolder {
22044 incarnation_id: shared.incarnation_id,
22045 instance_id: session.session.instance_id,
22046 generation: session.session.generation,
22047 });
22048 current.snapshot()
22049 };
22050 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22051 let receipt = managed_spawn_receipt(
22052 &resolved,
22053 shared.incarnation_id,
22054 session,
22055 snapshot,
22056 None,
22057 None,
22058 None,
22059 );
22060 assert!(matches!(
22061 shared
22062 .acquire_durable_managed_spawn_v2(
22063 &request,
22064 managed_spawn_request_digest_v2(&request).unwrap(),
22065 )
22066 .unwrap(),
22067 DurableManagedSpawnReplayDecision::Reserved,
22068 ));
22069 {
22070 let _transaction = shared
22071 .state_transaction
22072 .lock()
22073 .unwrap_or_else(|poisoned| poisoned.into_inner());
22074 shared
22075 .link_durable_managed_spawn_v2_lease_locked(
22076 &request.spawn_spec.idempotency_key,
22077 &lease.lease_id,
22078 )
22079 .unwrap();
22080 shared.persist_state_locked().unwrap();
22081 }
22082 shared
22083 .commit_durable_managed_spawn_v2(&request, receipt.clone())
22084 .unwrap();
22085 let legacy_same_key = ManagedWorktreeSpawnRequest {
22086 spawn_spec: request.spawn_spec.clone(),
22087 worktree_profile_id: request.worktree_profile_id.clone(),
22088 };
22089 shared.remember_managed_spawn_attempt(
22090 Some(&request.spawn_spec.idempotency_key),
22091 legacy_same_key.clone(),
22092 Ok(receipt.clone()),
22093 Instant::now(),
22094 );
22095 assert!(shared
22096 .replay_managed_spawn(&legacy_same_key, Instant::now())
22097 .unwrap()
22098 .is_none());
22099 drop(shared);
22100
22101 let mut loaded = session_registry::load(
22102 Some(&state_path),
22103 &NodeId::new("node-terminal-test").unwrap(),
22104 )
22105 .unwrap();
22106 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22107 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22108 *reopened
22109 .managed_worktrees
22110 .lock()
22111 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22112 ManagedWorktreeRegistry::from_records(
22113 std::mem::take(&mut loaded.managed_worktrees),
22114 std::mem::take(&mut loaded.managed_worktree_tombstones),
22115 )
22116 .unwrap();
22117 *reopened
22118 .managed_spawn_replays
22119 .lock()
22120 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22121 .managed_spawn_replays
22122 .into_iter()
22123 .map(|record| (record.idempotency_key.clone(), record))
22124 .collect();
22125 let lease_count_before = reopened
22126 .managed_worktrees
22127 .lock()
22128 .unwrap_or_else(|poisoned| poisoned.into_inner())
22129 .records()
22130 .len();
22131
22132 assert_eq!(
22133 reopened
22134 .spawn_managed_worktree_v2(request.clone())
22135 .await
22136 .unwrap(),
22137 receipt,
22138 );
22139 assert_eq!(
22140 reopened
22141 .managed_worktrees
22142 .lock()
22143 .unwrap_or_else(|poisoned| poisoned.into_inner())
22144 .records()
22145 .len(),
22146 lease_count_before,
22147 );
22148 let mut changed = request;
22149 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22150 let failure = reopened
22151 .spawn_managed_worktree_v2(changed)
22152 .await
22153 .unwrap_err();
22154 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22155 assert_eq!(
22156 reopened
22157 .managed_worktrees
22158 .lock()
22159 .unwrap_or_else(|poisoned| poisoned.into_inner())
22160 .records()
22161 .len(),
22162 lease_count_before,
22163 );
22164 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22165 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22166 let failure = reopened
22167 .spawn_managed_worktree_v2(new_key)
22168 .await
22169 .unwrap_err();
22170 assert_eq!(
22171 failure.code,
22172 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22173 );
22174 assert_eq!(
22175 reopened
22176 .managed_worktrees
22177 .lock()
22178 .unwrap_or_else(|poisoned| poisoned.into_inner())
22179 .records()
22180 .len(),
22181 lease_count_before,
22182 );
22183 assert_eq!(
22184 reopened
22185 .managed_spawn_replays
22186 .lock()
22187 .unwrap_or_else(|poisoned| poisoned.into_inner())
22188 .len(),
22189 1,
22190 );
22191 std::fs::remove_dir_all(root).unwrap();
22192 }
22193
22194 #[test]
22195 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22196 let shared = terminal_test_shared();
22197 let request = ManagedWorktreeSpawnRequest {
22198 spawn_spec: spawn_spec_fixture(),
22199 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22200 };
22201 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22202 shared.remember_managed_spawn(
22203 request.clone(),
22204 Err(expected.clone()),
22205 Instant::now(),
22206 );
22207
22208 assert_eq!(
22209 shared
22210 .replay_managed_spawn(&request, Instant::now())
22211 .unwrap()
22212 .unwrap(),
22213 Err(expected),
22214 );
22215 assert!(shared
22216 .managed_spawn_replays
22217 .lock()
22218 .unwrap_or_else(|poisoned| poisoned.into_inner())
22219 .is_empty());
22220 }
22221
22222 #[tokio::test]
22223 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22224 let shared = terminal_test_shared();
22225 let lease = install_managed_lease(&shared);
22226 let raw = shared.spawn_session(
22227 lease.workspace_id.clone(),
22228 agent("claude"),
22229 SessionMode::Pty,
22230 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22231 None,
22232 ).await.unwrap_err();
22233 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22234 let mut spec = spawn_spec_fixture();
22235 spec.target.workspace_id = lease.workspace_id.clone();
22236 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22237 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22238 let mut selected = spawn_spec_fixture();
22239 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22240 selected.target.worktree_id = Some(lease.workspace_id);
22241 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22242 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22243 assert!(shared.handle.snapshot().sessions.is_empty());
22244 }
22245
22246 #[test]
22247 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22248 let shared = terminal_test_shared();
22249 let lease = install_managed_lease(&shared);
22250 let snapshot = snapshot_for_wire(
22251 &shared, true, true, true, false, true, true, false, false, true,
22252 );
22253 assert!(snapshot.managed_worktrees.is_empty());
22254 assert!(snapshot.workspaces.iter().all(|workspace| {
22255 workspace.managed_worktree_profiles.is_none()
22256 }));
22257 let mut reply = ResponseEnvelope {
22258 request_id: 1,
22259 result: Ok(NodeResponse::Resync {
22260 event_sequence: 1,
22261 oldest_available_sequence: 1,
22262 snapshot: shared.snapshot(),
22263 events: vec![NodeEventEnvelope {
22264 sequence: 1,
22265 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22266 }],
22267 }),
22268 };
22269 project_response_without_managed_worktrees(&mut reply);
22270 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22271 panic!("projection changed the response kind");
22272 };
22273 assert!(snapshot.managed_worktrees.is_empty());
22274 assert!(events.is_empty());
22275 let mut git = git_snapshot_for_parser();
22276 git.branch = Some(lease.branch.clone());
22277 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22278 lease_id: lease.lease_id.clone(),
22279 source_workspace_id: lease.source_workspace_id.clone(),
22280 branch: lease.branch.clone(),
22281 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22282 active_session_count: 1,
22283 managed_record_count: 0,
22284 });
22285 let mut inspection_reply = ResponseEnvelope {
22286 request_id: 2,
22287 result: Ok(NodeResponse::WorkspaceInspected {
22288 inspection: WorkspaceInspection {
22289 workspace_id: lease.workspace_id.clone(),
22290 entries: Vec::new(),
22291 tree_truncated: false,
22292 git,
22293 truncation: None,
22294 },
22295 }),
22296 };
22297 project_response_without_managed_worktrees(&mut inspection_reply);
22298 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22299 panic!("projection changed the inspection response kind");
22300 };
22301 assert!(inspection.git.managed_worktree.is_none());
22302 let failure = managed_git_worktree_failure(GitWorktreeError {
22303 kind: GitWorktreeErrorKind::Failed,
22304 message: r"git failed at C:\private\managed-target".to_owned(),
22305 });
22306 assert!(!failure.message.contains("private"));
22307 assert!(!failure.message.contains("managed-target"));
22308 }
22309
22310 #[tokio::test]
22311 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22312 let root = temporary_workspace_root("managed-restart");
22313 let source = root.join("source");
22314 let allocation = root.join("allocation");
22315 std::fs::create_dir_all(&source).unwrap();
22316 std::fs::create_dir_all(&allocation).unwrap();
22317 run_test_git(&source, &["init"]);
22318 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22319 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22320 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22321 run_test_git(&source, &["add", "seed.txt"]);
22322 run_test_git(&source, &["commit", "-m", "seed"]);
22323 let profile = ManagedWorktreeProfile::new(
22324 WorktreeProfileId::new("default").unwrap(),
22325 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22326 &allocation,
22327 "gate4agent",
22328 "HEAD",
22329 ManagedWorktreeRetention::Retain,
22330 ).unwrap();
22331 let workspace = WorkspaceConfig::new(
22332 WorkspaceId::new("primary").unwrap(),
22333 &source,
22334 ).unwrap()
22335 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22336 .with_managed_worktree_profile(profile.clone()).unwrap();
22337 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22338 let source_text = workspace.canonical_root().to_owned();
22339 let target_text = target.to_string_lossy().into_owned();
22340 let created = create_git_worktree(
22341 &source_text,
22342 &target_text,
22343 "gate4agent/mw-restart",
22344 Some("HEAD"),
22345 ).await.unwrap();
22346 let lease = ManagedWorktreeLeaseRecord {
22347 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22348 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22349 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22350 profile_id: profile.profile_id().clone(),
22351 profile_revision: profile.revision().clone(),
22352 target_root: created.path.clone(),
22353 branch: "gate4agent/mw-restart".to_owned(),
22354 base_commit: created.head.clone(),
22355 expected_head: None,
22356 retention: ManagedWorktreeRetention::Retain,
22357 state: ManagedWorktreeLeaseState::Allocating,
22358 session_holders: Vec::new(),
22359 record_holders: Vec::new(),
22360 cleanup_failure: None,
22361 created_at_unix_ms: 1,
22362 updated_at_unix_ms: 1,
22363 };
22364 let catalog = active_registry().unwrap();
22365 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22366 let shared = NodeShared::new_with_incarnation(
22367 handle,
22368 "fixture-token".to_owned(),
22369 NodeId::new("node-managed-restart").unwrap(),
22370 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22371 vec![workspace],
22372 vec![agent("claude")],
22373 ProviderRuntimeStatuses::default(),
22374 None,
22375 Vec::new(),
22376 Vec::new(),
22377 SpawnProfileRegistry::default(),
22378 None,
22379 None,
22380 None,
22381 None,
22382 Vec::new(),
22383 vec![lease.clone()],
22384 Vec::new(),
22385 Vec::new(),
22386 None,
22387 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22388 );
22389 shared.reconcile_managed_worktrees().await;
22390 let retained = shared.managed_worktrees.lock()
22391 .unwrap_or_else(|poisoned| poisoned.into_inner())
22392 .get(&lease.lease_id).unwrap().clone();
22393 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22394 assert!(shared.workspaces.read()
22395 .unwrap_or_else(|poisoned| poisoned.into_inner())
22396 .contains_key(&lease.workspace_id));
22397 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22398 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22399 assert!(!Path::new(&created.path).exists());
22400 std::fs::remove_dir_all(root).unwrap();
22401 }
22402
22403 fn run_test_git(root: &Path, arguments: &[&str]) {
22404 let output = std::process::Command::new("git")
22405 .arg("-C").arg(root).args(arguments).output().unwrap();
22406 assert!(
22407 output.status.success(),
22408 "git {:?} failed: {}",
22409 arguments,
22410 String::from_utf8_lossy(&output.stderr),
22411 );
22412 }
22413
22414 #[tokio::test]
22415 async fn spawn_spec_validation_fails_before_session_mutation() {
22416 let shared = terminal_test_shared();
22417 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22418 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22419 }));
22420 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22421 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22422 }));
22423 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22424 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22425 }));
22426
22427 let mut target_mismatch = spawn_spec_fixture();
22428 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22429 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22430 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22431
22432 let mut unknown_profile = spawn_spec_fixture();
22433 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22434 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22435 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22436 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22437
22438 let mut revision_mismatch = spawn_spec_fixture();
22439 revision_mismatch.idempotency_key =
22440 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22441 revision_mismatch.expected_profile_revision =
22442 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22443 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22444 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22445
22446 let mut unsupported_materializer = spawn_spec_fixture();
22447 unsupported_materializer.idempotency_key =
22448 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22449 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22450 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22451 };
22452 let failure = shared
22453 .spawn_from_spec(unsupported_materializer)
22454 .await
22455 .unwrap_err();
22456 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22457
22458 let failure = shared
22459 .spawn_session_with_deadline(
22460 WorkspaceId::new("primary").unwrap(),
22461 agent("claude"),
22462 SessionMode::Pty,
22463 gate4agent_types::TerminalSize {
22464 rows: 24,
22465 columns: 80,
22466 },
22467 None,
22468 None,
22469 None,
22470 None,
22471 None,
22472 None,
22473 SpawnRecordPolicy::ProviderIdentityOnly,
22474 Some(Instant::now()),
22475 &[],
22476 None,
22477 ApprovalLevel::default(),
22478 None,
22479 None,
22480 )
22481 .await
22482 .unwrap_err();
22483 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22484 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22485 assert!(shared.handle.snapshot().sessions.is_empty());
22486 }
22487
22488 #[test]
22489 fn worktree_selection_requires_its_separate_negotiated_capability() {
22490 let mut spec = spawn_spec_fixture();
22491 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22492 let request = NodeRequest::SpawnSpec { spec };
22493 let spawn_spec_capability =
22494 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22495 assert!(request_uses_unnegotiated_capability(
22496 &request,
22497 &[spawn_spec_capability.clone()],
22498 ));
22499 let worktree_capability =
22500 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22501 assert!(request_uses_unnegotiated_capability(
22502 &request,
22503 &[spawn_spec_capability.clone(), worktree_capability.clone()],
22504 ));
22505 let profile_revision_capability =
22506 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22507 assert!(request_uses_unnegotiated_capability(
22508 &request,
22509 &[
22510 spawn_spec_capability.clone(),
22511 worktree_capability.clone(),
22512 profile_revision_capability.clone(),
22513 ],
22514 ));
22515 let bundle_materialization_capability =
22522 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22523 assert!(request_uses_unnegotiated_capability(
22524 &request,
22525 &[
22526 spawn_spec_capability.clone(),
22527 worktree_capability.clone(),
22528 profile_revision_capability.clone(),
22529 bundle_materialization_capability.clone(),
22530 ],
22531 ));
22532 let history_context_pack_capability =
22533 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22534 assert!(!request_uses_unnegotiated_capability(
22535 &request,
22536 &[
22537 spawn_spec_capability,
22538 worktree_capability,
22539 profile_revision_capability,
22540 bundle_materialization_capability,
22541 history_context_pack_capability,
22542 ],
22543 ));
22544 }
22545
22546 #[tokio::test]
22547 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22548 let mut shared = terminal_test_shared();
22549 let spec = spawn_spec_fixture();
22550 let receipt = shared
22551 .resolve_spawn_spec(&spec)
22552 .unwrap()
22553 .receipt(shared.incarnation_id, terminal_address(1));
22554 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22555
22556 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22557 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22558 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22559
22560 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22561
22562 let mut revision_conflicting = spec.clone();
22563 revision_conflicting.expected_profile_revision =
22564 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22565 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22566 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22567
22568 let mut stale_cache_miss = spec.clone();
22569 stale_cache_miss.idempotency_key =
22570 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22571 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22572 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22573
22574 let mut conflicting = spec;
22575 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22576 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22577 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22578 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22579 assert!(shared.handle.snapshot().sessions.is_empty());
22580 }
22581
22582 #[tokio::test]
22583 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22584 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22585 let mut spec = spawn_spec_fixture();
22586 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22587 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22588 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22589
22590 let failure = shared
22591 .create_worktree(
22592 WorkspaceId::new("primary").unwrap(),
22593 WorkspaceId::new("new-worktree").unwrap(),
22594 "not-an-absolute-path".to_owned(),
22595 "invalid branch".to_owned(),
22596 None,
22597 )
22598 .await
22599 .unwrap_err();
22600 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22601
22602 let failure = shared
22603 .remove_worktree(
22604 WorkspaceId::new("primary").unwrap(),
22605 "not-an-absolute-path".to_owned(),
22606 )
22607 .await
22608 .unwrap_err();
22609 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22610 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22611 assert!(shared.handle.snapshot().sessions.is_empty());
22612 }
22613
22614 #[tokio::test]
22615 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22616 let root = temporary_workspace_root("manual-worktree-compensation");
22617 let source = root.join("source");
22618 let target = root.join("target");
22619 let invalid_state_parent = root.join("state-parent-is-a-file");
22620 let invalid_state_path = invalid_state_parent.join("state.json");
22621 std::fs::create_dir_all(&source).unwrap();
22622 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22623 run_test_git(&source, &["init"]);
22624 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22625 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22626 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22627 run_test_git(&source, &["add", "seed.txt"]);
22628 run_test_git(&source, &["commit", "-m", "seed"]);
22629
22630 let catalog = active_registry().unwrap();
22631 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22632 let workspace = WorkspaceConfig::new(
22633 WorkspaceId::new("primary").unwrap(),
22634 &source,
22635 ).unwrap();
22636 let mut shared = NodeShared::new(
22637 handle,
22638 "fixture-token".to_owned(),
22639 NodeId::new("node-worktree-compensation").unwrap(),
22640 vec![workspace],
22641 vec![agent("claude")],
22642 );
22643 shared.state_path = Some(invalid_state_path);
22644
22645 let error = shared.create_worktree(
22646 WorkspaceId::new("primary").unwrap(),
22647 WorkspaceId::new("compensated").unwrap(),
22648 target.to_string_lossy().into_owned(),
22649 "gate4agent/compensated".to_owned(),
22650 Some("HEAD".to_owned()),
22651 ).await.unwrap_err();
22652
22653 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22654 assert!(!target.exists());
22655 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22656 .iter()
22657 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22658 std::fs::remove_dir_all(root).unwrap();
22659 }
22660
22661 #[test]
22662 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22663 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22664 NativeGitWorktreeSnapshot {
22665 path: path.to_owned(),
22666 head: "0123456789abcdef".to_owned(),
22667 branch: Some("refs/heads/test".to_owned()),
22668 is_bare,
22669 is_main,
22670 locked: false,
22671 lock_reason: None,
22672 prunable: false,
22673 prunable_reason: None,
22674 workspace_id: None,
22675 }
22676 }
22677
22678 let source = r"C:\repo";
22679 let selected = r"C:\repo-worktree";
22680 let listed = vec![
22681 worktree(source, true, false),
22682 worktree(selected, false, false),
22683 ];
22684 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
22685
22686 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
22687 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
22688
22689 let bare = vec![
22690 worktree(source, true, false),
22691 worktree(selected, false, true),
22692 ];
22693 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
22694 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
22695
22696 let foreign_failure =
22697 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
22698 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
22699 }
22700
22701 fn terminal_frame(sequence: u64) -> TerminalFrame {
22702 TerminalFrame {
22703 sequence,
22704 size: gate4agent_types::TerminalSize {
22705 rows: 24,
22706 columns: 80,
22707 },
22708 cursor_row: 2,
22709 cursor_column: 3,
22710 contents: format!("frame-{sequence}"),
22711 formatted: vec![1, 2, 3],
22712 scrollback_formatted: Vec::new(),
22713 alternate_screen: false,
22714 mouse_protocol_enabled: false,
22715 mouse_protocol_encoding: Default::default(),
22716 produced_at_unix_ms: 0,
22717 screen_state: gate4agent_types::PtyScreenState::default(),
22718 bracketed_paste: None,
22719 }
22720 }
22721
22722 #[test]
22723 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
22724 let shared = terminal_test_shared();
22725 let mut receiver = shared.terminal_event_tx.subscribe();
22726 let original = terminal_address(1);
22727
22728 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22729 let first = receiver.try_recv().unwrap();
22730 assert_eq!(first.len(), 1);
22731 assert!(matches!(
22732 &first[0].event,
22733 NodeEvent::TerminalFrame { address, frame }
22734 if address == &original && frame.sequence == 7
22735 ));
22736
22737 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22738 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
22739 assert!(matches!(
22740 receiver.try_recv(),
22741 Err(broadcast::error::TryRecvError::Empty)
22742 ));
22743
22744 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
22745 let advanced = receiver.try_recv().unwrap();
22746 assert_eq!(advanced[0].sequence, 2);
22747
22748 let resumed = terminal_address(2);
22749 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
22750 let reset = receiver.try_recv().unwrap();
22751 assert_eq!(reset[0].sequence, 3);
22752 assert!(matches!(
22753 &reset[0].event,
22754 NodeEvent::TerminalFrame { address, frame }
22755 if address == &resumed && frame.sequence == 1
22756 ));
22757 shared.clear_terminal_frame_watermark(&resumed);
22758 assert!(!shared
22759 .terminal_frame_watermarks
22760 .lock()
22761 .unwrap_or_else(|poisoned| poisoned.into_inner())
22762 .contains_key(&resumed.session.instance_id));
22763 }
22764
22765 #[test]
22766 fn terminal_frame_batches_replace_without_entering_node_event_history() {
22767 let shared = terminal_test_shared();
22768 let mut receiver = shared.terminal_event_tx.subscribe();
22769 let address = terminal_address(1);
22770 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
22771
22772 for sequence in 1..=count {
22773 shared.publish_terminal_frame_candidates(vec![(
22774 address.clone(),
22775 terminal_frame(sequence),
22776 )]);
22777 }
22778
22779 let history = shared
22780 .history
22781 .lock()
22782 .unwrap_or_else(|poisoned| poisoned.into_inner());
22783 assert!(history.events.is_empty());
22784 assert_eq!(history.last_sequence, count);
22785 assert_eq!(history.replay_floor_sequence, 1);
22786 drop(history);
22787 let NodeResponse::Resync {
22788 event_sequence,
22789 oldest_available_sequence,
22790 events,
22791 ..
22792 } = shared.resync(0) else {
22793 unreachable!("resync helper returned another response")
22794 };
22795 assert_eq!(event_sequence, count);
22796 assert_eq!(oldest_available_sequence, 1);
22797 assert!(events.is_empty());
22798 assert!(matches!(
22799 resync_required_event(&shared).event,
22800 NodeEvent::ResyncRequired {
22801 oldest_available_sequence: 1,
22802 }
22803 ));
22804 assert!(matches!(
22805 receiver.try_recv(),
22806 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
22807 ));
22808 let latest = receiver.try_recv().unwrap();
22809 assert_eq!(latest.len(), 1);
22810 assert_eq!(latest[0].sequence, count);
22811 }
22812
22813 #[test]
22814 fn real_durable_eviction_advances_authoritative_replay_floor() {
22815 let shared = terminal_test_shared();
22816 for _ in 0..=NODE_EVENT_HISTORY_MAX {
22817 shared.publish(NodeEvent::ControllerChanged { controller: None });
22818 }
22819
22820 let NodeResponse::Resync {
22821 event_sequence,
22822 oldest_available_sequence,
22823 events,
22824 ..
22825 } = shared.resync(0) else {
22826 unreachable!("resync helper returned another response")
22827 };
22828 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
22829 assert_eq!(oldest_available_sequence, 2);
22830 assert_eq!(events.first().map(|event| event.sequence), Some(2));
22831 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
22832 assert!(matches!(
22833 resync_required_event(&shared).event,
22834 NodeEvent::ResyncRequired {
22835 oldest_available_sequence: 2,
22836 }
22837 ));
22838 }
22839
22840 #[test]
22841 fn legacy_terminal_frame_connection_has_no_live_subscription() {
22842 let shared = terminal_test_shared();
22843 let without_capability = terminal_event_subscription(&shared, false);
22844 let with_capability = terminal_event_subscription(&shared, true);
22845 assert!(without_capability.is_none());
22846 assert!(with_capability.is_some());
22847 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22848 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
22849 }));
22850 }
22851
22852 #[test]
22853 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
22854 let mut pending = Vec::new();
22855 let mut event_sequence = 0_u64;
22856 for frame_sequence in 1..=4 {
22857 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
22858 event_sequence += 1;
22859 let mut address = terminal_address(1);
22860 address.session.instance_id = AgentInstanceId(instance);
22861 queue_connection_event(
22862 &mut pending,
22863 NodeEventEnvelope {
22864 sequence: event_sequence,
22865 event: NodeEvent::TerminalFrame {
22866 address,
22867 frame: terminal_frame(frame_sequence),
22868 },
22869 },
22870 0,
22871 );
22872 }
22873 }
22874 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
22875 pending.sort_unstable_by_key(|event| event.sequence);
22876
22877 let mut uninterrupted = pending.clone();
22878 let first_burst = connection_event_burst_len(uninterrupted.len());
22879 uninterrupted.drain(..first_burst);
22880 let second_burst = connection_event_burst_len(uninterrupted.len());
22881 uninterrupted.drain(..second_burst);
22882 assert!(uninterrupted.is_empty());
22883
22884 let (frame_tx, mut frame_rx) =
22885 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
22886 frame_tx
22887 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
22888 request_id: 77,
22889 request: NodeRequest::Snapshot,
22890 })))
22891 .unwrap();
22892 let burst_len = connection_event_burst_len(pending.len());
22893 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
22894 pending.drain(..burst_len);
22895 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
22896 assert!(matches!(
22897 frame_rx.try_recv(),
22898 Ok(Ok(ClientFrame::Request(RequestEnvelope {
22899 request_id: 77,
22900 request: NodeRequest::Snapshot,
22901 })))
22902 ));
22903 }
22904
22905 #[test]
22906 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
22907 let mut pending = Vec::new();
22908 for sequence in [7_u64, 8] {
22909 let mut address = terminal_address(1);
22910 address.session.instance_id = AgentInstanceId(sequence);
22911 queue_connection_event(
22912 &mut pending,
22913 NodeEventEnvelope {
22914 sequence,
22915 event: NodeEvent::TerminalFrame {
22916 address,
22917 frame: terminal_frame(sequence),
22918 },
22919 },
22920 0,
22921 );
22922 }
22923 let mut discard_through = 0;
22924 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
22925 assert!(pending.is_empty());
22926
22927 let replayed = NodeEventEnvelope {
22928 sequence: 8,
22929 event: NodeEvent::TerminalFrame {
22930 address: terminal_address(1),
22931 frame: terminal_frame(8),
22932 },
22933 };
22934 let advanced = NodeEventEnvelope {
22935 sequence: 9,
22936 event: NodeEvent::TerminalFrame {
22937 address: terminal_address(2),
22938 frame: terminal_frame(1),
22939 },
22940 };
22941 queue_connection_event_batch(
22942 &mut pending,
22943 &[replayed, advanced.clone()],
22944 discard_through,
22945 );
22946 assert_eq!(pending, vec![advanced]);
22947 }
22948
22949 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
22950 ManagedSessionRecord {
22951 record_id: SessionRecordId::new(record_id).unwrap(),
22952 display_name: format!("{provider} record"),
22953 provider: agent(provider),
22954 mode: SessionMode::Pty,
22955 state: ManagedSessionState::Unavailable,
22956 workspace_id: WorkspaceId::new("primary").unwrap(),
22957 canonical_root: opaque_windows_path(
22958 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22959 ),
22960 provider_session: None,
22961 active_session: None,
22962 environment_profile: None,
22963 bundle: None,
22964 context_id: None,
22965 context: None,
22966 exported_context: None,
22967 task_binding: None,
22968 created_at_unix_ms: 1,
22969 updated_at_unix_ms: 1,
22970 last_error: None,
22971 }
22972 }
22973
22974 fn retention_test_record(
22975 workspace_id: &str,
22976 record_id: &str,
22977 state: ManagedSessionState,
22978 updated_at_unix_ms: u64,
22979 ) -> ManagedSessionRecord {
22980 ManagedSessionRecord {
22981 record_id: SessionRecordId::new(record_id).unwrap(),
22982 display_name: format!("{record_id} display"),
22983 provider: agent("claude"),
22984 mode: SessionMode::Pty,
22985 state,
22986 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
22987 canonical_root: opaque_windows_path(
22988 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22989 ),
22990 provider_session: None,
22991 active_session: None,
22992 environment_profile: None,
22993 bundle: None,
22994 context_id: None,
22995 context: None,
22996 exported_context: None,
22997 task_binding: None,
22998 created_at_unix_ms: updated_at_unix_ms,
22999 updated_at_unix_ms,
23000 last_error: None,
23001 }
23002 }
23003
23004 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23012 vec![
23013 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23014 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23015 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23016 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23017 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23018 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23019 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23020 retention_test_record(
23021 "workspace-a",
23022 "decoy-pending",
23023 ManagedSessionState::IdentityPending,
23024 now - 500_000,
23025 ),
23026 retention_test_record(
23027 "workspace-a",
23028 "decoy-dormant",
23029 ManagedSessionState::Dormant,
23030 now - 500_000,
23031 ),
23032 ]
23033 }
23034
23035 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23036 let root = temporary_workspace_root("session-record-retention");
23037 std::fs::create_dir_all(&root).unwrap();
23038 let catalog = active_registry().unwrap();
23039 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23040 let workspace_a =
23041 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23042 let workspace_b =
23043 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23044 let mut shared = NodeShared::new(
23045 handle,
23046 "fixture-token".to_owned(),
23047 NodeId::new("node-retention-test").unwrap(),
23048 vec![workspace_a, workspace_b],
23049 vec![agent("claude")],
23050 );
23051 shared.state_path = Some(root.join("node-state-v10.json"));
23052 let now = unix_time_ms();
23053 for record in retention_fixture_records(now) {
23054 shared.insert_record(record).unwrap();
23055 }
23056 shared
23057 .persist_state_locked()
23058 .expect("fixture-only, empty-history persist against a fresh temp state path");
23059 (shared, runtime, root, now)
23060 }
23061
23062 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23063 shared
23064 .history
23065 .lock()
23066 .unwrap_or_else(|poisoned| poisoned.into_inner())
23067 .events
23068 .iter()
23069 .filter_map(|envelope| match &envelope.event {
23070 NodeEvent::SessionRecordRemoved { record_id } => {
23071 Some(record_id.as_str().to_owned())
23072 }
23073 _ => None,
23074 })
23075 .collect()
23076 }
23077
23078 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23079 shared
23080 .session_records
23081 .lock()
23082 .unwrap_or_else(|poisoned| poisoned.into_inner())
23083 .records
23084 .keys()
23085 .map(|record_id| record_id.as_str().to_owned())
23086 .collect()
23087 }
23088
23089 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23090 values.iter().map(|value| (*value).to_owned()).collect()
23091 }
23092
23093 #[tokio::test]
23094 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23095 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23096 shared.session_record_retention = SessionRecordRetentionConfig {
23097 age_ms: 0,
23098 keep_per_workspace: 1,
23099 };
23100 shared.retire_unavailable_session_records().await;
23101
23102 assert_eq!(
23103 retention_remaining_record_ids(&shared),
23104 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23105 );
23106 assert_eq!(
23107 retention_removed_record_ids(&shared),
23108 owned(&["a1", "a2", "b1", "b2"]),
23109 );
23110 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23111
23112 drop(shared);
23113 drop(runtime);
23114 std::fs::remove_dir_all(root).unwrap();
23115 }
23116
23117 #[tokio::test]
23123 async fn session_record_retention_both_set_retires_only_the_intersection() {
23124 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23125 shared.session_record_retention = SessionRecordRetentionConfig {
23126 age_ms: 250_000,
23127 keep_per_workspace: 1,
23128 };
23129 shared.retire_unavailable_session_records().await;
23130
23131 assert_eq!(
23132 retention_remaining_record_ids(&shared),
23133 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23134 );
23135 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23136 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23137
23138 drop(shared);
23139 drop(runtime);
23140 std::fs::remove_dir_all(root).unwrap();
23141 }
23142
23143 #[tokio::test]
23144 async fn session_record_retention_both_disabled_is_a_no_op() {
23145 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23146 shared.session_record_retention = SessionRecordRetentionConfig::default();
23147 shared.retire_unavailable_session_records().await;
23148
23149 assert_eq!(
23150 retention_remaining_record_ids(&shared),
23151 owned(&[
23152 "a1", "a2", "a3", "b1", "b2", "b3",
23153 "decoy-live", "decoy-pending", "decoy-dormant",
23154 ]),
23155 );
23156 assert!(retention_removed_record_ids(&shared).is_empty());
23157 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23158
23159 drop(shared);
23160 drop(runtime);
23161 std::fs::remove_dir_all(root).unwrap();
23162 }
23163
23164 #[test]
23165 fn session_record_context_export_selects_one_exact_history_identity() {
23166 let candidates = vec![
23167 HistoryCandidateSummary {
23168 id: "candidate-other".to_owned(),
23169 session_id_hint: "provider-other".to_owned(),
23170 modified_at_unix_ms: Some(1),
23171 },
23172 HistoryCandidateSummary {
23173 id: "candidate-exact".to_owned(),
23174 session_id_hint: "provider-exact".to_owned(),
23175 modified_at_unix_ms: Some(2),
23176 },
23177 ];
23178 assert_eq!(
23179 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23180 "candidate-exact",
23181 );
23182 }
23183
23184 #[test]
23185 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23186 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23187 id: id.to_owned(),
23188 session_id_hint: hint.to_owned(),
23189 modified_at_unix_ms: None,
23190 };
23191 let missing = select_history_candidate(
23192 &[candidate("candidate-other", "provider-other")],
23193 Some("provider-exact"),
23194 )
23195 .unwrap_err();
23196 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23197 let ambiguous = select_history_candidate(
23198 &[
23199 candidate("candidate-a", "provider-exact"),
23200 candidate("candidate-b", "provider-exact"),
23201 ],
23202 Some("provider-exact"),
23203 )
23204 .unwrap_err();
23205 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23206 }
23207
23208 #[test]
23216 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23217 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23218 id: id.to_owned(),
23219 session_id_hint: hint.to_owned(),
23220 modified_at_unix_ms: None,
23221 };
23222 assert_eq!(
23223 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23224 .unwrap(),
23225 "candidate-sole",
23226 );
23227 let missing = select_history_candidate(&[], None).unwrap_err();
23228 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23229 let ambiguous = select_history_candidate(
23230 &[
23231 candidate("candidate-a", "sidecar-session-a"),
23232 candidate("candidate-b", "sidecar-session-b"),
23233 ],
23234 None,
23235 )
23236 .unwrap_err();
23237 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23238 }
23239
23240 #[test]
23241 fn session_record_context_export_revalidation_rejects_record_change() {
23242 let session = terminal_address(1);
23243 let identity = ProviderSessionIdentity {
23244 key: ProviderSessionKey::SessionId,
23245 id: "provider-exact".to_owned(),
23246 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23247 };
23248 let mut expected = record("claude", "record-context-export");
23249 expected.state = ManagedSessionState::Live;
23250 expected.provider_session = Some(identity.clone());
23251 expected.active_session = Some(session.clone());
23252 let binding = SessionBinding {
23253 workspace_id: session.workspace_id.clone(),
23254 generation: session.session.generation,
23255 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23256 pending_resume: None,
23257 record_id: Some(expected.record_id.clone()),
23258 managed_worktree_lease_id: None,
23259 environment_profile: None,
23260 bundle: None,
23261 context: None,
23262 materialization_id: None,
23263 };
23264 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23265 assert!(session_record_context_export_binding_is_exact(
23266 &expected,
23267 &expected,
23268 Some(&identity),
23269 &session,
23270 &binding,
23271 &expected.provider,
23272 ¤t_root,
23273 false,
23274 ));
23275 let mut changed = expected.clone();
23276 changed.updated_at_unix_ms += 1;
23277 assert!(!session_record_context_export_binding_is_exact(
23278 &expected,
23279 &changed,
23280 Some(&identity),
23281 &session,
23282 &binding,
23283 &expected.provider,
23284 ¤t_root,
23285 false,
23286 ));
23287 assert!(!session_record_context_export_binding_is_exact(
23292 &expected,
23293 &changed,
23294 Some(&identity),
23295 &session,
23296 &binding,
23297 &expected.provider,
23298 ¤t_root,
23299 true,
23300 ));
23301 let mut detached = expected.clone();
23302 detached.state = ManagedSessionState::Dormant;
23303 detached.active_session = None;
23304 detached.updated_at_unix_ms += 1;
23305 assert!(!session_record_context_export_binding_is_exact(
23306 &expected,
23307 &detached,
23308 Some(&identity),
23309 &session,
23310 &binding,
23311 &expected.provider,
23312 ¤t_root,
23313 false,
23314 ));
23315 assert!(session_record_context_export_binding_is_exact(
23316 &expected,
23317 &detached,
23318 Some(&identity),
23319 &session,
23320 &binding,
23321 &expected.provider,
23322 ¤t_root,
23323 true,
23324 ));
23325 }
23326
23327 #[test]
23334 fn session_record_context_export_binding_is_exact_without_identity() {
23335 let session = terminal_address(1);
23336 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23337 expected.state = ManagedSessionState::Live;
23338 expected.active_session = Some(session.clone());
23339 let binding = SessionBinding {
23340 workspace_id: session.workspace_id.clone(),
23341 generation: session.session.generation,
23342 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23343 pending_resume: None,
23344 record_id: Some(expected.record_id.clone()),
23345 managed_worktree_lease_id: None,
23346 environment_profile: None,
23347 bundle: None,
23348 context: None,
23349 materialization_id: None,
23350 };
23351 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23352 assert!(session_record_context_export_binding_is_exact(
23353 &expected,
23354 &expected,
23355 None,
23356 &session,
23357 &binding,
23358 &expected.provider,
23359 ¤t_root,
23360 false,
23361 ));
23362 let mut drifted = expected.clone();
23366 drifted.provider_session = Some(ProviderSessionIdentity {
23367 key: ProviderSessionKey::SessionId,
23368 id: "unexpected-identity".to_owned(),
23369 transcript_path: None,
23370 });
23371 assert!(!session_record_context_export_binding_is_exact(
23372 &expected,
23373 &drifted,
23374 None,
23375 &session,
23376 &binding,
23377 &expected.provider,
23378 ¤t_root,
23379 false,
23380 ));
23381 }
23382
23383 #[test]
23384 fn session_record_context_export_preflight_requires_live_usable_source() {
23385 let mut source = record("claude", "record-context-export-preflight");
23386 source.state = ManagedSessionState::Live;
23387 assert!(session_record_context_export_source_is_usable(
23388 &source,
23389 &SessionStatus::Running,
23390 false,
23391 ));
23392 assert!(session_record_context_export_source_is_usable(
23393 &source,
23394 &SessionStatus::Exited { exit_code: Some(0) },
23395 false,
23396 ));
23397
23398 source.state = ManagedSessionState::Dormant;
23399 assert!(!session_record_context_export_source_is_usable(
23400 &source,
23401 &SessionStatus::Running,
23402 false,
23403 ));
23404 assert!(session_record_context_export_source_is_usable(
23408 &source,
23409 &SessionStatus::Exited { exit_code: Some(0) },
23410 true,
23411 ));
23412 assert!(!session_record_context_export_source_is_usable(
23413 &source,
23414 &SessionStatus::Exited { exit_code: Some(0) },
23415 false,
23416 ));
23417 source.state = ManagedSessionState::Live;
23418 assert!(!session_record_context_export_source_is_usable(
23419 &source,
23420 &SessionStatus::Starting,
23421 false,
23422 ));
23423 }
23424
23425 #[test]
23426 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23427 let catalog = active_registry().unwrap();
23428 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23429 let workspace = WorkspaceConfig::new(
23430 WorkspaceId::new("primary").unwrap(),
23431 std::env::current_dir().unwrap(),
23432 )
23433 .unwrap();
23434 let shared = NodeShared::new(
23435 handle,
23436 "fixture-token".to_owned(),
23437 NodeId::new("node-context-export-conflict").unwrap(),
23438 vec![workspace],
23439 vec![agent("claude")],
23440 );
23441 let history = HistorySessionRecord {
23442 session_id: "provider-exact".to_owned(),
23443 title: None,
23444 cwd: None,
23445 model: None,
23446 message_count: 1,
23447 completed_turn_count: None,
23448 total_tokens: 0,
23449 messages: vec![gate4agent_types::HistoryMessageRecord {
23450 role: gate4agent_types::HistoryMessageRole::User,
23451 text: "bounded context".to_owned(),
23452 }],
23453 };
23454 let pack = NodeContextPack::export(
23455 ContextPackLineageReceipt {
23456 source_node_id: shared.node_id.clone(),
23457 source_session: terminal_address(1),
23458 source_provider: agent("claude"),
23459 },
23460 &history,
23461 )
23462 .unwrap();
23463 let context_id = pack.receipt().id.clone();
23464 let session = terminal_address(1);
23465 let identity = ProviderSessionIdentity {
23466 key: ProviderSessionKey::SessionId,
23467 id: history.session_id.clone(),
23468 transcript_path: None,
23469 };
23470 let mut expected = record("claude", "record-context-export-final-conflict");
23471 expected.state = ManagedSessionState::Live;
23472 expected.provider_session = Some(identity.clone());
23473 expected.active_session = Some(session.clone());
23474 let mut changed = expected.clone();
23475 changed.updated_at_unix_ms += 1;
23476 shared.insert_record(changed).unwrap();
23477
23478 let error = shared
23479 .commit_context_pack_for_session_record(
23480 &expected,
23481 Some(&identity),
23482 &session,
23483 "candidate-exact",
23484 &history,
23485 pack,
23486 false,
23487 )
23488 .unwrap_err();
23489
23490 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23491 assert!(shared
23492 .context_catalog
23493 .read()
23494 .unwrap_or_else(|poisoned| poisoned.into_inner())
23495 .get(&context_id)
23496 .is_none());
23497 }
23498
23499 #[tokio::test]
23500 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23501 let root = temporary_workspace_root("session-task-binding");
23502 std::fs::create_dir_all(&root).unwrap();
23503 let state_path = root.join("node-state.json");
23504 let catalog = active_registry().unwrap();
23505 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23506 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23507 let node_id = NodeId::new("node-1").unwrap();
23508 let mut shared = NodeShared::new(
23509 handle,
23510 "fixture-token".to_owned(),
23511 node_id.clone(),
23512 vec![workspace],
23513 vec![agent("claude")],
23514 );
23515 shared.state_path = Some(state_path.clone());
23516 let record = record("claude", "sr-task");
23517 let record_id = record.record_id.clone();
23518 shared.insert_record(record).unwrap();
23519 shared.persist_state().unwrap();
23520 shared
23521 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23522 .unwrap();
23523
23524 let minted = process_request_inner(
23525 &shared,
23526 77,
23527 ClientRole::Operator,
23528 NodeRequest::SetSessionTask {
23529 record_id: record_id.clone(),
23530 expected_revision: 0,
23531 target: SessionTaskTargetV1::New,
23532 },
23533 )
23534 .await
23535 .unwrap();
23536 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23537 panic!("session task mutation returned another response");
23538 };
23539 let minted_binding = minted.task_binding.clone().unwrap();
23540 let minted_task_id = minted_binding.task_id.clone().unwrap();
23541 assert_eq!(minted_binding.revision, 1);
23542 assert!(minted_task_id.as_str().starts_with("task-"));
23543 assert_eq!(minted_task_id.as_str().len(), 29);
23544
23545 let event_count = shared.current_sequence();
23546 let repeated = process_request_inner(
23547 &shared,
23548 77,
23549 ClientRole::Operator,
23550 NodeRequest::SetSessionTask {
23551 record_id: record_id.clone(),
23552 expected_revision: 1,
23553 target: SessionTaskTargetV1::Existing {
23554 task_id: minted_task_id.clone(),
23555 },
23556 },
23557 )
23558 .await
23559 .unwrap();
23560 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23561 assert_eq!(shared.current_sequence(), event_count);
23562
23563 let stale = process_request_inner(
23564 &shared,
23565 77,
23566 ClientRole::Operator,
23567 NodeRequest::SetSessionTask {
23568 record_id: record_id.clone(),
23569 expected_revision: 0,
23570 target: SessionTaskTargetV1::Existing {
23571 task_id: minted_task_id,
23572 },
23573 },
23574 )
23575 .await
23576 .unwrap_err();
23577 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23578
23579 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23580 let assigned = process_request_inner(
23581 &shared,
23582 77,
23583 ClientRole::Operator,
23584 NodeRequest::SetSessionTask {
23585 record_id: record_id.clone(),
23586 expected_revision: 1,
23587 target: SessionTaskTargetV1::Existing {
23588 task_id: assigned_id.clone(),
23589 },
23590 },
23591 )
23592 .await
23593 .unwrap();
23594 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23595 panic!("session task assignment returned another response");
23596 };
23597 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23598 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23599
23600 let cleared = process_request_inner(
23601 &shared,
23602 77,
23603 ClientRole::Operator,
23604 NodeRequest::SetSessionTask {
23605 record_id: record_id.clone(),
23606 expected_revision: 2,
23607 target: SessionTaskTargetV1::Clear,
23608 },
23609 )
23610 .await
23611 .unwrap();
23612 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23613 panic!("session task clear returned another response");
23614 };
23615 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23616 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23617 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23618 assert_eq!(
23619 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23620 cleared.updated_at_unix_ms,
23621 );
23622
23623 let persisted = std::fs::read(&state_path).unwrap();
23624 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23625 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23626 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23627 std::fs::remove_dir_all(root).unwrap();
23628 }
23629
23630 #[test]
23631 fn session_task_capability_strips_snapshots_events_and_replies() {
23632 let mut bound = record("claude", "sr-task-projection");
23633 bound.updated_at_unix_ms = 2;
23634 bound.task_binding = Some(SessionTaskBindingV1 {
23635 revision: 1,
23636 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23637 changed_at_unix_ms: 2,
23638 });
23639 let request = NodeRequest::SetSessionTask {
23640 record_id: bound.record_id.clone(),
23641 expected_revision: 1,
23642 target: SessionTaskTargetV1::Clear,
23643 };
23644 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23645 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23646 }));
23647 assert!(!request_uses_unnegotiated_capability(
23648 &request,
23649 &baseline_capabilities().unwrap(),
23650 ));
23651 assert!(request_uses_unnegotiated_capability(&request, &[]));
23652
23653 let mut snapshot = terminal_test_shared().snapshot();
23654 snapshot.session_records.push(bound.clone());
23655 let mut reply = ResponseEnvelope {
23656 request_id: 1,
23657 result: Ok(NodeResponse::Resync {
23658 event_sequence: 1,
23659 oldest_available_sequence: 1,
23660 snapshot,
23661 events: vec![NodeEventEnvelope {
23662 sequence: 1,
23663 event: NodeEvent::SessionRecordUpserted {
23664 record: bound.clone(),
23665 },
23666 }],
23667 }),
23668 };
23669 project_response_without_session_task_binding(&mut reply);
23670 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23671 panic!("task projection changed the response kind");
23672 };
23673 assert!(snapshot.session_records[0].task_binding.is_none());
23674 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
23675 panic!("task projection changed the event kind");
23676 };
23677 assert!(record.task_binding.is_none());
23678
23679 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
23680 sequence: 2,
23681 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
23682 });
23683 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
23684 panic!("task event projection changed the event kind");
23685 };
23686 assert!(record.task_binding.is_none());
23687
23688 let mut direct = ResponseEnvelope {
23689 request_id: 2,
23690 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
23691 };
23692 project_response_without_session_task_binding(&mut direct);
23693 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
23694 panic!("task reply projection changed the response kind");
23695 };
23696 assert!(record.task_binding.is_none());
23697 }
23698
23699 #[test]
23700 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
23701 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
23702 let workspace = WorkspaceConfig::new(
23703 WorkspaceId::new("primary").unwrap(),
23704 std::env::current_dir().unwrap(),
23705 )
23706 .unwrap();
23707 let config = NodeServerConfig::new(
23708 DEFAULT_NODE_ENDPOINT,
23709 "fixture-token",
23710 NodeId::new("node-1").unwrap(),
23711 [workspace],
23712 )
23713 .unwrap();
23714 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
23715 }
23716
23717 #[cfg(all(feature = "fixture", windows))]
23718 #[test]
23719 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
23720 let root = temporary_workspace_root("provider-bundle-argv-hold");
23721 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
23722 std::fs::create_dir_all(&root).unwrap();
23723 std::fs::create_dir_all(&outside).unwrap();
23724 let proof_path = root.join("provider.proof");
23725 let release_signal = root.join("release.signal");
23726 let config = NodeServerConfig::new(
23727 format!(
23728 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
23729 std::process::id(),
23730 ),
23731 "fixture-token",
23732 NodeId::new("provider-bundle-hold-node").unwrap(),
23733 [WorkspaceConfig::new(
23734 WorkspaceId::new("primary").unwrap(),
23735 std::env::current_dir().unwrap(),
23736 )
23737 .unwrap()],
23738 )
23739 .unwrap()
23740 .with_spawn_profiles(SpawnProfileRegistry::new([
23741 crate::protocol::SpawnProfileDefaults {
23742 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
23743 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
23744 .unwrap(),
23745 provider: agent("kimi"),
23746 mode: SessionMode::Pty,
23747 terminal_size: gate4agent_types::TerminalSize {
23748 rows: 24,
23749 columns: 80,
23750 },
23751 prompt: None,
23752 bundle_id: None,
23753 context_id: None,
23754 environment_profile_id: None,
23755 },
23756 ])
23757 .unwrap());
23758 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
23759 config,
23760 agent("kimi"),
23761 proof_path.clone(),
23762 release_signal.clone(),
23763 )
23764 .unwrap();
23765 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
23766
23767 let spec = NodeServer::provider_bundle_argv_fixture_spec(
23768 agent("kimi"),
23769 proof_path.clone(),
23770 Some(release_signal.clone()),
23771 )
23772 .unwrap();
23773 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
23774 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
23775 let release_wait = wrapper
23776 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
23777 .unwrap();
23778 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
23779 assert!(proof_write < release_wait);
23780 assert!(release_wait < fixture_ready);
23781 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
23782 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
23783 assert_eq!(
23784 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
23785 &[
23786 proof_path.to_string_lossy().into_owned(),
23787 release_signal.to_string_lossy().into_owned(),
23788 ],
23789 );
23790
23791 let error = NodeServer::provider_bundle_argv_fixture_spec(
23792 agent("kimi"),
23793 proof_path,
23794 Some(outside.join("release.signal")),
23795 )
23796 .unwrap_err();
23797 assert!(matches!(
23798 error,
23799 NodeServerError::Registry(message)
23800 if message == "provider bundle argv release path must share the proof parent"
23801 ));
23802 drop(server);
23803 std::fs::remove_dir_all(root).unwrap();
23804 std::fs::remove_dir_all(outside).unwrap();
23805 }
23806
23807 #[cfg(feature = "fixture")]
23808 #[test]
23809 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
23810 let root = temporary_workspace_root("context-fixture-registry");
23811 std::fs::create_dir_all(&root).unwrap();
23812 let proof_path = root.join("context.proof");
23813 let config = NodeServerConfig::new(
23814 format!(
23815 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
23816 std::process::id(),
23817 ),
23818 "fixture-token",
23819 NodeId::new("context-fixture-node").unwrap(),
23820 [WorkspaceConfig::new(
23821 WorkspaceId::new("primary").unwrap(),
23822 std::env::current_dir().unwrap(),
23823 )
23824 .unwrap()],
23825 )
23826 .unwrap();
23827 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
23828 let mut enabled = server
23829 .shared
23830 .enabled_providers
23831 .iter()
23832 .map(AgentId::as_str)
23833 .collect::<Vec<_>>();
23834 enabled.sort_unstable();
23835 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
23836 for provider in &server.shared.enabled_providers {
23837 assert!(server
23838 .shared
23839 .provider_adapter_contracts
23840 .iter()
23841 .any(|contract| {
23842 &contract.provider == provider
23843 && contract.family == AdapterFamily::History
23844 }));
23845 }
23846 drop(server);
23847 std::fs::remove_dir_all(root).unwrap();
23848 }
23849
23850 #[cfg(feature = "fixture")]
23851 #[test]
23852 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
23853 let root = temporary_workspace_root("monitoring-context-fixture");
23854 std::fs::create_dir_all(&root).unwrap();
23855 let proof_path = root.join("context.proof");
23856 let standard = NodeServer::context_pack_fixture_catalog(
23857 proof_path.clone(),
23858 None,
23859 false,
23860 ).unwrap();
23861 let monitoring = NodeServer::context_pack_fixture_catalog(
23862 proof_path.clone(),
23863 None,
23864 true,
23865 ).unwrap();
23866 let claude = AgentId::new("claude").unwrap();
23867 let standard_claude = standard.get(&claude).unwrap();
23868 let monitoring_claude = monitoring.get(&claude).unwrap();
23869 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
23873 assert_eq!(
23874 monitoring_claude.capabilities.adapters.history,
23875 standard_claude.capabilities.adapters.history,
23876 );
23877 assert_ne!(monitoring_claude.launch, standard_claude.launch);
23878 let codex = AgentId::new("codex").unwrap();
23879 let standard_codex = standard.get(&codex).unwrap();
23880 let monitoring_codex = monitoring.get(&codex).unwrap();
23881 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
23882 assert_eq!(
23883 monitoring_codex.capabilities.adapters.history,
23884 standard_codex.capabilities.adapters.history,
23885 );
23886 assert_ne!(monitoring_codex.launch, standard_codex.launch);
23887 let codex_validation = monitoring_codex.launch.fixed_args.iter()
23888 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
23889 .unwrap();
23890 for required in [
23891 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
23892 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
23893 "@('role', 'text')",
23894 "g4a-private-provider-session-canary",
23895 "private-provider-login-identity",
23896 "$rawProof",
23897 "$env:GATE4AGENT_HOOK_URL",
23898 "g4a-private-provider-session-canary",
23899 ] {
23900 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
23901 }
23902 for provider in ["grok", "kimi"] {
23903 let provider = AgentId::new(provider).unwrap();
23904 assert_eq!(monitoring.get(&provider), standard.get(&provider));
23905 }
23906
23907 let config = NodeServerConfig::new(
23908 format!(
23909 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
23910 std::process::id(),
23911 ),
23912 "fixture-token",
23913 NodeId::new("monitoring-context-fixture-node").unwrap(),
23914 [WorkspaceConfig::new(
23915 WorkspaceId::new("primary").unwrap(),
23916 std::env::current_dir().unwrap(),
23917 )
23918 .unwrap()],
23919 )
23920 .unwrap();
23921 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
23922 assert!(server.shared.fixture_semantic_hook_policy);
23923 drop(server);
23924 std::fs::remove_dir_all(root).unwrap();
23925 }
23926
23927 #[cfg(feature = "fixture")]
23928 #[test]
23929 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
23930 let root = temporary_workspace_root("kimi-context-only-fixture");
23931 std::fs::create_dir_all(&root).unwrap();
23932 let proof_path = root.join("context.proof");
23933 let standard = NodeServer::context_pack_fixture_catalog(
23934 proof_path.clone(),
23935 None,
23936 false,
23937 ).unwrap();
23938 let kimi = AgentId::new("kimi").unwrap();
23939 let context_only = NodeServer::context_pack_fixture_catalog(
23940 proof_path,
23941 Some(&kimi),
23942 false,
23943 )
23944 .unwrap();
23945
23946 let codex = AgentId::new("codex").unwrap();
23947 assert_eq!(
23948 standard.get(&codex).unwrap().launch.fixed_args,
23949 context_only.get(&codex).unwrap().launch.fixed_args,
23950 );
23951 let script = context_only
23952 .get(&kimi)
23953 .unwrap()
23954 .launch
23955 .fixed_args
23956 .iter()
23957 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
23958 .expect("Kimi context-only validation script is missing");
23959 for required in [
23960 "$bundleArgs.Count -ne 0",
23961 "GATE4AGENT_CONTEXT_ROOT",
23962 "context-pack.json",
23963 "$entries.Count -ne 1",
23964 "g4a-context-pack-v1",
23965 "$document.PSObject.Properties['cwd']",
23966 "'claude', 'codex', 'grok', 'kimi'",
23967 "$hasUser",
23968 "$hasAssistant",
23969 "SHA256",
23970 "context-only",
23971 ] {
23972 assert!(script.contains(required), "missing validation: {required}");
23973 }
23974 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
23975 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
23976 }
23977 assert!(!standard
23978 .get(&kimi)
23979 .unwrap()
23980 .launch
23981 .fixed_args
23982 .iter()
23983 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
23984
23985 std::fs::remove_dir_all(root).unwrap();
23986 }
23987
23988 #[cfg(all(feature = "fixture", windows))]
23989 #[tokio::test]
23990 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
23991 let root = temporary_workspace_root("kimi-context-only-spawn");
23992 let workspace_root = root.join("workspace");
23993 std::fs::create_dir_all(&workspace_root).unwrap();
23994 let materialization_root = root.join("private-materializations");
23995 let proof_path = root.join("context.proof");
23996 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
23997 let workspace_id = WorkspaceId::new("primary").unwrap();
23998 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
23999 let endpoint = format!(
24000 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24001 std::process::id(),
24002 unix_time_ms(),
24003 );
24004 let spawn_profiles = SpawnProfileRegistry::new([
24005 crate::protocol::SpawnProfileDefaults {
24006 profile_id: profile_id.clone(),
24007 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24008 provider: agent("kimi"),
24009 mode: SessionMode::Pty,
24010 terminal_size: gate4agent_types::TerminalSize {
24011 rows: 30,
24012 columns: 120,
24013 },
24014 prompt: None,
24015 bundle_id: None,
24016 context_id: None,
24017 environment_profile_id: None,
24018 },
24019 ])
24020 .unwrap();
24021 let mut config = NodeServerConfig::new(
24022 &endpoint,
24023 "fixture-token",
24024 node_id.clone(),
24025 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24026 )
24027 .unwrap()
24028 .with_spawn_profiles(spawn_profiles)
24029 .with_session_environment_materialization(
24030 materialization_root,
24031 Arc::new(FixtureSecretResolver {
24032 deny: Arc::new(AtomicBool::new(false)),
24033 }),
24034 )
24035 .unwrap();
24036 config.fixture_raw_pty_runtime = true;
24037 let server = NodeServer::new_context_only_proof_fixture(
24038 config,
24039 agent("kimi"),
24040 proof_path.clone(),
24041 )
24042 .unwrap();
24043 let shared = Arc::clone(&server.shared);
24044 let shutdown = server.shutdown_handle();
24045
24046 let history = HistorySessionRecord {
24047 session_id: "context-only-source-history".to_owned(),
24048 title: None,
24049 cwd: None,
24050 model: None,
24051 message_count: 2,
24052 completed_turn_count: None,
24053 total_tokens: 0,
24054 messages: vec![
24055 gate4agent_types::HistoryMessageRecord {
24056 role: gate4agent_types::HistoryMessageRole::User,
24057 text: "continue the bounded parent run through native C2".to_owned(),
24058 },
24059 gate4agent_types::HistoryMessageRecord {
24060 role: gate4agent_types::HistoryMessageRole::Assistant,
24061 text: "the exact parent context is ready for the child run".to_owned(),
24062 },
24063 ],
24064 };
24065 let pack = NodeContextPack::export(
24066 ContextPackLineageReceipt {
24067 source_node_id: node_id.clone(),
24068 source_session: SessionAddress {
24069 workspace_id: workspace_id.clone(),
24070 session: SessionKey {
24071 instance_id: AgentInstanceId(71),
24072 generation: SessionGeneration(1),
24073 },
24074 },
24075 source_provider: agent("codex"),
24076 },
24077 &history,
24078 )
24079 .unwrap();
24080 let expected_context_hash = digest(&SHA256, pack.bytes())
24081 .as_ref()
24082 .iter()
24083 .map(|byte| format!("{byte:02x}"))
24084 .collect::<String>();
24085 let context = shared
24086 .context_catalog
24087 .write()
24088 .unwrap_or_else(|poisoned| poisoned.into_inner())
24089 .insert(pack)
24090 .unwrap();
24091 let spec = SpawnSpec {
24092 target: crate::protocol::SpawnTarget {
24093 node_id,
24094 workspace_id: workspace_id.clone(),
24095 worktree_id: None,
24096 },
24097 profile_id,
24098 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24099 "target-kimi-r1",
24100 ).unwrap(),
24101 overrides: crate::protocol::SpawnOverrides {
24102 context_id: crate::protocol::SpawnOverride::Set {
24103 value: context.id.clone(),
24104 },
24105 ..crate::protocol::SpawnOverrides::default()
24106 },
24107 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24108 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24109 required_capabilities: SpawnRequiredCapabilities::default(),
24110 };
24111 let resolved = match shared.resolve_spawn_spec(&spec) {
24112 Ok(resolved) => resolved,
24113 Err(error) => panic!(
24114 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24115 error.code,
24116 error.message,
24117 ),
24118 };
24119 let resolved_context = match shared.resolve_context(&resolved) {
24120 Ok(Some(resolved_context)) => resolved_context,
24121 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24122 Err(error) => panic!(
24123 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24124 error.code,
24125 error.message,
24126 ),
24127 };
24128 assert_eq!(resolved_context, context);
24129 let runtime_policy = match shared
24130 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24131 .await
24132 {
24133 Ok(runtime_policy) => runtime_policy,
24134 Err(error) => panic!(
24135 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24136 error.code,
24137 error.message,
24138 ),
24139 };
24140 assert!(runtime_policy.raw_pty_lifecycle);
24141
24142 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24143 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24144 agent("kimi"),
24145 TransportKind::Pty,
24146 vec![EnvMutation {
24147 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24148 value: Some(OsString::from("must-remain-rejected")),
24149 }],
24150 )
24151 .unwrap();
24152 assert_eq!(
24153 launch_control
24154 .install_native_launch_environment_overlay(
24155 AgentInstanceId(72),
24156 generic_overlay,
24157 )
24158 .unwrap_err(),
24159 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24160 );
24161
24162 let diagnostic_address = SessionAddress {
24163 workspace_id: workspace_id.clone(),
24164 session: SessionKey {
24165 instance_id: AgentInstanceId(73),
24166 generation: SessionGeneration(1),
24167 },
24168 };
24169 let (diagnostic_overlay, diagnostic_materialization) = match shared
24170 .prepare_session_materialization(
24171 &diagnostic_address,
24172 &agent("kimi"),
24173 SessionMode::Pty,
24174 None,
24175 None,
24176 Some(&resolved_context),
24177 None,
24178 )
24179 {
24180 Ok(Some(prepared)) => prepared,
24181 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24182 Err(error) => panic!(
24183 "Kimi context-only materialization rejected: exact_code={:?} message={}",
24184 error.code,
24185 error.message,
24186 ),
24187 };
24188 let diagnostic_overlay = diagnostic_overlay
24189 .expect("Kimi context-only materialization omitted its environment overlay");
24190 assert!(matches!(
24191 &diagnostic_overlay,
24192 PreparedNativeLaunchOverlay::Environment(_),
24193 ));
24194 match shared.install_prepared_launch_overlay(
24195 diagnostic_address.session.instance_id,
24196 diagnostic_overlay,
24197 ) {
24198 Ok(None) => {}
24199 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24200 Err(error) => panic!(
24201 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24202 error.code,
24203 error.message,
24204 ),
24205 }
24206 assert!(launch_control.clear_native_instance_launch_overlay(
24207 diagnostic_address.session.instance_id,
24208 ));
24209 drop(diagnostic_materialization);
24210 assert!(shared
24211 .materializations
24212 .lock()
24213 .unwrap_or_else(|poisoned| poisoned.into_inner())
24214 .is_empty());
24215
24216 let node_task = tokio::spawn(server.run());
24217 let ready_client = timeout(Duration::from_secs(5), async {
24218 loop {
24219 match gate4agent_node_wire::LocalNodeClient::connect(
24220 &endpoint,
24221 &shared.node_id,
24222 ClientRole::Observer,
24223 "fixture-token",
24224 )
24225 .await
24226 {
24227 Ok(client) => break client,
24228 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24229 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24230 }
24231 }
24232 })
24233 .await
24234 .expect("Kimi context-only Node did not become ready");
24235 assert_eq!(
24236 ready_client
24237 .hello()
24238 .snapshot
24239 .provider_runtime_statuses
24240 .iter()
24241 .find(|status| status.provider().as_str() == "kimi")
24242 .expect("Kimi runtime status is missing")
24243 .mode(),
24244 crate::protocol::ProviderRuntimeMode::RawPassthrough,
24245 );
24246 drop(ready_client);
24247
24248 let receipt = match shared.spawn_from_spec(spec).await {
24249 Ok(receipt) => receipt,
24250 Err(error) => panic!(
24251 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24252 error.code,
24253 error.message,
24254 ),
24255 };
24256 assert_eq!(receipt.provider, agent("kimi"));
24257 assert_eq!(receipt.context.as_ref(), Some(&context));
24258 assert!(receipt.context_binding_is_valid());
24259 timeout(Duration::from_secs(5), async {
24260 while !proof_path.is_file() {
24261 sleep(Duration::from_millis(10)).await;
24262 }
24263 })
24264 .await
24265 .expect("Kimi context-only fixture did not write its proof");
24266
24267 let proof = std::fs::read_to_string(&proof_path).unwrap();
24268 let lines = proof.lines().collect::<Vec<_>>();
24269 assert_eq!(lines.len(), 7);
24270 assert_eq!(lines[0], "context-only");
24271 assert!(Path::new(lines[1]).is_absolute());
24272 assert_ne!(
24273 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24274 std::fs::canonicalize(&workspace_root).unwrap(),
24275 );
24276 assert_eq!(
24277 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24278 std::fs::canonicalize(&workspace_root).unwrap(),
24279 );
24280 assert_eq!(lines[3], expected_context_hash);
24281 assert_eq!(lines[4], "g4a-context-pack-v1");
24282 assert_eq!(lines[5], "codex");
24283 assert_eq!(lines[6], "2");
24284 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24285 session.instance_id == receipt.session.session.instance_id
24286 && session.agent_id.as_str() == "kimi"
24287 }));
24288
24289 shutdown.request_shutdown().await.unwrap();
24290 timeout(Duration::from_secs(5), node_task)
24291 .await
24292 .unwrap()
24293 .unwrap()
24294 .unwrap();
24295 drop(shutdown);
24296 drop(shared);
24297 std::fs::remove_dir_all(root).unwrap();
24298 }
24299
24300 #[cfg(feature = "fixture")]
24301 #[test]
24302 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24303 let root = temporary_workspace_root("clean-exit-constructor");
24304 std::fs::create_dir_all(&root).unwrap();
24305 let config = construction_test_config("clean-exit-constructor")
24306 .with_state_path(root.join("node-state.json"))
24307 .unwrap();
24308 let server = NodeServer::new_clean_exit_fixture(
24309 config,
24310 root.clone(),
24311 root.join("started.marker"),
24312 root.join("release.signal"),
24313 )
24314 .unwrap();
24315 assert_eq!(
24316 server
24317 .shared
24318 .enabled_providers
24319 .iter()
24320 .map(AgentId::as_str)
24321 .collect::<Vec<_>>(),
24322 ["claude"],
24323 );
24324 drop(server);
24325 std::fs::remove_dir_all(root).unwrap();
24326 }
24327
24328 #[test]
24329 fn production_registry_provider_contract_manifest_is_exact() {
24330 let registry = active_registry().unwrap();
24331 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24332 assert_eq!(
24333 providers
24334 .iter()
24335 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24336 .collect::<Vec<_>>(),
24337 vec![
24338 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24339 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24340 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24341 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24342 ]
24343 );
24344 assert_eq!(
24345 adapters
24346 .iter()
24347 .map(|contract| (
24348 contract.provider.as_str(),
24349 contract.family,
24350 contract.adapter_id.as_str(),
24351 contract.revision.as_str(),
24352 ))
24353 .collect::<Vec<_>>(),
24354 vec![
24355 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24356 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24357 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24358 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24359 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24360 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24361 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24362 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24363 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24364 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24365 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24366 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24367 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24368 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24369 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24370 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24371 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24372 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24373 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24374 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24375 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24376 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24377 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24378 ]
24379 );
24380 }
24381
24382 fn construction_test_config(name: &str) -> NodeServerConfig {
24383 let workspace = WorkspaceConfig::new(
24384 WorkspaceId::new("primary").unwrap(),
24385 std::env::current_dir().unwrap(),
24386 )
24387 .unwrap();
24388 NodeServerConfig::new(
24389 format!(r"\\.\pipe\gate4agent-{name}"),
24390 "fixture-token",
24391 NodeId::new(name).unwrap(),
24392 [workspace],
24393 )
24394 .unwrap()
24395 }
24396
24397 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24398 let adapter = AdapterBinding::new(
24399 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24400 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24401 gate4agent_types::AdapterVerification::SyntheticFixture,
24402 )
24403 .unwrap();
24404 let agent_ids = ["claude", "codex", "kimi"]
24405 .into_iter()
24406 .map(|agent_id| AgentId::new(agent_id).unwrap())
24407 .collect::<Vec<_>>();
24408 let adapters = gate4agent_catalog::AdapterRegistry::new(
24409 [
24410 AdapterFamily::PtySemantic,
24411 AdapterFamily::Pipe,
24412 AdapterFamily::OneShot,
24413 AdapterFamily::Acp,
24414 AdapterFamily::Hook,
24415 AdapterFamily::ManagedHook,
24416 AdapterFamily::History,
24417 AdapterFamily::Resume,
24418 AdapterFamily::SessionOptions,
24419 AdapterFamily::CapabilityProbe,
24420 ]
24421 .into_iter()
24422 .map(|family| gate4agent_catalog::AdapterDescriptor {
24423 family,
24424 binding: adapter.clone(),
24425 agents: agent_ids.clone(),
24426 }),
24427 )
24428 .unwrap();
24429 let specs = agent_ids
24430 .iter()
24431 .map(|agent_id| {
24432 let agent_id = agent_id.as_str();
24433 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24434 spec.revision =
24435 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24436 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24437 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24438 adapter: adapter.clone(),
24439 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24440 launch_override: None,
24441 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24442 });
24443 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24444 adapter: adapter.clone(),
24445 launch_override: None,
24446 });
24447 spec.capabilities.adapters.hook = Some(adapter.clone());
24448 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24449 spec.capabilities.adapters.one_shot = Some(adapter.clone());
24450 spec.capabilities.adapters.history = Some(adapter.clone());
24451 spec.capabilities.adapters.resume = Some(adapter.clone());
24452 spec.capabilities.adapters.session_options = Some(adapter.clone());
24453 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24454 spec
24455 })
24456 .collect::<Vec<_>>();
24457 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24458 }
24459
24460 #[test]
24468 fn node_server_construction_accepts_the_production_manifest() {
24469 let registry = active_registry().unwrap();
24470 let (providers, _) = provider_contract_manifest(®istry).unwrap();
24471 assert_eq!(providers.len(), registry.iter().count());
24472 assert!(!providers.is_empty());
24473 let server = NodeServer::new_with_registry(
24474 construction_test_config("manifest-production-bound"),
24475 registry,
24476 )
24477 .unwrap();
24478 drop(server);
24479 }
24480
24481 #[test]
24482 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24483 let registry = maximum_size_provider_manifest_registry();
24484 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24485 assert_eq!(providers.len(), 3);
24486 assert_eq!(adapters.len(), 30);
24487 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24488 let error = match NodeServer::new_with_registry(
24489 construction_test_config("manifest-handshake-overflow"),
24490 registry,
24491 ) {
24492 Ok(_) => panic!("oversized negotiated manifest was accepted"),
24493 Err(error) => error,
24494 };
24495 assert!(matches!(
24496 error,
24497 NodeServerError::ProviderContractManifest(message)
24498 if message.contains("authentication binding length")
24499 && message.contains("8192-byte limit")
24500 ));
24501 }
24502
24503 #[test]
24504 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24505 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24506 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24507 let registry = AgentRegistry::new([spec]).unwrap();
24508 assert!(matches!(
24509 provider_contract_manifest(®istry),
24510 Err(NodeServerError::ProviderContractManifest(message))
24511 if message.contains("exceeds the 128-byte limit")
24512 ));
24513 }
24514
24515 #[test]
24516 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24517 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24518 spec.revision = "invalid revision".to_owned();
24519 let registry = AgentRegistry::new([spec]).unwrap();
24520 assert!(matches!(
24521 provider_contract_manifest(®istry),
24522 Err(NodeServerError::ProviderContractManifest(message))
24523 if message.contains("bounded lowercase ASCII")
24524 ));
24525 }
24526
24527 #[test]
24528 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24529 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24530 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24531 let conflicting_binding = builtin_registry()
24532 .get_by_id("codex")
24533 .unwrap()
24534 .capabilities
24535 .adapters
24536 .one_shot
24537 .clone()
24538 .unwrap();
24539 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24540 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24541 pipe.adapter = transport_binding;
24542 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24543 let registry = AgentRegistry::new([spec]).unwrap();
24544 assert!(matches!(
24545 provider_contract_manifest(®istry),
24546 Err(NodeServerError::ProviderContractManifest(message))
24547 if message.contains("conflicting OneShot adapter bindings")
24548 ));
24549 }
24550
24551 #[test]
24552 fn public_node_failures_expose_only_fixed_categories() {
24553 let provider = failure(
24554 NodeFailureCode::BackendOperationFailed,
24555 r"provider bearer-secret failed at C:\private\session.jsonl",
24556 );
24557 assert_eq!(provider.message, "backend-operation-failed");
24558 assert!(!provider.message.contains("bearer-secret"));
24559 assert!(!provider.message.contains(r"C:\private"));
24560 let persistence = persistence_failure(io::Error::new(
24561 io::ErrorKind::Other,
24562 r"state write failed at C:\private\state-v1.json",
24563 ));
24564 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24565 assert!(!persistence.message.contains(r"C:\private"));
24566
24567 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24568 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24569 assert_eq!(
24570 generic.to_string(),
24571 "node durable state failed: durable-state-load-failed",
24572 );
24573 }
24574 }
24575
24576 #[test]
24577 fn terminal_byte_sequences_are_bounded_before_dispatch() {
24578 let sequence = b"\x1b[1;5D".to_vec();
24579 assert_eq!(
24580 terminal_bytes_action(sequence.clone()).unwrap(),
24581 InputAction::TerminalBytes(sequence),
24582 );
24583 assert_eq!(
24584 terminal_bytes_action(Vec::new()).unwrap_err().code,
24585 NodeFailureCode::InvalidRequest,
24586 );
24587 assert_eq!(
24588 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24589 .unwrap_err()
24590 .code,
24591 NodeFailureCode::InvalidRequest,
24592 );
24593 }
24594
24595 #[test]
24596 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24597 let root = temporary_workspace_root("exclusive-state-owner");
24598 std::fs::create_dir_all(&root).unwrap();
24599 let workspace = WorkspaceConfig::new(
24600 WorkspaceId::new("primary").unwrap(),
24601 &root,
24602 )
24603 .unwrap();
24604 let state_path = root.join("state-v1.json");
24605 let endpoint = format!(
24606 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24607 std::process::id(),
24608 std::time::SystemTime::now()
24609 .duration_since(std::time::UNIX_EPOCH)
24610 .unwrap()
24611 .as_nanos(),
24612 );
24613 let config = NodeServerConfig::new(
24614 endpoint,
24615 "fixture-token",
24616 NodeId::new("node-1").unwrap(),
24617 [workspace],
24618 )
24619 .unwrap()
24620 .with_state_path(&state_path)
24621 .unwrap();
24622
24623 let first = NodeServer::new(config.clone()).unwrap();
24624 let error = match NodeServer::new(config.clone()) {
24625 Ok(_) => panic!("a second node server acquired the same durable state path"),
24626 Err(error) => error,
24627 };
24628 assert!(matches!(
24629 &error,
24630 NodeServerError::DurableState(error)
24631 if error.kind() == io::ErrorKind::WouldBlock
24632 ));
24633 assert_eq!(
24634 error.to_string(),
24635 "node durable state failed: durable-state-lock-failed",
24636 );
24637 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24638 drop(first);
24639 let second = NodeServer::new(config).unwrap();
24640 drop(second);
24641 std::fs::remove_dir_all(root).unwrap();
24642 }
24643
24644 #[test]
24645 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24646 let catalog = active_registry().unwrap();
24647 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24648 let workspace_id = WorkspaceId::new("primary").unwrap();
24649 let workspace = WorkspaceConfig::new(
24650 workspace_id.clone(),
24651 std::env::current_dir().unwrap(),
24652 )
24653 .unwrap();
24654 let shared = NodeShared::new(
24655 handle,
24656 "fixture-token".to_owned(),
24657 NodeId::new("node-1").unwrap(),
24658 vec![workspace.clone()],
24659 vec![agent("claude"), agent("codex"), agent("kimi")],
24660 );
24661 let instance_id = AgentInstanceId(17);
24662 let generation = SessionGeneration(3);
24663 assert!(shared.address_for(instance_id, generation).is_none());
24664 shared.bind_session(instance_id, workspace_id.clone(), generation);
24665 let address = SessionAddress {
24666 workspace_id,
24667 session: SessionKey { instance_id, generation },
24668 };
24669 assert_eq!(
24670 shared.workspace_root(&address.workspace_id).unwrap(),
24671 workspace.canonical_root,
24672 );
24673 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
24674 shared.remove_binding(&address);
24675 assert!(shared.address_for(instance_id, generation).is_none());
24676 }
24677
24678 #[test]
24679 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
24680 let address = SessionAddress {
24681 workspace_id: WorkspaceId::new("primary").unwrap(),
24682 session: SessionKey {
24683 instance_id: AgentInstanceId(7),
24684 generation: SessionGeneration(2),
24685 },
24686 };
24687 let record_id = SessionRecordId::new("record-a").unwrap();
24688 let size = gate4agent_types::TerminalSize {
24689 rows: 24,
24690 columns: 80,
24691 };
24692 let address_requests = [
24693 NodeRequest::Resume {
24694 session: address.clone(),
24695 terminal_size: size,
24696 initial_prompt: None,
24697 },
24698 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
24699 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
24700 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
24701 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
24702 NodeRequest::TerminalControl {
24703 session: address.clone(),
24704 control: TerminalControl::Enter,
24705 },
24706 NodeRequest::Resize { session: address.clone(), size },
24707 NodeRequest::Interrupt { session: address.clone() },
24708 NodeRequest::Stop { session: address.clone(), force: false },
24709 NodeRequest::Remove { session: address },
24710 ];
24711 for request in &address_requests {
24712 assert!(request_requires_open_provider_ids_with(
24713 request,
24714 |_| Some(agent("third-party-agent")),
24715 |_| Some(agent("claude")),
24716 ));
24717 assert!(!request_requires_open_provider_ids_with(
24718 request,
24719 |_| Some(agent("claude")),
24720 |_| Some(agent("claude")),
24721 ));
24722 assert!(request_requires_open_provider_ids_with(
24723 request,
24724 |_| None,
24725 |_| Some(agent("claude")),
24726 ));
24727 }
24728
24729 let record_requests = [
24730 NodeRequest::RenameSessionRecord {
24731 record_id: record_id.clone(),
24732 display_name: "renamed".to_owned(),
24733 },
24734 NodeRequest::ResumeSessionRecord {
24735 record_id: record_id.clone(),
24736 terminal_size: size,
24737 initial_prompt: None,
24738 },
24739 NodeRequest::ForgetSessionRecord { record_id },
24740 ];
24741 for request in &record_requests {
24742 assert!(request_requires_open_provider_ids_with(
24743 request,
24744 |_| Some(agent("claude")),
24745 |_| Some(agent("grok")),
24746 ));
24747 assert!(!request_requires_open_provider_ids_with(
24748 request,
24749 |_| Some(agent("claude")),
24750 |_| Some(agent("kimi")),
24751 ));
24752 assert!(request_requires_open_provider_ids_with(
24753 request,
24754 |_| Some(agent("claude")),
24755 |_| None,
24756 ));
24757 }
24758
24759 assert!(request_requires_open_provider_ids_with(
24760 &NodeRequest::Spawn {
24761 workspace_id: WorkspaceId::new("primary").unwrap(),
24762 provider: agent("third-party-agent"),
24763 mode: SessionMode::Pty,
24764 terminal_size: size,
24765 initial_prompt: None,
24766 },
24767 |_| None,
24768 |_| None,
24769 ));
24770 assert!(!request_requires_open_provider_ids_with(
24771 &NodeRequest::Snapshot,
24772 |_| None,
24773 |_| None,
24774 ));
24775 assert!(request_requires_open_provider_ids_with(
24776 &NodeRequest::ForgetContextPack {
24777 context_id: SpawnContextId::new("ctx-forget").unwrap(),
24778 },
24779 |_| None,
24780 |_| None,
24781 ));
24782 }
24783
24784 #[test]
24785 fn legacy_projection_filters_open_record_replies_and_event_history() {
24786 let catalog = active_registry().unwrap();
24787 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24788 let workspace = WorkspaceConfig::new(
24789 WorkspaceId::new("primary").unwrap(),
24790 std::env::current_dir().unwrap(),
24791 )
24792 .unwrap();
24793 let shared = NodeShared::new(
24794 handle,
24795 "fixture-token".to_owned(),
24796 NodeId::new("node-1").unwrap(),
24797 vec![workspace],
24798 vec![agent("claude"), agent("grok")],
24799 );
24800 let legacy = record("claude", "legacy-record");
24801 let open = record("grok", "open-record");
24802 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24803 record: legacy.clone(),
24804 });
24805 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24806 record: open.clone(),
24807 });
24808 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24809 record_id: legacy.record_id.clone(),
24810 });
24811 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24812 record_id: open.record_id.clone(),
24813 });
24814 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
24815 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
24816 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
24817 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
24818
24819 let mut record_reply = ResponseEnvelope {
24820 request_id: 1,
24821 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
24822 };
24823 project_response_legacy_provider_ids(&shared, &mut record_reply);
24824 assert!(matches!(
24825 record_reply.result,
24826 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
24827 ));
24828
24829 let mut resync = ResponseEnvelope {
24830 request_id: 2,
24831 result: Ok(NodeResponse::Resync {
24832 event_sequence: open_removed.sequence,
24833 oldest_available_sequence: 1,
24834 snapshot: shared.snapshot(),
24835 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
24836 }),
24837 };
24838 project_response_legacy_provider_ids(&shared, &mut resync);
24839 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
24840 panic!("legacy projection changed the resync response kind");
24841 };
24842 assert!(snapshot.session_records.iter().all(|item| {
24843 provider_id_is_legacy(&item.provider)
24844 }));
24845 assert_eq!(events.len(), 2);
24846 assert!(events.iter().all(|event| matches!(
24847 &event.event,
24848 NodeEvent::SessionRecordUpserted { record }
24849 if provider_id_is_legacy(&record.provider)
24850 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
24851 }
24852
24853 #[test]
24854 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
24855 let catalog = active_registry().unwrap();
24856 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24857 let workspace_id = WorkspaceId::new("primary").unwrap();
24858 let workspace = WorkspaceConfig::new(
24859 workspace_id.clone(),
24860 std::env::current_dir().unwrap(),
24861 )
24862 .unwrap();
24863 let shared = NodeShared::new(
24864 handle,
24865 "fixture-token".to_owned(),
24866 NodeId::new("node-1").unwrap(),
24867 vec![workspace],
24868 vec![agent("claude")],
24869 );
24870 for index in 0..CONTROL_SESSIONS_MAX {
24871 shared.bind_session(
24872 AgentInstanceId((index + 1) as u64),
24873 workspace_id.clone(),
24874 SessionGeneration(1),
24875 );
24876 }
24877 let error = shared.ensure_binding_capacity().unwrap_err();
24878 assert_eq!(error.code, NodeFailureCode::BackendBusy);
24879 }
24880
24881 #[tokio::test]
24882 async fn provider_spawn_admission_rejects_before_session_mutation() {
24883 let catalog = active_registry().unwrap();
24884 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24885 let workspace = WorkspaceConfig::new(
24886 WorkspaceId::new("primary").unwrap(),
24887 std::env::current_dir().unwrap(),
24888 )
24889 .unwrap();
24890 let mut shared = NodeShared::new(
24891 handle,
24892 "fixture-token".to_owned(),
24893 NodeId::new("node-provider-admission").unwrap(),
24894 vec![workspace],
24895 vec![agent("claude")],
24896 );
24897 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24898 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
24899 ])
24900 .unwrap();
24901 let terminal_size = gate4agent_types::TerminalSize {
24902 rows: 24,
24903 columns: 80,
24904 };
24905
24906 let unavailable = shared
24907 .spawn_session(
24908 WorkspaceId::new("primary").unwrap(),
24909 agent("claude"),
24910 SessionMode::Pty,
24911 terminal_size,
24912 None,
24913 )
24914 .await
24915 .unwrap_err();
24916 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
24917 assert_eq!(unavailable.message, "backend-operation-failed");
24918
24919 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24920 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
24921 agent("claude"),
24922 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
24923 ),
24924 ])
24925 .unwrap();
24926 let semantic = shared
24927 .spawn_session(
24928 WorkspaceId::new("primary").unwrap(),
24929 agent("claude"),
24930 SessionMode::Inline,
24931 terminal_size,
24932 None,
24933 )
24934 .await
24935 .unwrap_err();
24936 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
24937 assert_eq!(semantic.message, "unsupported-capability");
24938
24939 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
24940 assert!(shared
24941 .session_bindings
24942 .lock()
24943 .unwrap_or_else(|poisoned| poisoned.into_inner())
24944 .is_empty());
24945 assert!(shared
24946 .session_records
24947 .lock()
24948 .unwrap_or_else(|poisoned| poisoned.into_inner())
24949 .records
24950 .is_empty());
24951 assert!(shared.handle.snapshot().sessions.is_empty());
24952 }
24953
24954 #[tokio::test]
24955 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
24956 let catalog = active_registry().unwrap();
24957 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24958 let workspace_id = WorkspaceId::new("primary").unwrap();
24959 let workspace = WorkspaceConfig::new(
24960 workspace_id.clone(),
24961 std::env::current_dir().unwrap(),
24962 )
24963 .unwrap();
24964 let shared = NodeShared::new(
24965 handle,
24966 "fixture-token".to_owned(),
24967 NodeId::new("node-raw-spawn-record").unwrap(),
24968 vec![workspace],
24969 vec![agent("claude")],
24970 );
24971 let address = SessionAddress {
24972 workspace_id,
24973 session: SessionKey {
24974 instance_id: AgentInstanceId(71),
24975 generation: SessionGeneration::default(),
24976 },
24977 };
24978
24979 let record_id = shared
24980 .bind_spawn_session(
24981 &address,
24982 agent("claude"),
24983 SessionMode::Pty,
24984 ProviderRuntimePolicy::raw_pty(),
24985 SpawnRecordPolicy::Always,
24986 None,
24987 )
24988 .unwrap()
24989 .unwrap();
24990 shared
24991 .handle
24992 .dispatch(shared.prepare_command(ControlCommand::Register {
24993 instance_id: address.session.instance_id,
24994 agent_id: agent("claude"),
24995 transport: TransportKind::Pty,
24996 }))
24997 .unwrap();
24998 runtime.tick().await;
24999
25000 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25001 session.instance_id == address.session.instance_id
25002 && session.generation == address.session.generation
25003 }));
25004 let record = shared.record(&record_id).unwrap();
25005 assert_eq!(record.display_name, "claude #1");
25006 assert_eq!(record.state, ManagedSessionState::Live);
25007 assert_eq!(record.active_session.as_ref(), Some(&address));
25008 assert!(record.provider_session.is_none());
25009 let binding_record_id = shared
25010 .session_bindings
25011 .lock()
25012 .unwrap_or_else(|poisoned| poisoned.into_inner())
25013 .get(&address.session.instance_id)
25014 .and_then(|binding| binding.record_id.clone());
25015 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25016
25017 let renamed = shared
25018 .rename_session_record(&record_id, "raw review".to_owned())
25019 .unwrap();
25020 assert_eq!(renamed.record_id, record_id);
25021 assert_eq!(renamed.display_name, "raw review");
25022 shared.reconcile_managed_record(
25023 &record_id,
25024 &address,
25025 &ControlEvent {
25026 sequence: 1,
25027 command_id: None,
25028 instance_id: address.session.instance_id,
25029 generation: address.session.generation,
25030 event: ControlEventKind::Running { process_id: None },
25031 },
25032 false,
25033 false,
25034 );
25035 let running = shared.record(&record_id).unwrap();
25036 assert_eq!(running.state, ManagedSessionState::Live);
25037 assert_eq!(running.active_session.as_ref(), Some(&address));
25038 assert_eq!(running.display_name, "raw review");
25039
25040 shared.reconcile_managed_record(
25041 &record_id,
25042 &address,
25043 &ControlEvent {
25044 sequence: 2,
25045 command_id: None,
25046 instance_id: address.session.instance_id,
25047 generation: address.session.generation,
25048 event: ControlEventKind::Exited {
25049 exit_code: Some(0),
25050 forced: false,
25051 },
25052 },
25053 false,
25054 false,
25055 );
25056 let detached = shared.record(&record_id).unwrap();
25057 assert_eq!(detached.state, ManagedSessionState::Unavailable);
25058 assert!(detached.active_session.is_none());
25059 assert_eq!(detached.display_name, "raw review");
25060 let renamed_detached = shared
25061 .rename_session_record(&record_id, "raw review detached".to_owned())
25062 .unwrap();
25063 assert_eq!(renamed_detached.record_id, record_id);
25064 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25065 assert!(renamed_detached.active_session.is_none());
25066 assert_eq!(renamed_detached.display_name, "raw review detached");
25067 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25068 }
25069
25070 #[test]
25071 fn legacy_raw_spawn_binding_remains_untracked() {
25072 let catalog = active_registry().unwrap();
25073 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25074 let workspace_id = WorkspaceId::new("primary").unwrap();
25075 let workspace = WorkspaceConfig::new(
25076 workspace_id.clone(),
25077 std::env::current_dir().unwrap(),
25078 )
25079 .unwrap();
25080 let shared = NodeShared::new(
25081 handle,
25082 "fixture-token".to_owned(),
25083 NodeId::new("node-legacy-raw-spawn").unwrap(),
25084 vec![workspace],
25085 vec![agent("claude")],
25086 );
25087 let address = SessionAddress {
25088 workspace_id,
25089 session: SessionKey {
25090 instance_id: AgentInstanceId(72),
25091 generation: SessionGeneration::default(),
25092 },
25093 };
25094
25095 let record_id = shared
25096 .bind_spawn_session(
25097 &address,
25098 agent("claude"),
25099 SessionMode::Pty,
25100 ProviderRuntimePolicy::raw_pty(),
25101 SpawnRecordPolicy::ProviderIdentityOnly,
25102 None,
25103 )
25104 .unwrap();
25105
25106 assert!(record_id.is_none());
25107 let binding_record_id = shared
25108 .session_bindings
25109 .lock()
25110 .unwrap_or_else(|poisoned| poisoned.into_inner())
25111 .get(&address.session.instance_id)
25112 .and_then(|binding| binding.record_id.clone());
25113 assert!(binding_record_id.is_none());
25114 assert!(shared.snapshot().session_records.is_empty());
25115 }
25116
25117 #[tokio::test]
25118 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25119 let catalog = active_registry().unwrap();
25120 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25121 let workspace_id = WorkspaceId::new("primary").unwrap();
25122 let workspace = WorkspaceConfig::new(
25123 workspace_id.clone(),
25124 std::env::current_dir().unwrap(),
25125 )
25126 .unwrap();
25127 let mut shared = NodeShared::new(
25128 handle,
25129 "fixture-token".to_owned(),
25130 NodeId::new("node-raw-semantic-gate").unwrap(),
25131 vec![workspace],
25132 vec![agent("claude")],
25133 );
25134 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25135 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25136 ])
25137 .unwrap();
25138 shared
25139 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25140 .unwrap();
25141 let address = SessionAddress {
25142 workspace_id,
25143 session: SessionKey {
25144 instance_id: AgentInstanceId(72),
25145 generation: SessionGeneration::default(),
25146 },
25147 };
25148 shared.bind_session_with_policy(
25149 &address,
25150 ProviderRuntimePolicy::raw_pty(),
25151 None,
25152 );
25153 shared
25154 .handle
25155 .dispatch(shared.prepare_command(ControlCommand::Register {
25156 instance_id: address.session.instance_id,
25157 agent_id: agent("claude"),
25158 transport: TransportKind::Pty,
25159 }))
25160 .unwrap();
25161 runtime.tick().await;
25162 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25163 let revision_before = shared.handle.snapshot().revision;
25164 let history_sequence_before = shared.current_sequence();
25165 let terminal_size = gate4agent_types::TerminalSize {
25166 rows: 24,
25167 columns: 80,
25168 };
25169
25170 for request in [
25171 NodeRequest::Prompt {
25172 session: address.clone(),
25173 text: "prompt".to_owned(),
25174 },
25175 NodeRequest::Paste {
25176 session: address.clone(),
25177 text: "paste".to_owned(),
25178 },
25179 NodeRequest::Resume {
25185 session: address.clone(),
25186 terminal_size,
25187 initial_prompt: Some("resume".to_owned()),
25188 },
25189 ] {
25190 let error = process_request_inner(
25191 &shared,
25192 77,
25193 ClientRole::Operator,
25194 request,
25195 )
25196 .await
25197 .unwrap_err();
25198 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25199 }
25200
25201 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25202 assert_eq!(shared.handle.snapshot().revision, revision_before);
25203 assert_eq!(shared.current_sequence(), history_sequence_before);
25204 let bindings = shared
25205 .session_bindings
25206 .lock()
25207 .unwrap_or_else(|poisoned| poisoned.into_inner());
25208 let binding = bindings.get(&address.session.instance_id).unwrap();
25209 assert_eq!(binding.generation, address.session.generation);
25210 assert!(binding.pending_resume.is_none());
25211 assert!(binding.record_id.is_none());
25212 }
25213
25214 #[tokio::test]
25215 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25216 let catalog = active_registry().unwrap();
25217 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25218 let primary = WorkspaceConfig::new(
25219 WorkspaceId::new("primary").unwrap(),
25220 std::env::current_dir().unwrap(),
25221 )
25222 .unwrap();
25223 let shared = NodeShared::new(
25224 handle,
25225 "fixture-token".to_owned(),
25226 NodeId::new("node-1").unwrap(),
25227 vec![primary],
25228 vec![agent("claude")],
25229 );
25230 shared
25231 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25232 .unwrap();
25233 let before = shared.snapshot();
25234 let error = process_request_inner(
25235 &shared,
25236 77,
25237 ClientRole::Operator,
25238 NodeRequest::RegisterWorkspace {
25239 workspace_id: WorkspaceId::new("foreign").unwrap(),
25240 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25241 },
25242 )
25243 .await
25244 .unwrap_err();
25245 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25246 assert_eq!(error.message, "invalid-request");
25247 assert_eq!(shared.snapshot().workspaces, before.workspaces);
25248 }
25249
25250 #[tokio::test]
25251 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25252 let catalog = active_registry().unwrap();
25253 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25254 let primary_id = WorkspaceId::new("primary").unwrap();
25255 let primary = WorkspaceConfig::new(
25256 primary_id.clone(),
25257 std::env::current_dir().unwrap(),
25258 )
25259 .unwrap();
25260 let shared = NodeShared::new(
25261 handle,
25262 "fixture-token".to_owned(),
25263 NodeId::new("node-1").unwrap(),
25264 vec![primary.clone()],
25265 vec![agent("claude")],
25266 );
25267 let secondary_id = WorkspaceId::new("secondary").unwrap();
25268 let secondary = shared
25269 .register_workspace(
25270 secondary_id.clone(),
25271 std::env::temp_dir().to_string_lossy().into_owned(),
25272 )
25273 .await
25274 .unwrap();
25275 assert_eq!(secondary.workspace_id, secondary_id);
25276 assert!(secondary.sessions.is_empty());
25277 assert_eq!(shared.snapshot().workspaces.len(), 2);
25278
25279 let duplicate_id = shared
25280 .register_workspace(
25281 secondary_id.clone(),
25282 primary.canonical_root().to_owned(),
25283 )
25284 .await
25285 .unwrap_err();
25286 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25287 let duplicate_root = shared
25288 .register_workspace(
25289 WorkspaceId::new("duplicate-root").unwrap(),
25290 windows_path_text(&secondary.canonical_root).to_owned(),
25291 )
25292 .await
25293 .unwrap_err();
25294 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25295
25296 let address = SessionAddress {
25297 workspace_id: secondary_id.clone(),
25298 session: SessionKey {
25299 instance_id: AgentInstanceId(41),
25300 generation: SessionGeneration(1),
25301 },
25302 };
25303 shared.bind_session(
25304 address.session.instance_id,
25305 secondary_id.clone(),
25306 address.session.generation,
25307 );
25308 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25309 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25310 shared.remove_binding(&address);
25311 shared.unregister_workspace(&secondary_id).unwrap();
25312 assert_eq!(shared.snapshot().workspaces.len(), 1);
25313
25314 let last = shared.unregister_workspace(&primary_id).unwrap_err();
25315 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25316 let history = shared
25317 .history
25318 .lock()
25319 .unwrap_or_else(|poisoned| poisoned.into_inner());
25320 assert!(matches!(
25321 &history.events[0],
25322 NodeEventEnvelope {
25323 sequence: 1,
25324 event: NodeEvent::WorkspaceAdded { workspace },
25325 } if workspace.workspace_id == secondary_id
25326 ));
25327 assert!(matches!(
25328 &history.events[1],
25329 NodeEventEnvelope {
25330 sequence: 2,
25331 event: NodeEvent::WorkspaceRemoved { workspace_id },
25332 } if workspace_id == &secondary_id
25333 ));
25334 }
25335
25336 #[tokio::test]
25337 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25338 let secondary_root = temporary_workspace_root("provider-session-reference-index");
25339 std::fs::create_dir_all(&secondary_root).unwrap();
25340 let catalog = active_registry().unwrap();
25341 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25342 let primary_id = WorkspaceId::new("primary").unwrap();
25343 let secondary_id = WorkspaceId::new("secondary").unwrap();
25344 let shared = NodeShared::new(
25345 handle,
25346 "fixture-token".to_owned(),
25347 NodeId::new("node-session-reference-index").unwrap(),
25348 vec![
25349 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25350 .unwrap(),
25351 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25352 ],
25353 vec![agent("claude"), agent("codex")],
25354 );
25355 let identity = ProviderSessionIdentity {
25356 key: gate4agent_types::ProviderSessionKey::SessionId,
25357 id: "provider-session-42".to_owned(),
25358 transcript_path: None,
25359 };
25360 let index_request = |workspace_id: WorkspaceId,
25361 provider: AgentId,
25362 identity: ProviderSessionIdentity,
25363 display_name: &str| {
25364 NodeRequest::IndexProviderSession {
25365 workspace_id,
25366 provider,
25367 identity,
25368 display_name: display_name.to_owned(),
25369 }
25370 };
25371 let request = index_request(
25372 primary_id.clone(),
25373 agent("claude"),
25374 identity.clone(),
25375 "release shepherd",
25376 );
25377 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25378 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25379 }));
25380 let observer = process_request_inner(
25381 &shared,
25382 77,
25383 ClientRole::Observer,
25384 request.clone(),
25385 )
25386 .await
25387 .unwrap_err();
25388 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25389 let uncontrolled = process_request_inner(
25390 &shared,
25391 77,
25392 ClientRole::Operator,
25393 request.clone(),
25394 )
25395 .await
25396 .unwrap_err();
25397 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25398 shared
25399 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25400 .unwrap();
25401
25402 let indexed = process_request_inner(
25403 &shared,
25404 77,
25405 ClientRole::Operator,
25406 request.clone(),
25407 )
25408 .await
25409 .unwrap();
25410 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25411 panic!("provider session reference index returned another response");
25412 };
25413 assert_eq!(record.state, ManagedSessionState::Dormant);
25414 assert_eq!(record.mode, SessionMode::Pty);
25415 assert_eq!(record.workspace_id, primary_id);
25416 assert_eq!(record.provider_session.as_ref(), Some(&identity));
25417 assert!(record.active_session.is_none());
25418 assert!(record.environment_profile.is_none());
25419 assert!(record.bundle.is_none());
25420 assert!(record.context.is_none());
25421
25422 let repeated = process_request_inner(
25423 &shared,
25424 77,
25425 ClientRole::Operator,
25426 index_request(
25427 primary_id.clone(),
25428 agent("claude"),
25429 identity.clone(),
25430 "ignored replacement",
25431 ),
25432 )
25433 .await
25434 .unwrap();
25435 assert!(matches!(
25436 repeated,
25437 NodeResponse::ProviderSessionIndexed { record: ref existing }
25438 if existing.record_id == record.record_id
25439 && existing.display_name == "release shepherd"
25440 ));
25441 let conflicting_workspace = process_request_inner(
25442 &shared,
25443 77,
25444 ClientRole::Operator,
25445 index_request(
25446 secondary_id,
25447 agent("claude"),
25448 identity.clone(),
25449 "release shepherd",
25450 ),
25451 )
25452 .await
25453 .unwrap_err();
25454 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25455 let conflicting_provider = process_request_inner(
25456 &shared,
25457 77,
25458 ClientRole::Operator,
25459 index_request(
25460 primary_id.clone(),
25461 agent("codex"),
25462 identity.clone(),
25463 "release shepherd",
25464 ),
25465 )
25466 .await
25467 .unwrap_err();
25468 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25469 let transcript_rejected = process_request_inner(
25470 &shared,
25471 77,
25472 ClientRole::Operator,
25473 index_request(
25474 primary_id,
25475 agent("claude"),
25476 ProviderSessionIdentity {
25477 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25478 ..identity
25479 },
25480 "release shepherd",
25481 ),
25482 )
25483 .await
25484 .unwrap_err();
25485 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25486 let upserts = shared
25487 .history
25488 .lock()
25489 .unwrap_or_else(|poisoned| poisoned.into_inner())
25490 .events
25491 .iter()
25492 .filter(|event| matches!(
25493 &event.event,
25494 NodeEvent::SessionRecordUpserted { record: published }
25495 if published.record_id == record.record_id
25496 ))
25497 .count();
25498 assert_eq!(upserts, 1);
25499 std::fs::remove_dir_all(secondary_root).unwrap();
25500 }
25501
25502 #[tokio::test]
25503 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25504 let fixture_root = temporary_workspace_root("standalone-independent");
25505 let target = fixture_root.join("independent");
25506 std::fs::create_dir_all(&fixture_root).unwrap();
25507 let catalog = active_registry().unwrap();
25508 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25509 let selected = WorkspaceConfig::new(
25510 WorkspaceId::new("primary").unwrap(),
25511 std::env::current_dir().unwrap(),
25512 )
25513 .unwrap();
25514 let selected_root = selected.canonical_root().to_owned();
25515 let shared = NodeShared::new(
25516 handle,
25517 "fixture-token".to_owned(),
25518 NodeId::new("node-standalone").unwrap(),
25519 vec![selected],
25520 vec![agent("claude")],
25521 );
25522 let workspace_id = WorkspaceId::new("independent").unwrap();
25523 let request = NodeRequest::CreateStandaloneWorkspace {
25524 workspace_id: workspace_id.clone(),
25525 root: opaque_windows_path(target.to_string_lossy().into_owned()),
25526 initial_branch: Some("main".to_owned()),
25527 };
25528 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25529 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25530 }));
25531 assert!(!request_uses_unnegotiated_capability(
25532 &request,
25533 &baseline_capabilities().unwrap(),
25534 ));
25535 assert!(request_uses_unnegotiated_capability(&request, &[]));
25536
25537 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25538 .await
25539 .unwrap_err();
25540 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25541 assert!(!target.exists());
25542 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25543 .await
25544 .unwrap_err();
25545 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25546 assert!(!target.exists());
25547 shared
25548 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25549 .unwrap();
25550
25551 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25552 .await
25553 .unwrap();
25554 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25555 panic!("standalone workspace creation returned another response");
25556 };
25557 assert_eq!(workspace.workspace_id, workspace_id);
25558 assert!(target.join(".git").is_dir());
25559 let branch = run_git_bounded(
25560 target.to_str().unwrap(),
25561 &["branch", "--show-current"],
25562 GIT_OUTPUT_MAX_BYTES,
25563 )
25564 .await
25565 .unwrap();
25566 assert!(branch.success);
25567 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25568 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25569 assert_eq!(worktrees.len(), 1);
25570 let listed_root = WorkspaceConfig::new(
25571 WorkspaceId::new("listed-independent").unwrap(),
25572 &worktrees[0].path,
25573 )
25574 .unwrap();
25575 assert!(platform::roots_equal(
25576 listed_root.canonical_root(),
25577 windows_path_text(&workspace.canonical_root),
25578 ));
25579 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25580 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25581 snapshot.workspace_id == workspace_id
25582 && snapshot.canonical_root == workspace.canonical_root
25583 }));
25584 assert!(shared
25585 .history
25586 .lock()
25587 .unwrap_or_else(|poisoned| poisoned.into_inner())
25588 .events
25589 .iter()
25590 .any(|event| matches!(
25591 &event.event,
25592 NodeEvent::WorkspaceAdded { workspace }
25593 if workspace.workspace_id == workspace_id
25594 )));
25595 std::fs::remove_dir_all(fixture_root).unwrap();
25596 }
25597
25598 #[tokio::test]
25599 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25600 let fixture_root = temporary_workspace_root("standalone-compensation");
25601 let created_target = fixture_root.join("created-target");
25602 let existing_target = fixture_root.join("existing-target");
25603 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25604 let invalid_state_path = invalid_state_parent.join("state.json");
25605 std::fs::create_dir_all(&existing_target).unwrap();
25606 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25607 let catalog = active_registry().unwrap();
25608 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25609 let primary = WorkspaceConfig::new(
25610 WorkspaceId::new("primary").unwrap(),
25611 std::env::current_dir().unwrap(),
25612 )
25613 .unwrap();
25614 let mut shared = NodeShared::new(
25615 handle,
25616 "fixture-token".to_owned(),
25617 NodeId::new("node-standalone-compensation").unwrap(),
25618 vec![primary],
25619 vec![agent("claude")],
25620 );
25621 shared.state_path = Some(invalid_state_path);
25622
25623 let created_recovery = shared
25624 .create_standalone_workspace(
25625 WorkspaceId::new("compensated").unwrap(),
25626 created_target.to_string_lossy().into_owned(),
25627 Some("main".to_owned()),
25628 )
25629 .await
25630 .unwrap_err();
25631 assert_eq!(
25632 created_recovery.code,
25633 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25634 );
25635 assert!(created_target.join(".git").is_dir());
25636 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25637 workspace.workspace_id.as_str() != "compensated"
25638 }));
25639
25640 let existing_recovery = shared
25641 .create_standalone_workspace(
25642 WorkspaceId::new("restored").unwrap(),
25643 existing_target.to_string_lossy().into_owned(),
25644 None,
25645 )
25646 .await
25647 .unwrap_err();
25648 assert_eq!(
25649 existing_recovery.code,
25650 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25651 );
25652 assert!(existing_target.is_dir());
25653 assert!(existing_target.join(".git").is_dir());
25654 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25655 workspace.workspace_id.as_str() != "restored"
25656 }));
25657
25658 let registered_empty = fixture_root.join("registered-empty");
25659 std::fs::create_dir(®istered_empty).unwrap();
25660 let catalog = active_registry().unwrap();
25661 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25662 let registered = WorkspaceConfig::new(
25663 WorkspaceId::new("registered-empty").unwrap(),
25664 ®istered_empty,
25665 )
25666 .unwrap();
25667 let duplicate_shared = NodeShared::new(
25668 handle,
25669 "fixture-token".to_owned(),
25670 NodeId::new("node-standalone-duplicate-root").unwrap(),
25671 vec![registered],
25672 vec![agent("claude")],
25673 );
25674 let duplicate = duplicate_shared
25675 .create_standalone_workspace(
25676 WorkspaceId::new("duplicate-root").unwrap(),
25677 registered_empty.to_string_lossy().into_owned(),
25678 None,
25679 )
25680 .await
25681 .unwrap_err();
25682 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
25683 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
25684 std::fs::remove_dir_all(fixture_root).unwrap();
25685 }
25686
25687 #[test]
25688 fn managed_record_id_collision_preserves_the_existing_record() {
25689 let catalog = active_registry().unwrap();
25690 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25691 let workspace = WorkspaceConfig::new(
25692 WorkspaceId::new("primary").unwrap(),
25693 std::env::current_dir().unwrap(),
25694 )
25695 .unwrap();
25696 let shared = NodeShared::new(
25697 handle,
25698 "fixture-token".to_owned(),
25699 NodeId::new("node-1").unwrap(),
25700 vec![workspace.clone()],
25701 vec![agent("claude")],
25702 );
25703 let record_id = SessionRecordId::new("sr-collision").unwrap();
25704 let original = ManagedSessionRecord {
25705 record_id: record_id.clone(),
25706 display_name: "original".to_owned(),
25707 provider: agent("claude"),
25708 mode: SessionMode::Pty,
25709 state: ManagedSessionState::Dormant,
25710 workspace_id: workspace.workspace_id,
25711 canonical_root: opaque_windows_path(workspace.canonical_root),
25712 provider_session: None,
25713 active_session: None,
25714 environment_profile: None,
25715 bundle: None,
25716 context_id: None,
25717 context: None,
25718 exported_context: None,
25719 task_binding: None,
25720 created_at_unix_ms: 1,
25721 updated_at_unix_ms: 1,
25722 last_error: None,
25723 };
25724 shared.insert_record(original.clone()).unwrap();
25725 let mut collision = original.clone();
25726 collision.display_name = "replacement".to_owned();
25727 let error = shared.insert_record(collision).unwrap_err();
25728 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
25729 assert_eq!(shared.record(&record_id).unwrap(), original);
25730 }
25731
25732 #[test]
25733 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
25734 let catalog = active_registry().unwrap();
25735 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25736 let workspace = WorkspaceConfig::new(
25737 WorkspaceId::new("primary").unwrap(),
25738 std::env::current_dir().unwrap(),
25739 )
25740 .unwrap();
25741 let shared = NodeShared::new(
25742 handle,
25743 "fixture-token".to_owned(),
25744 NodeId::new("node-1").unwrap(),
25745 vec![workspace.clone()],
25746 vec![agent("claude")],
25747 );
25748 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
25749 shared
25750 .insert_record(ManagedSessionRecord {
25751 record_id: record_id.clone(),
25752 display_name: "runtime error".to_owned(),
25753 provider: agent("claude"),
25754 mode: SessionMode::Pty,
25755 state: ManagedSessionState::IdentityPending,
25756 workspace_id: workspace.workspace_id,
25757 canonical_root: opaque_windows_path(workspace.canonical_root),
25758 provider_session: None,
25759 active_session: None,
25760 environment_profile: None,
25761 bundle: None,
25762 context_id: None,
25763 context: None,
25764 exported_context: None,
25765 task_binding: None,
25766 created_at_unix_ms: 1,
25767 updated_at_unix_ms: 1,
25768 last_error: None,
25769 })
25770 .unwrap();
25771
25772 shared
25773 .mark_record_error(
25774 &record_id,
25775 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
25776 )
25777 .unwrap();
25778 let record = shared.record(&record_id).unwrap();
25779 assert_eq!(record.state, ManagedSessionState::Unavailable);
25780 assert_eq!(
25781 record.last_error.as_deref(),
25782 Some("provider-runtime-failed"),
25783 );
25784 let snapshot = shared.snapshot();
25785 assert_eq!(snapshot.session_records, vec![record]);
25786 }
25787
25788 #[test]
25797 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
25798 let catalog = active_registry().unwrap();
25799 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25800 let workspace = WorkspaceConfig::new(
25801 WorkspaceId::new("primary").unwrap(),
25802 std::env::current_dir().unwrap(),
25803 )
25804 .unwrap();
25805 let shared = NodeShared::new(
25806 handle,
25807 "fixture-token".to_owned(),
25808 NodeId::new("node-1").unwrap(),
25809 vec![workspace],
25810 vec![agent("claude")],
25811 );
25812
25813 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25814
25815 shared.record_rejected_command(
25816 CommandId(1),
25817 "agent 'claude' does not support transport Acp".to_owned(),
25818 true,
25819 );
25820 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
25822
25823 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
25824 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
25825 assert!(reason.unsupported_transport);
25826
25827 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25829
25830 shared.record_rejected_command(
25835 CommandId(3),
25836 "backend busy".to_owned(),
25837 false,
25838 );
25839 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
25840 assert!(!unclassified.unsupported_transport);
25841 }
25842
25843 #[test]
25844 fn provider_identity_update_is_ignored_without_exact_session_policy() {
25845 let catalog = active_registry().unwrap();
25846 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25847 let workspace = WorkspaceConfig::new(
25848 WorkspaceId::new("primary").unwrap(),
25849 std::env::current_dir().unwrap(),
25850 )
25851 .unwrap();
25852 let shared = NodeShared::new(
25853 handle,
25854 "fixture-token".to_owned(),
25855 NodeId::new("node-identity-policy").unwrap(),
25856 vec![workspace.clone()],
25857 vec![agent("claude")],
25858 );
25859 let address = SessionAddress {
25860 workspace_id: workspace.workspace_id.clone(),
25861 session: SessionKey {
25862 instance_id: AgentInstanceId(90),
25863 generation: SessionGeneration(1),
25864 },
25865 };
25866 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
25867 shared
25868 .insert_record(ManagedSessionRecord {
25869 record_id: record_id.clone(),
25870 display_name: "policy denied".to_owned(),
25871 provider: agent("claude"),
25872 mode: SessionMode::Pty,
25873 state: ManagedSessionState::IdentityPending,
25874 workspace_id: workspace.workspace_id,
25875 canonical_root: opaque_windows_path(workspace.canonical_root),
25876 provider_session: None,
25877 active_session: Some(address.clone()),
25878 environment_profile: None,
25879 bundle: None,
25880 context_id: None,
25881 context: None,
25882 exported_context: None,
25883 task_binding: None,
25884 created_at_unix_ms: 1,
25885 updated_at_unix_ms: 1,
25886 last_error: None,
25887 })
25888 .unwrap();
25889 let event = ControlEvent {
25890 sequence: 1,
25891 command_id: None,
25892 instance_id: address.session.instance_id,
25893 generation: address.session.generation,
25894 event: ControlEventKind::ProviderEvent {
25895 sequence: 1,
25896 source: gate4agent_types::ProviderSource {
25897 family: AdapterFamily::PtySemantic,
25898 binding: AdapterBinding::new(
25899 gate4agent_types::AdapterId::new("claude").unwrap(),
25900 "fixture/v1",
25901 gate4agent_types::AdapterVerification::SyntheticFixture,
25902 )
25903 .unwrap(),
25904 },
25905 source_sequence: 1,
25906 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25907 identity: gate4agent_types::ProviderSessionIdentity {
25908 key: gate4agent_types::ProviderSessionKey::SessionId,
25909 id: "provider-session".to_owned(),
25910 transcript_path: None,
25911 },
25912 },
25913 },
25914 };
25915
25916 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
25917
25918 let record = shared.record(&record_id).unwrap();
25919 assert!(record.provider_session.is_none());
25920 assert_eq!(record.state, ManagedSessionState::IdentityPending);
25921 assert_eq!(record.active_session.as_ref(), Some(&address));
25922 }
25923
25924 #[test]
25925 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
25926 let secondary_root = temporary_workspace_root("identity-scope");
25927 std::fs::create_dir_all(&secondary_root).unwrap();
25928 let catalog = active_registry().unwrap();
25929 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25930 let primary = WorkspaceConfig::new(
25931 WorkspaceId::new("primary").unwrap(),
25932 std::env::current_dir().unwrap(),
25933 )
25934 .unwrap();
25935 let secondary = WorkspaceConfig::new(
25936 WorkspaceId::new("secondary").unwrap(),
25937 &secondary_root,
25938 )
25939 .unwrap();
25940 let shared = NodeShared::new(
25941 handle,
25942 "fixture-token".to_owned(),
25943 NodeId::new("node-1").unwrap(),
25944 vec![primary.clone(), secondary.clone()],
25945 vec![agent("claude")],
25946 );
25947 let identity = gate4agent_types::ProviderSessionIdentity {
25948 key: gate4agent_types::ProviderSessionKey::SessionId,
25949 id: "provider-session".to_owned(),
25950 transcript_path: None,
25951 };
25952 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
25953 shared
25954 .insert_record(ManagedSessionRecord {
25955 record_id: dormant_id.clone(),
25956 display_name: "dormant".to_owned(),
25957 provider: agent("claude"),
25958 mode: SessionMode::Pty,
25959 state: ManagedSessionState::Dormant,
25960 workspace_id: primary.workspace_id,
25961 canonical_root: opaque_windows_path(primary.canonical_root),
25962 provider_session: Some(identity.clone()),
25963 active_session: None,
25964 environment_profile: None,
25965 bundle: None,
25966 context_id: None,
25967 context: None,
25968 exported_context: None,
25969 task_binding: None,
25970 created_at_unix_ms: 1,
25971 updated_at_unix_ms: 1,
25972 last_error: None,
25973 })
25974 .unwrap();
25975 let address = SessionAddress {
25976 workspace_id: secondary.workspace_id.clone(),
25977 session: SessionKey {
25978 instance_id: AgentInstanceId(91),
25979 generation: SessionGeneration(1),
25980 },
25981 };
25982 let current_id = SessionRecordId::new("sr-current").unwrap();
25983 shared
25984 .insert_record(ManagedSessionRecord {
25985 record_id: current_id.clone(),
25986 display_name: "current".to_owned(),
25987 provider: agent("claude"),
25988 mode: SessionMode::Pty,
25989 state: ManagedSessionState::IdentityPending,
25990 workspace_id: secondary.workspace_id,
25991 canonical_root: opaque_windows_path(secondary.canonical_root),
25992 provider_session: None,
25993 active_session: Some(address.clone()),
25994 environment_profile: None,
25995 bundle: None,
25996 context_id: None,
25997 context: None,
25998 exported_context: None,
25999 task_binding: None,
26000 created_at_unix_ms: 2,
26001 updated_at_unix_ms: 2,
26002 last_error: None,
26003 })
26004 .unwrap();
26005 shared.bind_managed_session(
26006 &address,
26007 current_id.clone(),
26008 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26009 None,
26010 );
26011 let event = ControlEvent {
26012 sequence: 1,
26013 command_id: None,
26014 instance_id: address.session.instance_id,
26015 generation: address.session.generation,
26016 event: ControlEventKind::ProviderEvent {
26017 sequence: 1,
26018 source: gate4agent_types::ProviderSource {
26019 family: gate4agent_types::AdapterFamily::PtySemantic,
26020 binding: gate4agent_types::AdapterBinding::new(
26021 gate4agent_types::AdapterId::new("claude").unwrap(),
26022 "fixture/v1",
26023 gate4agent_types::AdapterVerification::SyntheticFixture,
26024 )
26025 .unwrap(),
26026 },
26027 source_sequence: 1,
26028 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26029 identity,
26030 },
26031 },
26032 };
26033 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
26034
26035 let binding = shared
26036 .session_bindings
26037 .lock()
26038 .unwrap_or_else(|poisoned| poisoned.into_inner())
26039 .get(&address.session.instance_id)
26040 .cloned()
26041 .unwrap();
26042 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
26043 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26044 let current = shared.record(¤t_id).unwrap();
26045 assert_eq!(current.state, ManagedSessionState::Unavailable);
26046 assert_eq!(current.active_session.as_ref(), Some(&address));
26047 assert_eq!(
26048 current.last_error.as_deref(),
26049 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26050 );
26051 std::fs::remove_dir_all(secondary_root).unwrap();
26052 }
26053
26054 #[tokio::test]
26055 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26056 let root = temporary_workspace_root("semantic-provider-session");
26057 std::fs::create_dir_all(&root).unwrap();
26058 let state_path = root.join("state-v1.json");
26059 let catalog = active_registry().unwrap();
26060 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26061 let workspace = WorkspaceConfig::new(
26062 WorkspaceId::new("primary").unwrap(),
26063 &root,
26064 )
26065 .unwrap();
26066 let node_id = NodeId::new("node-semantic-identity").unwrap();
26067 let shared = NodeShared::new_with_incarnation(
26068 handle,
26069 "fixture-token".to_owned(),
26070 node_id.clone(),
26071 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26072 vec![workspace.clone()],
26073 vec![agent("claude")],
26074 ProviderRuntimeStatuses::default(),
26075 None,
26076 Vec::new(),
26077 Vec::new(),
26078 SpawnProfileRegistry::default(),
26079 None,
26080 None,
26081 None,
26082 Some(state_path.clone()),
26083 Vec::new(),
26084 Vec::new(),
26085 Vec::new(),
26086 Vec::new(),
26087 None,
26088 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26089 );
26090 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26091 key: gate4agent_types::ProviderSessionKey::SessionId,
26092 id: "provider-session".to_owned(),
26093 transcript_path: None,
26094 };
26095 let durable_id = SessionRecordId::new("sr-durable").unwrap();
26096 shared
26097 .insert_record(ManagedSessionRecord {
26098 record_id: durable_id.clone(),
26099 display_name: "durable".to_owned(),
26100 provider: agent("claude"),
26101 mode: SessionMode::Pty,
26102 state: ManagedSessionState::Dormant,
26103 workspace_id: workspace.workspace_id.clone(),
26104 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26105 provider_session: Some(persisted_identity.clone()),
26106 active_session: None,
26107 environment_profile: None,
26108 bundle: None,
26109 context_id: None,
26110 context: None,
26111 exported_context: None,
26112 task_binding: None,
26113 created_at_unix_ms: 1,
26114 updated_at_unix_ms: 1,
26115 last_error: None,
26116 })
26117 .unwrap();
26118 let address = SessionAddress {
26119 workspace_id: workspace.workspace_id.clone(),
26120 session: SessionKey {
26121 instance_id: AgentInstanceId(92),
26122 generation: SessionGeneration(2),
26123 },
26124 };
26125 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26126 shared
26127 .insert_record(ManagedSessionRecord {
26128 record_id: pending_id.clone(),
26129 display_name: "pending".to_owned(),
26130 provider: agent("claude"),
26131 mode: SessionMode::Pty,
26132 state: ManagedSessionState::IdentityPending,
26133 workspace_id: workspace.workspace_id.clone(),
26134 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26135 provider_session: None,
26136 active_session: Some(address.clone()),
26137 environment_profile: None,
26138 bundle: None,
26139 context_id: None,
26140 context: None,
26141 exported_context: None,
26142 task_binding: None,
26143 created_at_unix_ms: 2,
26144 updated_at_unix_ms: 2,
26145 last_error: None,
26146 })
26147 .unwrap();
26148 shared.bind_managed_session(
26149 &address,
26150 pending_id.clone(),
26151 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26152 None,
26153 );
26154 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26155 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26156 ..persisted_identity
26157 };
26158 let event = ControlEvent {
26159 sequence: 1,
26160 command_id: None,
26161 instance_id: address.session.instance_id,
26162 generation: address.session.generation,
26163 event: ControlEventKind::ProviderEvent {
26164 sequence: 1,
26165 source: gate4agent_types::ProviderSource {
26166 family: gate4agent_types::AdapterFamily::PtySemantic,
26167 binding: gate4agent_types::AdapterBinding::new(
26168 gate4agent_types::AdapterId::new("claude").unwrap(),
26169 "fixture/v1",
26170 gate4agent_types::AdapterVerification::SyntheticFixture,
26171 )
26172 .unwrap(),
26173 },
26174 source_sequence: 1,
26175 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26176 identity: observed_identity.clone(),
26177 },
26178 },
26179 };
26180 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26181
26182 let snapshot = shared.snapshot();
26183 assert_eq!(snapshot.session_records.len(), 1);
26184 let live = &snapshot.session_records[0];
26185 assert_eq!(live.record_id, durable_id);
26186 assert_eq!(live.state, ManagedSessionState::Live);
26187 assert_eq!(live.active_session.as_ref(), Some(&address));
26188 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26189 let binding = shared
26190 .session_bindings
26191 .lock()
26192 .unwrap_or_else(|poisoned| poisoned.into_inner())
26193 .get(&address.session.instance_id)
26194 .cloned()
26195 .unwrap();
26196 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26197
26198 let mut duplicate = live.clone();
26199 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26200 duplicate.display_name = "duplicate".to_owned();
26201 duplicate.state = ManagedSessionState::Dormant;
26202 duplicate.active_session = None;
26203 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26204 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26205 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26206 assert_eq!(shared.snapshot().session_records.len(), 1);
26207
26208 let terminal_size = gate4agent_types::TerminalSize {
26209 rows: 24,
26210 columns: 80,
26211 };
26212 let (first_busy, second_busy) = tokio::join!(
26213 shared.resume_session_record(&durable_id, terminal_size, None),
26214 shared.resume_session_record(&durable_id, terminal_size, None),
26215 );
26216 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26217 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26218 assert_eq!(busy.message, "session-record-busy");
26219 }
26220
26221 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26222 assert_eq!(loaded.records.len(), 1);
26223 assert_eq!(loaded.records[0].record_id, durable_id);
26224 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26225 assert_eq!(
26226 loaded.records[0]
26227 .provider_session
26228 .as_ref()
26229 .unwrap()
26230 .transcript_path,
26231 None,
26232 );
26233 let catalog = active_registry().unwrap();
26234 let (reloaded_handle, _reloaded_runtime) =
26235 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26236 let reloaded = NodeShared::new_with_incarnation(
26237 reloaded_handle,
26238 "fixture-token".to_owned(),
26239 node_id,
26240 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26241 vec![workspace],
26242 vec![agent("claude")],
26243 ProviderRuntimeStatuses::default(),
26244 None,
26245 Vec::new(),
26246 Vec::new(),
26247 SpawnProfileRegistry::default(),
26248 None,
26249 None,
26250 None,
26251 None,
26252 loaded.records,
26253 loaded.managed_worktrees,
26254 loaded.managed_worktree_tombstones,
26255 loaded.materializations,
26256 loaded.warning,
26257 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26258 );
26259 assert_eq!(reloaded.snapshot().session_records.len(), 1);
26260 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26261 std::fs::remove_dir_all(root).unwrap();
26262 }
26263
26264 #[tokio::test]
26265 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26266 let secondary_root = temporary_workspace_root("workspace-restore");
26267 let other_root = temporary_workspace_root("workspace-conflict");
26268 std::fs::create_dir_all(&secondary_root).unwrap();
26269 std::fs::create_dir_all(&other_root).unwrap();
26270 let catalog = active_registry().unwrap();
26271 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26272 let primary = WorkspaceConfig::new(
26273 WorkspaceId::new("primary").unwrap(),
26274 std::env::current_dir().unwrap(),
26275 )
26276 .unwrap();
26277 let secondary = WorkspaceConfig::new(
26278 WorkspaceId::new("secondary").unwrap(),
26279 &secondary_root,
26280 )
26281 .unwrap();
26282 let shared = NodeShared::new(
26283 handle,
26284 "fixture-token".to_owned(),
26285 NodeId::new("node-1").unwrap(),
26286 vec![primary, secondary.clone()],
26287 vec![agent("claude")],
26288 );
26289 let record_id = SessionRecordId::new("sr-workspace").unwrap();
26290 shared
26291 .insert_record(ManagedSessionRecord {
26292 record_id: record_id.clone(),
26293 display_name: "workspace record".to_owned(),
26294 provider: agent("claude"),
26295 mode: SessionMode::Pty,
26296 state: ManagedSessionState::Dormant,
26297 workspace_id: secondary.workspace_id.clone(),
26298 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26299 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26300 key: gate4agent_types::ProviderSessionKey::SessionId,
26301 id: "provider-session".to_owned(),
26302 transcript_path: None,
26303 }),
26304 active_session: None,
26305 environment_profile: None,
26306 bundle: None,
26307 context_id: None,
26308 context: None,
26309 exported_context: None,
26310 task_binding: None,
26311 created_at_unix_ms: 1,
26312 updated_at_unix_ms: 1,
26313 last_error: None,
26314 })
26315 .unwrap();
26316 shared.unregister_workspace(&secondary.workspace_id).unwrap();
26317 let unavailable = shared.record(&record_id).unwrap();
26318 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26319 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26320
26321 let conflict = shared
26322 .register_workspace(
26323 secondary.workspace_id.clone(),
26324 other_root.to_string_lossy().into_owned(),
26325 )
26326 .await
26327 .unwrap_err();
26328 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26329 shared
26330 .register_workspace(
26331 secondary.workspace_id,
26332 secondary_root.to_string_lossy().into_owned(),
26333 )
26334 .await
26335 .unwrap();
26336 let restored = shared.record(&record_id).unwrap();
26337 assert_eq!(restored.state, ManagedSessionState::Dormant);
26338 assert!(restored.last_error.is_none());
26339 std::fs::remove_dir_all(secondary_root).unwrap();
26340 std::fs::remove_dir_all(other_root).unwrap();
26341 }
26342
26343 #[test]
26344 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26345 let error = validate_workspace_request_root(
26346 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26347 )
26348 .unwrap_err();
26349 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26350 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26351 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26352 }
26353
26354 #[test]
26355 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26356 let catalog = active_registry().unwrap();
26357 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26358 let workspace = WorkspaceConfig::new(
26359 WorkspaceId::new("primary").unwrap(),
26360 std::env::current_dir().unwrap(),
26361 )
26362 .unwrap();
26363 let shared = NodeShared::new(
26364 handle,
26365 "fixture-token".to_owned(),
26366 NodeId::new("node-1").unwrap(),
26367 vec![workspace],
26368 vec![agent("claude")],
26369 );
26370 let publishers = 4_u64;
26371 let events_per_publisher = 256_u64;
26372 let start = std::sync::Barrier::new(publishers as usize + 1);
26373 let finished = AtomicU64::new(0);
26374
26375 std::thread::scope(|scope| {
26376 for _ in 0..publishers {
26377 scope.spawn(|| {
26378 start.wait();
26379 for _ in 0..events_per_publisher {
26380 shared.publish(NodeEvent::ControllerChanged { controller: None });
26381 }
26382 finished.fetch_add(1, Ordering::Release);
26383 });
26384 }
26385 start.wait();
26386 while finished.load(Ordering::Acquire) != publishers {
26387 assert_resync_history_is_linearized(shared.resync(0));
26388 std::thread::yield_now();
26389 }
26390 });
26391 let response = shared.resync(0);
26392 assert_resync_history_is_linearized(response.clone());
26393 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26394 unreachable!("resync helper returned another response");
26395 };
26396 assert_eq!(event_sequence, publishers * events_per_publisher);
26397 assert_eq!(events.len() as u64, event_sequence);
26398 }
26399
26400 fn assert_resync_history_is_linearized(response: NodeResponse) {
26401 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26402 unreachable!("resync helper returned another response");
26403 };
26404 assert!(events.windows(2).all(|pair| {
26405 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26406 }));
26407 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26408 }
26409
26410 #[test]
26411 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26412 assert_eq!(
26413 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26414 r"C:\repo\worktree",
26415 );
26416 assert_eq!(
26417 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26418 r"\\server\share\repo",
26419 );
26420 assert!(validate_workspace_root(
26421 &WorkspaceId::new("network").unwrap(),
26422 r"\\server\share\repo",
26423 )
26424 .is_err());
26425 let workspace = WorkspaceConfig::new(
26426 WorkspaceId::new("primary").unwrap(),
26427 std::env::current_dir().unwrap(),
26428 )
26429 .unwrap();
26430 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26431 }
26432
26433 #[test]
26434 fn resume_rebinds_only_on_the_expected_authorized_generation() {
26435 let catalog = active_registry().unwrap();
26436 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26437 let workspace_id = WorkspaceId::new("primary").unwrap();
26438 let workspace = WorkspaceConfig::new(
26439 workspace_id.clone(),
26440 std::env::current_dir().unwrap(),
26441 )
26442 .unwrap();
26443 let shared = NodeShared::new(
26444 handle,
26445 "fixture-token".to_owned(),
26446 NodeId::new("node-1").unwrap(),
26447 vec![workspace],
26448 vec![agent("claude")],
26449 );
26450 let instance_id = AgentInstanceId(17);
26451 let original = SessionAddress {
26452 workspace_id,
26453 session: SessionKey {
26454 instance_id,
26455 generation: SessionGeneration(3),
26456 },
26457 };
26458 shared.bind_session(
26459 instance_id,
26460 original.workspace_id.clone(),
26461 original.session.generation,
26462 );
26463 shared
26464 .arm_resume(
26465 &original,
26466 CommandId(41),
26467 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26468 )
26469 .unwrap();
26470
26471 shared.publish_control(ControlEvent {
26472 sequence: 1,
26473 command_id: None,
26474 instance_id,
26475 generation: SessionGeneration(4),
26476 event: ControlEventKind::Running { process_id: None },
26477 });
26478 assert_eq!(
26479 shared.address_for(instance_id, SessionGeneration(3)),
26480 Some(original.clone()),
26481 );
26482 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26483
26484 shared.publish_control(ControlEvent {
26485 sequence: 2,
26486 command_id: Some(CommandId(42)),
26487 instance_id,
26488 generation: SessionGeneration(3),
26489 event: ControlEventKind::CommandRejected {
26490 message: "unrelated command".to_owned(),
26491 },
26492 });
26493
26494 shared.publish_control(ControlEvent {
26495 sequence: 3,
26496 command_id: None,
26497 instance_id,
26498 generation: SessionGeneration(4),
26499 event: ControlEventKind::ResumeAuthorized {
26500 session: gate4agent_types::ResumeSessionSummary {
26501 key: gate4agent_types::ProviderSessionKey::SessionId,
26502 id: "fixture-session".to_owned(),
26503 },
26504 },
26505 });
26506 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26507 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26508 assert_eq!(rebound.workspace_id, original.workspace_id);
26509 let history = shared
26510 .history
26511 .lock()
26512 .unwrap_or_else(|poisoned| poisoned.into_inner());
26513 assert!(history.events.iter().any(|envelope| matches!(
26514 &envelope.event,
26515 NodeEvent::Control { address, event }
26516 if address == &rebound
26517 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26518 )));
26519 }
26520
26521 #[test]
26522 fn all_node_pty_prompts_use_bracketed_paste_framing() {
26523 for agent in ["claude", "codex", "kimi"] {
26524 assert_eq!(
26525 prompt_framing(&AgentId::new(agent).unwrap()),
26526 PromptFraming::BracketedPaste
26527 );
26528 }
26529 }
26530
26531 fn generous_walk_deadline() -> Instant {
26532 Instant::now() + Duration::from_secs(30)
26533 }
26534
26535 #[test]
26536 fn workspace_tree_is_relative_and_skips_heavy_directories() {
26537 let root = temporary_workspace_root("tree");
26538 std::fs::create_dir_all(root.join("src")).unwrap();
26539 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26540 std::fs::create_dir_all(root.join("target/debug")).unwrap();
26541 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26542 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26543 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26544 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26550 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26551 std::fs::create_dir_all(root.join("target-notes")).unwrap();
26555 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26556
26557 let (entries, truncated, _budget) =
26558 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26559 assert!(!truncated);
26560 assert!(entries.iter().any(|entry| {
26561 entry.relative_path.as_utf8() == Some("src")
26562 && entry.kind == WorkspaceEntryKind::Directory
26563 }));
26564 assert!(entries.iter().any(|entry| {
26565 entry.relative_path.as_utf8() == Some("src/lib.rs")
26566 && entry.kind == WorkspaceEntryKind::File
26567 }));
26568 assert!(entries.iter().any(|entry| {
26569 entry.relative_path.as_utf8() == Some("target-notes")
26570 && entry.kind == WorkspaceEntryKind::Directory
26571 }));
26572 assert!(entries.iter().any(|entry| {
26573 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26574 && entry.kind == WorkspaceEntryKind::File
26575 }));
26576 assert!(entries.iter().all(|entry| {
26577 let path = entry.relative_path.as_utf8().unwrap();
26578 !path.starts_with(".git")
26579 && !(path == "target" || path.starts_with("target/"))
26580 && !(path == "target-a2" || path.starts_with("target-a2/"))
26581 && !path.starts_with("node_modules")
26582 }));
26583
26584 std::fs::remove_dir_all(&root).unwrap();
26585 }
26586
26587 #[test]
26588 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26589 let root = temporary_workspace_root("tree-entry-cap");
26590 std::fs::create_dir_all(&root).unwrap();
26591 for index in 0..20 {
26592 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26593 }
26594
26595 let (entries, truncated, budget) =
26596 collect_workspace_entries(&root, generous_walk_deadline(), 5);
26597 assert!(truncated);
26598 assert!(budget.entry_cap_exceeded);
26599 assert!(!budget.time_budget_exceeded);
26600 assert!(entries.len() <= 5);
26601
26602 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26603 .expect("an entry-cap-truncated walk must report a truncation marker");
26604 assert!(inspection_truncation.walk_entry_cap_exceeded);
26605 assert!(!inspection_truncation.walk_time_budget_exceeded);
26606 assert!(!inspection_truncation.git_time_budget_exceeded);
26607 assert!(inspection_truncation.entries_visited >= 5);
26608
26609 std::fs::remove_dir_all(&root).unwrap();
26610 }
26611
26612 #[test]
26613 fn workspace_walk_within_budget_reports_no_truncation() {
26614 let root = temporary_workspace_root("tree-no-truncation");
26615 std::fs::create_dir_all(&root).unwrap();
26616 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26617
26618 let (_entries, truncated, budget) =
26619 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26620 assert!(!truncated);
26621 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26622
26623 std::fs::remove_dir_all(&root).unwrap();
26624 }
26625
26626 #[test]
26627 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26628 let root = temporary_workspace_root("tree-prefix-siblings");
26629 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26630 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26631 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26632 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26633
26634 let (entries, truncated, _budget) =
26635 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26636 assert!(!truncated);
26637 let paths = entries
26638 .iter()
26639 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26640 .collect::<Vec<_>>();
26641 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26642 assert_eq!(
26643 paths,
26644 vec![
26645 "gate4agent-c2".to_owned(),
26646 "gate4agent-c2-client".to_owned(),
26647 "gate4agent-c2-client/lib.rs".to_owned(),
26648 "gate4agent-c2/src".to_owned(),
26649 "gate4agent-c2/src/lib.rs".to_owned(),
26650 ],
26651 );
26652
26653 std::fs::remove_dir_all(&root).unwrap();
26654 }
26655
26656 #[test]
26657 fn git_parsers_cap_status_and_commit_results() {
26658 let mut status_output = Vec::new();
26659 for index in 0..=GIT_STATUS_MAX_ENTRIES {
26660 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26661 }
26662 let mut snapshot = git_snapshot_for_parser();
26663 parse_git_status(&status_output, &mut snapshot);
26664 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26665 assert!(snapshot.truncated);
26666 assert_eq!(snapshot.status[0].index_status, " ");
26667 assert_eq!(snapshot.status[0].worktree_status, "M");
26668
26669 snapshot.truncated = false;
26670 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26671 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
26672 .collect::<String>();
26673 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
26674 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
26675 assert!(snapshot.truncated);
26676 }
26677
26678 #[test]
26679 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
26680 let mut snapshot = git_snapshot_for_parser();
26681 parse_git_status(
26682 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
26683 &mut snapshot,
26684 );
26685
26686 assert!(!snapshot.truncated);
26687 assert_eq!(snapshot.status.len(), 3);
26688 assert_eq!(snapshot.status[0].index_status, "R");
26689 assert_eq!(snapshot.status[0].worktree_status, " ");
26690 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
26691 assert_eq!(
26692 snapshot.status[0]
26693 .previous_path
26694 .as_ref()
26695 .and_then(RepositoryPath::as_utf8),
26696 Some("src/previous name.rs"),
26697 );
26698 assert_eq!(snapshot.status[1].index_status, " ");
26699 assert_eq!(snapshot.status[1].worktree_status, "C");
26700 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
26701 assert_eq!(
26702 snapshot.status[1]
26703 .previous_path
26704 .as_ref()
26705 .and_then(RepositoryPath::as_utf8),
26706 Some("src/original.rs"),
26707 );
26708 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
26709 assert!(snapshot.status[2].previous_path.is_none());
26710 }
26711
26712 #[test]
26713 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
26714 let mut snapshot = git_snapshot_for_parser();
26715 parse_git_status(
26716 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
26717 &mut snapshot,
26718 );
26719
26720 assert!(snapshot.truncated);
26721 assert_eq!(snapshot.status.len(), 1);
26722 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
26723 assert!(snapshot.status[0].previous_path.is_none());
26724 }
26725
26726 #[tokio::test]
26727 async fn observer_can_browse_host_directories_without_controller() {
26728 let root = temporary_workspace_root("host-directory-observer");
26729 std::fs::create_dir_all(root.join("directory-b")).unwrap();
26730 std::fs::create_dir_all(root.join("directory-a")).unwrap();
26731 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
26732 let workspace_id = WorkspaceId::new("primary").unwrap();
26733 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
26734 let catalog = active_registry().unwrap();
26735 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26736 let shared = NodeShared::new(
26737 handle,
26738 "fixture-token".to_owned(),
26739 NodeId::new("node-1").unwrap(),
26740 vec![workspace],
26741 vec![agent("claude")],
26742 );
26743 let request = NodeRequest::BrowseHostDirectories {
26744 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
26745 after: None,
26746 };
26747 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26748 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
26749 }));
26750 assert!(!request_uses_unnegotiated_capability(
26751 &request,
26752 &baseline_capabilities().unwrap(),
26753 ));
26754 assert!(request_uses_unnegotiated_capability(&request, &[]));
26755
26756 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
26757 .await
26758 .unwrap();
26759 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
26760 panic!("host directory browse returned another response");
26761 };
26762 assert_eq!(
26763 listing
26764 .entries
26765 .iter()
26766 .map(|entry| entry.display_name.as_str())
26767 .collect::<Vec<_>>(),
26768 vec!["directory-a", "directory-b"],
26769 );
26770 assert!(listing.entries.iter().all(|entry| !entry.is_link));
26771 assert!(!listing.incomplete);
26772 std::fs::remove_dir_all(root).unwrap();
26773 }
26774
26775 #[tokio::test]
26776 async fn observer_can_inspect_only_a_registered_workspace() {
26777 let root = temporary_workspace_root("observer");
26778 std::fs::create_dir_all(root.join("src")).unwrap();
26779 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
26780 let workspace_id = WorkspaceId::new("primary").unwrap();
26781 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26782 let catalog = active_registry().unwrap();
26783 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26784 let shared = NodeShared::new(
26785 handle,
26786 "fixture-token".to_owned(),
26787 NodeId::new("node-1").unwrap(),
26788 vec![workspace],
26789 vec![agent("claude")],
26790 );
26791
26792 let response = process_request_inner(
26793 &shared,
26794 77,
26795 ClientRole::Observer,
26796 NodeRequest::InspectWorkspace {
26797 workspace_id: workspace_id.clone(),
26798 },
26799 )
26800 .await
26801 .unwrap();
26802 let NodeResponse::WorkspaceInspected { inspection } = response else {
26803 panic!("workspace inspection returned another response");
26804 };
26805 assert_eq!(inspection.workspace_id, workspace_id);
26806 assert!(inspection.entries.iter().any(|entry| {
26807 entry.relative_path.as_utf8() == Some("src/main.rs")
26808 }));
26809
26810 let unknown = process_request_inner(
26811 &shared,
26812 77,
26813 ClientRole::Observer,
26814 NodeRequest::InspectWorkspace {
26815 workspace_id: WorkspaceId::new("unknown").unwrap(),
26816 },
26817 )
26818 .await
26819 .unwrap_err();
26820 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
26821 std::fs::remove_dir_all(&root).unwrap();
26822 }
26823
26824 #[test]
26825 fn context_pack_source_accepts_only_running_or_successful_exit() {
26826 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
26827 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
26828 exit_code: Some(0),
26829 }));
26830 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26831 exit_code: Some(1),
26832 }));
26833 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26834 exit_code: None,
26835 }));
26836 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
26837 message: "provider failed".to_owned(),
26838 }));
26839 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
26840 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
26841 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
26842 }
26843
26844 #[test]
26845 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
26846 let git = git_snapshot_for_parser();
26847 let first = ContextPackRepositoryObservation {
26848 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26849 git: git.clone(),
26850 files: Vec::new(),
26851 };
26852 let second = ContextPackRepositoryObservation {
26853 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
26854 git,
26855 files: Vec::new(),
26856 };
26857
26858 let error = stable_context_pack_repository(first, second).unwrap_err();
26859
26860 assert_eq!(
26861 error.code,
26862 NodeFailureCode::ContextPackMaterializationFailed,
26863 );
26864 }
26865
26866 #[test]
26867 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
26868 let git = git_snapshot_for_parser();
26869 let first = ContextPackRepositoryObservation {
26870 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26871 git: git.clone(),
26872 files: vec![ContextPackRepositoryFileSource::utf8(
26873 "README.md",
26874 "first\n".to_owned(),
26875 6,
26876 )],
26877 };
26878 let second = ContextPackRepositoryObservation {
26879 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26880 git,
26881 files: vec![ContextPackRepositoryFileSource::utf8(
26882 "README.md",
26883 "other\n".to_owned(),
26884 6,
26885 )],
26886 };
26887
26888 let error = stable_context_pack_repository(first, second).unwrap_err();
26889
26890 assert_eq!(
26891 error.code,
26892 NodeFailureCode::ContextPackMaterializationFailed,
26893 );
26894 }
26895
26896 #[tokio::test]
26897 async fn native_session_catalog_zero_active_session_fixture() {
26898 let workspace_root = temporary_workspace_root("native-catalog-workspace");
26899 let history_root = temporary_workspace_root("native-catalog-history");
26900 let outside_root = temporary_workspace_root("native-catalog-outside");
26901 std::fs::create_dir_all(&workspace_root).unwrap();
26902 std::fs::create_dir_all(&history_root).unwrap();
26903 std::fs::create_dir_all(&outside_root).unwrap();
26904 let transcript = |session_id: &str, cwd: &Path, title: &str| {
26905 [
26906 serde_json::json!({
26907 "type": "user",
26908 "sessionId": session_id,
26909 "cwd": cwd.to_str().unwrap(),
26910 "message": { "content": "raw user text must not cross the catalog wire" }
26911 }),
26912 serde_json::json!({
26913 "type": "assistant",
26914 "sessionId": session_id,
26915 "cwd": cwd.to_str().unwrap(),
26916 "message": { "model": "claude-sonnet", "content": "raw answer" }
26917 }),
26918 serde_json::json!({
26919 "type": "ai-title",
26920 "sessionId": session_id,
26921 "aiTitle": title
26922 }),
26923 ]
26924 .into_iter()
26925 .map(|record| record.to_string())
26926 .collect::<Vec<_>>()
26927 .join("\n")
26928 };
26929 std::fs::write(
26930 history_root.join("stable-session.jsonl"),
26931 transcript("stable-session", &workspace_root, "Workspace review"),
26932 )
26933 .unwrap();
26934 std::fs::write(
26935 history_root.join("outside-session.jsonl"),
26936 transcript("outside-session", &outside_root, "Outside review"),
26937 )
26938 .unwrap();
26939 let history = NativeHistoryConfig::new(vec![
26940 NativeHistoryRoot::new(
26941 gate4agent_types::AdapterId::new("claude-code").unwrap(),
26942 HistorySourceLayout::SingleNdjson,
26943 history_root.clone(),
26944 )
26945 .unwrap(),
26946 ])
26947 .unwrap();
26948 let catalog = active_registry().unwrap();
26949 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26950 assert!(handle.snapshot().sessions.is_empty());
26951 let workspace_id = WorkspaceId::new("primary").unwrap();
26952 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
26953 let mut shared = NodeShared::new(
26954 handle,
26955 "fixture-token".to_owned(),
26956 NodeId::new("node-native-catalog").unwrap(),
26957 vec![workspace],
26958 vec![agent("claude")],
26959 );
26960 shared.native_session_catalog = Some(Arc::new(Mutex::new(
26961 NativeSessionCatalogAuthority::new(history),
26962 )));
26963 let route = NativeSessionCatalogRoute::workspace(
26964 workspace_id.clone(),
26965 agent("claude"),
26966 );
26967 let request = NodeRequest::CatalogNativeSessions {
26968 route: route.clone(),
26969 limit: 2,
26970 };
26971 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
26972 .await
26973 .unwrap_err();
26974 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26975 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
26976 .await
26977 .unwrap();
26978 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
26979 panic!("native catalog returned a different response");
26980 };
26981 let summary = summary.expect("node must emit native session catalog summary metadata");
26982 assert_eq!(entries.len(), 1);
26983 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
26984 assert_eq!(entries[0].external_group, None);
26985 assert_eq!(entries[0].record_id, None);
26986 assert_eq!(entries[0].title, None);
26987 assert_eq!(entries[0].model, None);
26988 assert_eq!(entries[0].message_count, 0);
26989 let encoded_entries = serde_json::to_string(&entries).unwrap();
26990 assert!(!encoded_entries.contains("stable-session"));
26991 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
26992 let page_request = NodeRequest::PageNativeSessions {
26993 route: route.clone(),
26994 window: crate::protocol::NativeSessionCatalogWindow::Recent,
26995 catalog_revision: summary.catalog_revision,
26996 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26997 after_selection_id: None,
26998 limit: 2,
26999 };
27000 let observer = process_request_inner(
27001 &shared,
27002 7,
27003 ClientRole::Observer,
27004 page_request.clone(),
27005 )
27006 .await
27007 .unwrap_err();
27008 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27009 let page_response = process_request_inner(
27010 &shared,
27011 7,
27012 ClientRole::Operator,
27013 page_request.clone(),
27014 )
27015 .await
27016 .unwrap();
27017 assert!(matches!(
27018 page_response,
27019 NodeResponse::NativeSessionsPaged { page, .. }
27020 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27021 ));
27022 let stale = process_request_inner(
27023 &shared,
27024 7,
27025 ClientRole::Operator,
27026 NodeRequest::PageNativeSessions {
27027 route: route.clone(),
27028 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27029 catalog_revision: summary.catalog_revision.wrapping_add(1),
27030 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27031 after_selection_id: None,
27032 limit: 2,
27033 },
27034 )
27035 .await
27036 .unwrap_err();
27037 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27038 let selection = NativeSessionSelection {
27039 route: route.clone(),
27040 catalog_revision: summary.catalog_revision,
27041 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27042 selection_id: entries[0].selection_id.clone(),
27043 };
27044 let preview_response = process_request_inner(
27045 &shared,
27046 7,
27047 ClientRole::Operator,
27048 NodeRequest::PreviewNativeSession {
27049 selection: selection.clone(),
27050 message_limit: 1,
27051 },
27052 )
27053 .await
27054 .unwrap();
27055 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27056 panic!("native preview returned a different response");
27057 };
27058 assert_eq!(preview.messages.len(), 1);
27059 assert!(preview.message_count_exact);
27060 assert_eq!(preview.messages[0].text, "raw answer");
27061 assert!(preview.truncated);
27062
27063 let index_request = NodeRequest::IndexNativeSession {
27064 selection: selection.clone(),
27065 display_name: "Saved Claude".to_owned(),
27066 };
27067 let observer = process_request_inner(
27068 &shared,
27069 7,
27070 ClientRole::Observer,
27071 index_request.clone(),
27072 )
27073 .await
27074 .unwrap_err();
27075 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27076 let without_controller = process_request_inner(
27077 &shared,
27078 7,
27079 ClientRole::Operator,
27080 index_request.clone(),
27081 )
27082 .await
27083 .unwrap_err();
27084 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27085 let acquired = process_request_inner(
27086 &shared,
27087 7,
27088 ClientRole::Operator,
27089 NodeRequest::AcquireController { lease_ms: 5_000 },
27090 )
27091 .await
27092 .unwrap();
27093 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27094 let indexed = process_request_inner(
27095 &shared,
27096 7,
27097 ClientRole::Operator,
27098 index_request,
27099 )
27100 .await
27101 .unwrap();
27102 assert!(indexed.native_session_index_contract_is_valid());
27103 let NodeResponse::NativeSessionIndexed {
27104 selection: echoed_selection,
27105 record,
27106 } = indexed else {
27107 panic!("native session index returned a different response");
27108 };
27109 assert_eq!(echoed_selection, selection);
27110 let recataloged = process_request_inner(
27111 &shared,
27112 7,
27113 ClientRole::Operator,
27114 NodeRequest::CatalogNativeSessions {
27115 route,
27116 limit: 2,
27117 },
27118 )
27119 .await
27120 .unwrap();
27121 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27122 panic!("native catalog returned a different response after indexing");
27123 };
27124 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27125
27126 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27127 let external_catalog = process_request_inner(
27128 &shared,
27129 7,
27130 ClientRole::Operator,
27131 NodeRequest::CatalogNativeSessions {
27132 route: unregistered_route.clone(),
27133 limit: 2,
27134 },
27135 )
27136 .await
27137 .unwrap();
27138 let external_json = serde_json::to_string(&external_catalog).unwrap();
27139 assert!(!external_json.contains("outside-session"));
27140 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27141 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27142 assert!(!external_json.contains("\"session_id\""));
27143 assert!(!external_json.contains("\"cwd\""));
27144 assert!(!external_json.contains("\"path\""));
27145 let NodeResponse::NativeSessionsCataloged {
27146 route: echoed_route,
27147 entries: external_entries,
27148 summary: Some(external_summary),
27149 } = external_catalog
27150 else {
27151 panic!("unregistered native catalog returned a different response");
27152 };
27153 assert_eq!(echoed_route, unregistered_route);
27154 assert_eq!(external_entries.len(), 1);
27155 assert_eq!(external_entries[0].record_id, None);
27156 let external_group = external_entries[0]
27157 .external_group
27158 .as_ref()
27159 .expect("unregistered session must have an external group");
27160 assert_eq!(
27161 external_group.kind,
27162 gate4agent_types::NativeSessionExternalGroupKind::Project,
27163 );
27164 assert!(external_group.group_id.starts_with("external-"));
27165 assert_eq!(
27166 external_group.display_name,
27167 outside_root.file_name().unwrap().to_string_lossy(),
27168 );
27169 let external_selection = NativeSessionSelection {
27170 route: unregistered_route,
27171 catalog_revision: external_summary.catalog_revision,
27172 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27173 selection_id: external_entries[0].selection_id.clone(),
27174 };
27175 let external_preview = process_request_inner(
27176 &shared,
27177 7,
27178 ClientRole::Operator,
27179 NodeRequest::PreviewNativeSession {
27180 selection: external_selection.clone(),
27181 message_limit: 1,
27182 },
27183 )
27184 .await
27185 .unwrap();
27186 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27187 assert!(!external_preview_json.contains("outside-session"));
27188 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27189 assert!(!external_preview_json.contains("\"session_id\""));
27190 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27191 panic!("unregistered native preview returned a different response");
27192 };
27193 assert_eq!(preview.messages.len(), 1);
27194 let external_index = process_request_inner(
27195 &shared,
27196 7,
27197 ClientRole::Operator,
27198 NodeRequest::IndexNativeSession {
27199 selection: external_selection,
27200 display_name: "External Claude".to_owned(),
27201 },
27202 )
27203 .await
27204 .unwrap_err();
27205 assert_eq!(
27206 external_index.code,
27207 NodeFailureCode::WorkspaceRegistrationRequired,
27208 );
27209 let record_response = process_request_inner(
27210 &shared,
27211 7,
27212 ClientRole::Operator,
27213 NodeRequest::PreviewSessionRecord {
27214 record_id: record.record_id.clone(),
27215 message_limit: 2,
27216 },
27217 )
27218 .await
27219 .unwrap();
27220 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27221 panic!("record preview returned a different response");
27222 };
27223 assert_eq!(record_id, record.record_id);
27224 assert_eq!(preview.messages.len(), 2);
27225 let encoded = serde_json::to_string(&preview).unwrap();
27226 assert!(!encoded.contains("stable-session"));
27227 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27228 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27229 panic!("record preview history summary returned a different response");
27230 };
27231 let summaries = events
27232 .iter()
27233 .filter_map(|envelope| match &envelope.event {
27234 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27235 if record_id == &record.record_id =>
27236 {
27237 Some(*summary)
27238 }
27239 _ => None,
27240 })
27241 .collect::<Vec<_>>();
27242 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27243 let summary_json = serde_json::to_string(&summaries).unwrap();
27244 for private in [
27245 "stable-session",
27246 "raw user text must not cross the catalog wire",
27247 "raw answer",
27248 "Workspace review",
27249 "claude-sonnet",
27250 ] {
27251 assert!(!summary_json.contains(private), "{private}");
27252 }
27253 let identity = record.provider_session.clone().unwrap();
27254 assert_eq!(
27255 shared.revalidate_session_record_preview(&record, &identity),
27256 Ok(())
27257 );
27258 shared.remove_record_memory(&record.record_id);
27259 assert_eq!(
27260 shared
27261 .revalidate_session_record_preview(&record, &identity)
27262 .unwrap_err()
27263 .code,
27264 NodeFailureCode::UnknownSessionRecord
27265 );
27266 let released = process_request_inner(
27267 &shared,
27268 7,
27269 ClientRole::Operator,
27270 NodeRequest::ReleaseController,
27271 )
27272 .await
27273 .unwrap();
27274 assert!(matches!(released, NodeResponse::Controller { controller: None }));
27275 assert!(shared.controller_state().is_none());
27276 std::fs::remove_dir_all(workspace_root).unwrap();
27277 std::fs::remove_dir_all(history_root).unwrap();
27278 std::fs::remove_dir_all(outside_root).unwrap();
27279 }
27280
27281 #[tokio::test]
27282 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27283 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27284 std::fs::create_dir_all(&history_root).unwrap();
27285 let history = NativeHistoryConfig::new(vec![
27286 NativeHistoryRoot::new(
27287 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27288 HistorySourceLayout::SingleNdjson,
27289 history_root.clone(),
27290 )
27291 .unwrap(),
27292 ])
27293 .unwrap();
27294 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27295 let held = catalog.lock().unwrap();
27296 let result = timeout(
27297 Duration::from_secs(1),
27298 run_native_session_catalog_operation(
27299 Arc::clone(&catalog),
27300 "native session catalog",
27301 |_| Ok::<(), std::convert::Infallible>(()),
27302 ),
27303 )
27304 .await
27305 .expect("concurrent native catalog request must not wait for the operation deadline")
27306 .unwrap_err();
27307 assert_eq!(result.code, NodeFailureCode::BackendBusy);
27308 drop(held);
27309 std::fs::remove_dir_all(history_root).unwrap();
27310 }
27311
27312 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27313 let unique = std::time::SystemTime::now()
27314 .duration_since(std::time::UNIX_EPOCH)
27315 .unwrap()
27316 .as_nanos();
27317 std::env::temp_dir().join(format!(
27318 "gate4agent-node-{label}-{}-{unique}",
27319 std::process::id(),
27320 ))
27321 }
27322
27323 fn git_snapshot_for_parser() -> GitSnapshot {
27324 GitSnapshot {
27325 is_repository: true,
27326 branch: Some("main".to_owned()),
27327 status: Vec::new(),
27328 recent_commits: Vec::new(),
27329 worktrees: Vec::new(),
27330 managed_worktree: None,
27331 truncated: false,
27332 diagnostic: None,
27333 }
27334 }
27335
27336 #[tokio::test]
27337 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27338 let root = temporary_workspace_root("managed-git-scope");
27339 std::fs::create_dir_all(&root).unwrap();
27340 run_test_git(&root, &["init"]);
27341 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27342 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27343 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27344 run_test_git(&root, &["add", "seed.txt"]);
27345 run_test_git(&root, &["commit", "-m", "seed"]);
27346 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27347
27348 let workspace_id = WorkspaceId::new("managed-a").unwrap();
27349 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27350 let catalog = active_registry().unwrap();
27351 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27352 let shared = NodeShared::new(
27353 handle,
27354 "fixture-token".to_owned(),
27355 NodeId::new("node-1").unwrap(),
27356 vec![workspace],
27357 vec![agent("claude")],
27358 );
27359 let mut lease = ManagedWorktreeLeaseRecord {
27360 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27361 source_workspace_id: WorkspaceId::new("primary").unwrap(),
27362 workspace_id: workspace_id.clone(),
27363 profile_id: WorktreeProfileId::new("default").unwrap(),
27364 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27365 target_root: root.to_string_lossy().into_owned(),
27366 branch: "gate4agent/a".to_owned(),
27367 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27368 expected_head: None,
27369 retention: ManagedWorktreeRetention::Retain,
27370 state: ManagedWorktreeLeaseState::InUse,
27371 session_holders: vec![ManagedWorktreeSessionHolder {
27372 incarnation_id: shared.incarnation_id,
27373 instance_id: AgentInstanceId(7),
27374 generation: SessionGeneration(1),
27375 }],
27376 record_holders: Vec::new(),
27377 cleanup_failure: None,
27378 created_at_unix_ms: 1,
27379 updated_at_unix_ms: 1,
27380 };
27381 *shared
27382 .managed_worktrees
27383 .lock()
27384 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27385 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27386
27387 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27388 let scope = inspection.git.managed_worktree.unwrap();
27389 assert_eq!(scope.lease_id, lease.lease_id);
27390 assert_eq!(scope.branch, "gate4agent/a");
27391 assert_eq!(scope.active_session_count, 1);
27392
27393 lease.branch = "gate4agent/b".to_owned();
27394 *shared
27395 .managed_worktrees
27396 .lock()
27397 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27398 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27399 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27400 assert!(mismatch.git.managed_worktree.is_none());
27401 std::fs::remove_dir_all(root).unwrap();
27402 }
27403
27404 #[tokio::test]
27405 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27406 let root = temporary_workspace_root("workspace-file-write-controller");
27407 std::fs::create_dir_all(root.join("src")).unwrap();
27408 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27409 let workspace_id = WorkspaceId::new("primary").unwrap();
27410 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27411 let catalog = active_registry().unwrap();
27412 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27413 let shared = NodeShared::new(
27414 handle,
27415 "fixture-token".to_owned(),
27416 NodeId::new("node-1").unwrap(),
27417 vec![workspace],
27418 vec![agent("claude")],
27419 );
27420 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27421 let expected_revision = workspace_file_revision(b"old\n");
27422 let request = NodeRequest::WriteWorkspaceFile {
27423 workspace_id: workspace_id.clone(),
27424 path: path.clone(),
27425 expected_revision: expected_revision.clone(),
27426 text: "new\n".to_owned(),
27427 };
27428 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27429 .await
27430 .unwrap_err();
27431 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27432 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27433 .await
27434 .unwrap_err();
27435 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27436 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27437 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27438 let NodeResponse::WorkspaceFileWritten { file } = response else {
27439 panic!("workspace file write returned another response");
27440 };
27441 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27442 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27443 let stale = process_request_inner(
27444 &shared,
27445 77,
27446 ClientRole::Operator,
27447 NodeRequest::WriteWorkspaceFile {
27448 workspace_id,
27449 path,
27450 expected_revision,
27451 text: "lost update\n".to_owned(),
27452 },
27453 )
27454 .await
27455 .unwrap_err();
27456 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27457 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27458 std::fs::remove_dir_all(root).unwrap();
27459 }
27460
27461 #[tokio::test]
27462 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27463 let root = temporary_workspace_root("workspace-entry-create-controller");
27464 std::fs::create_dir_all(root.join("src")).unwrap();
27465 let workspace_id = WorkspaceId::new("primary").unwrap();
27466 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27467 let catalog = active_registry().unwrap();
27468 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27469 let shared = NodeShared::new(
27470 handle,
27471 "fixture-token".to_owned(),
27472 NodeId::new("node-1").unwrap(),
27473 vec![workspace],
27474 vec![agent("claude")],
27475 );
27476 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27477 let request = NodeRequest::CreateWorkspaceFile {
27478 workspace_id: workspace_id.clone(),
27479 path: path.clone(),
27480 };
27481 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27482 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27483 }));
27484 assert!(!request_uses_unnegotiated_capability(
27485 &request,
27486 &baseline_capabilities().unwrap(),
27487 ));
27488 assert!(request_uses_unnegotiated_capability(&request, &[]));
27489 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27490 .await
27491 .unwrap_err();
27492 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27493 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27494 .await
27495 .unwrap_err();
27496 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27497 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27498
27499 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27500 .await
27501 .unwrap();
27502 let NodeResponse::WorkspaceFileCreated { file } = response else {
27503 panic!("workspace file create returned another response");
27504 };
27505 assert_eq!(file.workspace_id, workspace_id);
27506 assert_eq!(file.path, path);
27507 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27508 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27509
27510 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27511 .await
27512 .unwrap_err();
27513 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27514
27515 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27516 let response = process_request_inner(
27517 &shared,
27518 77,
27519 ClientRole::Operator,
27520 NodeRequest::CreateWorkspaceDirectory {
27521 workspace_id: workspace_id.clone(),
27522 path: directory_path.clone(),
27523 },
27524 )
27525 .await
27526 .unwrap();
27527 assert_eq!(
27528 response,
27529 NodeResponse::WorkspaceDirectoryCreated {
27530 workspace_id: workspace_id.clone(),
27531 entry: WorkspaceEntry {
27532 relative_path: directory_path,
27533 kind: WorkspaceEntryKind::Directory,
27534 },
27535 },
27536 );
27537 assert!(root.join("src/nested").is_dir());
27538
27539 let missing_parent = process_request_inner(
27540 &shared,
27541 77,
27542 ClientRole::Operator,
27543 NodeRequest::CreateWorkspaceFile {
27544 workspace_id: workspace_id.clone(),
27545 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27546 },
27547 )
27548 .await
27549 .unwrap_err();
27550 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27551 let non_directory_parent = process_request_inner(
27552 &shared,
27553 77,
27554 ClientRole::Operator,
27555 NodeRequest::CreateWorkspaceFile {
27556 workspace_id,
27557 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27558 },
27559 )
27560 .await
27561 .unwrap_err();
27562 assert_eq!(
27563 non_directory_parent.code,
27564 NodeFailureCode::RepositoryParentNotDirectory,
27565 );
27566 std::fs::remove_dir_all(root).unwrap();
27567 }
27568
27569 #[tokio::test]
27570 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27571 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27572 std::fs::create_dir_all(root.join("src")).unwrap();
27573 #[cfg(windows)]
27574 let root = std::fs::canonicalize(root).unwrap();
27575 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27576 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27577 let barrier = Arc::new(std::sync::Barrier::new(2));
27578 let done = Arc::new(AtomicBool::new(false));
27579 let task_commit_state = Arc::clone(&commit_state);
27580 let task_barrier = Arc::clone(&barrier);
27581 let task_done = Arc::clone(&done);
27582 let task_root = root.clone();
27583 let task = tokio::task::spawn_blocking(move || {
27584 task_barrier.wait();
27585 let result = create_workspace_file_on_disk(
27586 &task_root,
27587 &path,
27588 &task_commit_state,
27589 );
27590 task_done.store(true, Ordering::Release);
27591 result
27592 });
27593
27594 let error = settle_workspace_entry_create(
27595 task,
27596 Arc::clone(&commit_state),
27597 Duration::from_millis(10),
27598 "fixture deadline",
27599 "fixture task failed",
27600 )
27601 .await
27602 .unwrap_err();
27603 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27604 assert_eq!(
27605 commit_state.load(Ordering::Acquire),
27606 WORKSPACE_ENTRY_CREATE_CANCELED,
27607 );
27608
27609 barrier.wait();
27610 timeout(Duration::from_secs(1), async {
27611 while !done.load(Ordering::Acquire) {
27612 tokio::task::yield_now().await;
27613 }
27614 })
27615 .await
27616 .expect("canceled blocking create did not settle");
27617 assert!(!root.join("src/late.rs").exists());
27618 std::fs::remove_dir_all(root).unwrap();
27619 }
27620}
27621
27622#[cfg(test)]
27623mod provisional_spawn_tests {
27624 use super::*;
27625 use std::sync::Arc;
27626
27627 fn shared() -> NodeShared {
27628 let catalog = active_registry().unwrap();
27629 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27630 let workspace = WorkspaceConfig::new(
27631 WorkspaceId::new("primary").unwrap(),
27632 std::env::current_dir().unwrap(),
27633 )
27634 .unwrap()
27635 .with_worktree_service_mode(WorktreeServiceMode::Manual);
27636 NodeShared::new(
27637 handle,
27638 "fixture-token".to_owned(),
27639 NodeId::new("node-provisional-test").unwrap(),
27640 vec![workspace],
27641 vec![AgentId::new("claude").unwrap()],
27642 )
27643 }
27644
27645 #[test]
27648 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
27649 let shared = shared();
27650 let ws = WorkspaceId::new("primary").unwrap();
27651 let mut ids = Vec::with_capacity(50);
27652 for i in 1u64..=50 {
27653 let id = AgentInstanceId(i);
27654 ids.push(id);
27655 let address = SessionAddress {
27656 workspace_id: ws.clone(),
27657 session: SessionKey {
27658 instance_id: id,
27659 generation: SessionGeneration(1),
27660 },
27661 };
27662 shared.mark_spawn_provisional(address);
27663 }
27664 assert_eq!(
27665 shared
27666 .provisional_spawns
27667 .lock()
27668 .unwrap_or_else(|p| p.into_inner())
27669 .len(),
27670 50
27671 );
27672 for id in ids.iter().step_by(2) {
27673 shared.sessionize_spawn(*id);
27674 }
27675 assert_eq!(
27676 shared
27677 .provisional_spawns
27678 .lock()
27679 .unwrap_or_else(|p| p.into_inner())
27680 .len(),
27681 25
27682 );
27683 assert!(shared.take_due_provisional_spawns().is_empty());
27684 {
27685 let mut map = shared
27686 .provisional_spawns
27687 .lock()
27688 .unwrap_or_else(|p| p.into_inner());
27689 let past = Instant::now() - Duration::from_secs(30);
27690 for (_, (_, at)) in map.iter_mut() {
27691 *at = past;
27692 }
27693 }
27694 let due = shared.take_due_provisional_spawns();
27695 assert_eq!(due.len(), 25);
27696 let due_ids: std::collections::BTreeSet<_> =
27697 due.iter().map(|a| a.session.instance_id.0).collect();
27698 let expected: std::collections::BTreeSet<_> =
27699 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
27700 assert_eq!(due_ids, expected);
27701 assert!(shared
27702 .provisional_spawns
27703 .lock()
27704 .unwrap_or_else(|p| p.into_inner())
27705 .is_empty());
27706 }
27707
27708 #[test]
27711 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
27712 let shared = Arc::new(shared());
27713 let ws = WorkspaceId::new("primary").unwrap();
27714 let mut handles = Vec::new();
27715 for i in 1u64..=20 {
27716 let shared = Arc::clone(&shared);
27717 let ws = ws.clone();
27718 handles.push(std::thread::spawn(move || {
27719 let id = AgentInstanceId(i);
27720 let address = SessionAddress {
27721 workspace_id: ws,
27722 session: SessionKey {
27723 instance_id: id,
27724 generation: SessionGeneration(1),
27725 },
27726 };
27727 shared.mark_spawn_provisional(address);
27728 id
27729 }));
27730 }
27731 let mut ids = Vec::new();
27732 for h in handles {
27733 ids.push(h.join().unwrap());
27734 }
27735 ids.sort_by_key(|id| id.0);
27736 assert_eq!(ids.len(), 20);
27737 ids.dedup();
27738 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
27739 assert_eq!(
27740 shared
27741 .provisional_spawns
27742 .lock()
27743 .unwrap_or_else(|p| p.into_inner())
27744 .len(),
27745 20
27746 );
27747 shared.sessionize_spawn(AgentInstanceId(7));
27748 let left: Vec<_> = shared
27749 .provisional_spawns
27750 .lock()
27751 .unwrap_or_else(|p| p.into_inner())
27752 .keys()
27753 .map(|id| id.0)
27754 .collect();
27755 assert!(!left.contains(&7));
27756 assert_eq!(left.len(), 19);
27757 }
27758}
27759