1use crate::git_worktree::{
2 create_worktree as create_git_worktree,
3 create_worktree_with_timeout as create_git_worktree_with_timeout,
4 list_worktrees as list_git_worktrees,
5 list_worktrees_with_deadline as list_git_worktrees_with_deadline,
6 paths_equal as worktree_paths_equal, remove_worktree as remove_git_worktree,
7 removal_lookup_path as normalize_worktree_removal_target,
8 resolve_base_commit_with_timeout,
9 run_git_read_bounded, GitCommandOutput, GitWorktreeError, GitWorktreeErrorKind,
10 NativeGitWorktreeSnapshot,
11};
12use crate::host_directory_browser::{
13 browse_host_directories, HostDirectoryBrowseError, HostDirectoryBrowseErrorKind,
14};
15use crate::environment_profiles::{
16 EnvironmentProfileBinding, NodeEnvironmentProfile, MAX_NODE_ENVIRONMENT_PROFILES,
17};
18use crate::bundle_catalog::{BundleCatalog, NodeBundle};
19use self::bundle_delivery::{DeliveryStore, DeliveryStoreError};
20use self::context_pack_store::ContextPackStore;
21use crate::bundle_provider::{
22 bundle_launch_arguments, validate_bundle_binding, BundleProviderLayout,
23};
24use crate::context_pack::{
25 ContextPackCatalog, ContextPackRepository, ContextPackRepositoryFileSource,
26 ContextPackSelectedFileSkipReason, NodeContextPack, CONTEXT_PACK_SELECTED_FILES,
27};
28use crate::harness_mcp_proxy::{
29 HarnessMcpProxyError, HarnessMcpProxyRegistry, PreparedHarnessMcpSpawn,
30 ReviewedHarnessMcpProgram,
31};
32use crate::session_environment::{
33 MaterializationId, MaterializationOwner, MaterializationOwnershipRecord,
34 MaterializationState, NodeSecretResolver, NodeSessionMaterializationProfile,
35 SessionEnvironmentMaterializeError, SessionEnvironmentMaterializer,
36};
37use crate::worktree_service::{
38 exact_created_worktree, exact_owned_worktree, ManagedWorktreeLeaseRecord,
39 ManagedWorktreeProfile, ManagedWorktreeRegistry, ManagedWorktreeSessionHolder,
40};
41use crate::session_registry::{
42 self, validate_display_name, LoadedNodeState, ManagedWorktreeSpawnReplayRecordV10,
43 ManagedWorktreeSpawnReplayStateV10, MAX_MANAGED_SESSION_RECORDS,
44 MAX_MANAGED_WORKTREE_SPAWN_REPLAYS,
45};
46#[cfg(unix)]
47use crate::workspace_file_unix::{
48 create_workspace_directory as create_workspace_directory_on_disk,
49 create_workspace_file as create_workspace_file_on_disk,
50 read_workspace_file as read_workspace_file_from_disk,
51 write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
52 WorkspaceFileReadError, WorkspaceFileReadErrorKind,
53 WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
54 WORKSPACE_ENTRY_CREATE_PENDING,
55};
56#[cfg(windows)]
57use crate::workspace_file_windows::{
58 create_workspace_directory as create_workspace_directory_on_disk,
59 create_workspace_file as create_workspace_file_on_disk,
60 read_workspace_file as read_workspace_file_from_disk,
61 write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
62 WorkspaceFileReadError, WorkspaceFileReadErrorKind,
63 WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
64 WORKSPACE_ENTRY_CREATE_PENDING,
65};
66use crate::platform;
67use crate::provider_runtime::{
68 require_policy, ProviderRuntimeAdmissionError, ProviderRuntimeMonitor,
69 ProviderRuntimeRequirement,
70};
71use crate::spawn_spec::SpawnProfileRegistry;
72use crate::standalone_workspace::{
73 prepare_standalone_workspace, StandaloneWorkspaceError, StandaloneWorkspaceErrorKind,
74};
75use crate::protocol::{
76 read_json_frame_limited_body_timeout, write_json_frame, write_json_frame_limited,
77 validate_node_negotiated_handshake_capacity, validate_provider_contract_manifest,
78 provider_id_is_legacy, AdapterContractRevision, AgentStreamChunkKindV1, AgentStreamChunkV1,
79 AgentStreamInteractionOptionV1, AgentStreamNamedIdV1,
80 CapabilityId, ClientFrame, ClientRole,
81 ControllerState, DeliveryBlobChunkHexV1, DeliveryBlobDigestV1,
82 DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryStageId,
83 AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
84 AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, FrameError,
85 GitCommitDetails, GitDiff, GitDiffMode, GitDiffRequest, GitHistoryPage,
86 GitObjectId, GitCommitSummary, GitSignatureStatus, GitSnapshot, GitStatusEntry,
87 GitWorktreeSnapshot, HarnessMcpActivationDigest, HarnessMcpReservationId,
88 HostDirectoryListing,
89 NodeCompatibilitySupport, NodeEvent,
90 NodeEventEnvelope, NodeFailure, NodeFailureCode, NodeHello, NodeId, NodeIncarnationId,
91 NegotiatedNodeCompatibility, NodeRequest, NodeResponse, NodeSnapshot, OpaqueHostPath,
92 ProtocolRange,
93 ProviderAdapterContractSupport,
94 ProviderContractRevision, ProviderContractSupport, RepositoryPath, RequestEnvelope,
95 ProviderRuntimeStatuses, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
96 ContextPackLineageReceipt, ContextPackBytesRead, ResolvedContextPackReceipt,
97 ResolvedHarnessMcpProxyReceiptV1, ResolvedSpawnReceipt,
98 ResolvedSpawnSpec, ResponseEnvelope,
99 ServerChallenge, ServerFrame, SessionAddress, SessionAgentProgress, SessionKey, SessionMode,
100 ManagedSessionRecord,
101 ManagedSessionState, ManagedWorktreeCleanupFailure, ManagedWorktreeLeaseId,
102 SessionRecordRetentionConfig,
103 ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState, ManagedWorktreeRetention,
104 ManagedWorktreeSpawnReceipt, ManagedWorktreeSpawnRequest,
105 ManagedWorktreeSpawnRequestV2, SessionRecordId,
106 NativeSessionCatalogRoute, NativeSessionSelection, SessionTaskBindingV1,
107 SessionTaskTargetV1, TaskId,
108 BlockAuthorityV1, HarnessMcpLaunchV1, SessionHistorySummaryV1, MAX_ACP_BLOCKED_HELP_BYTES, MAX_ACP_BLOCKED_REASON_BYTES,
109 StateSchemaSupport, WorkspaceEntry, WorkspaceEntryKind, WorktreeProfileId,
110 SpawnContextId, SpawnEnvironmentProfileId, SpawnIdempotencyKey,
111 SpawnProfileDefaults, SpawnRequiredCapabilities, SpawnSpec, SpawnSpecResolveError,
112 WorkspaceFileContent, WorkspaceFileRevision,
113 WorkspaceFileRead, WorkspaceId, WorkspaceInspection, WorkspaceInspectionTruncationV1,
114 WorkspaceSnapshot,
115 WorktreeServiceMode,
116 LaunchInventory, ManagedWorktreeProfileSummary, SpawnProfileSummary,
117 WorktreeProfileInventory, MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
118 DEFAULT_CONTROLLER_LEASE_MS,
119 MAX_CONTROLLER_LEASE_MS, MIN_CONTROLLER_LEASE_MS, NODE_COMPATIBILITY_METADATA_CAPABILITY,
120 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
121 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, NODE_REPOSITORY_PATH_CAPABILITY,
122 NODE_PROVIDER_ID_OPEN_CAPABILITY,
123 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
124 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY, NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
125 NODE_HISTORY_CONTEXT_PACK_CAPABILITY, NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
126 NODE_NATIVE_SESSION_CATALOG_CAPABILITY, NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
127 NODE_NATIVE_SESSION_INDEX_CAPABILITY, NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
128 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
129 NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
130 NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
131 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
132 NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
133 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
134 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
135 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
136 NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
137 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
138 NODE_AGENT_STREAM_EVENTS_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY,
139 NODE_GIT_READ_CAPABILITY, NODE_WORKSPACE_FILE_READ_CAPABILITY,
140 NODE_WORKSPACE_FILE_WRITE_CAPABILITY, NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
141 NODE_WORKTREE_SELECTION_CAPABILITY,
142 BUILD_STAMP, MAX_NODE_CLIENT_FRAME_BYTES, MAX_NODE_HELLO_FRAME_BYTES,
143 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS, MAX_AGENT_PROGRESS_ENTRY_BYTES,
144 MAX_NODE_TERMINAL_BYTES, MAX_NODE_TEXT_BYTES,
145 MAX_REPOSITORY_PATH_BYTES,
146 MAX_GIT_DIFF_BYTES, MAX_GIT_HISTORY_COMMITS, MAX_WORKSPACE_FILE_BYTES,
147 MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE,
148 MAX_WORKSPACE_ROOT_BYTES, NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10,
149 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY, SPAWN_RUNTIME_RAW_PTY_LIFECYCLE,
150 SPAWN_RUNTIME_SEMANTIC_READINESS, SPAWN_RUNTIME_SEMANTIC_RESUME,
151 SPAWN_RUNTIME_STRUCTURED_PROMPT,
152};
153#[cfg(test)]
154use crate::protocol::WorktreeProfileRevision;
155use ring::digest::{digest, SHA256};
156use crate::protocol::correlation::{
157 interaction_correlation, sanitize_progress_tool_label, tool_class_label, tool_correlation,
158 truncate_text,
159};
160use gate4agent_catalog::{builtin_registry, AgentRegistry};
161use gate4agent_handle::{EventSubscription, Gate4AgentHandle, PortDispatchError};
162use gate4agent_runtime_native::{
163 NativeMcpServerLaunchOverlay, NativeInstanceLaunchOverlay,
164 NativeLaunchEnvironmentOverlay,
165 NativeHistoryConfig, NativeLaunchProfileControl, NativeRuntime, NativeRuntimeConfig,
166 NativeSessionCatalogAuthority, NativeSessionCatalogError, NativeSessionPreviewError,
167 ScopedNativeSessionCatalogEntry,
168};
169use gate4agent_runtime_native::shell_efficiency::ShellEfficiencyProfile;
170use std::sync::OnceLock;
171use gate4agent_runtime_native::tick_profile::TickProfileSnapshot;
172use drive_loop_profile::DriveLoopProfiler;
173#[cfg(test)]
174use gate4agent_runtime_native::{HistorySourceLayout, NativeHistoryRoot};
175use gate4agent_node_wire::{
176 auth_proof, negotiated_auth_proof, proofs_match, random_incarnation_id, random_nonce,
177 AuthDirection, OwnerOnlyLocalListener,
178};
179use gate4agent_types::{
180 AdapterBinding, AdapterFamily, AgentAdapterCapabilities, AgentCapabilities, AgentId,
181 AgentInstanceId, AgentReadinessSpec, AgentSpec, AgentTransportCapabilities, ApprovalLevel,
182 DetectionSpec, InitialPromptMode, LaunchSpec, ProcessMatcher, PromptSpec, SpecVerification,
183 CommandEnvelope, CommandId,
184 validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
185 HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
186 HostDecisionAuthority as ProviderHostDecisionAuthority,
187 HostRequestDecision as ProviderHostRequestDecision,
188 InputAction,
189 PromptFraming, PromptPayload, ResumeLaunchRequest,
190 ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
191 ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
192 ProviderSnapshot, ProviderStopReason,
193 ResumeTarget,
194 SessionGeneration, StartRequest,
195 TerminalControl, TerminalText,
196 SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
197 HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
198};
199use std::collections::{BTreeMap, VecDeque};
200use std::ffi::OsString;
201use std::io;
202use std::path::{Path, PathBuf};
203use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
204use std::sync::{Arc, Mutex, RwLock};
205use std::time::{Duration, Instant};
206use thiserror::Error;
207use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
208use tokio::task::{AbortHandle, JoinSet};
209use tokio::time::{sleep, timeout};
210
211mod http_api;
212pub mod node_wake;
213
214mod browser_bridge;
215
216use gate4agent_node_core::drive_loop_profile;
217
218#[path = "bundle_delivery.rs"]
219mod bundle_delivery;
220
221#[path = "context_pack_store.rs"]
222mod context_pack_store;
223
224#[cfg(windows)]
225pub use crate::platform::DEFAULT_NODE_ENDPOINT;
226
227const NODE_EVENT_HISTORY_MAX: usize = 4_096;
228const NODE_BROADCAST_CAPACITY: usize = 1_024;
229const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
230const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
236const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
243const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
244const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
245const MAX_PREAUTH_CONNECTIONS: usize = 32;
246const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
247const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
248const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
249const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
250const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
251const PROVISIONAL_SPAWN_GRACE_MS: u64 = 30_000;
257const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
265const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
273const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
280const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
285const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
286const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
287const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
288const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
289const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
290const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
291const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
292const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
293const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
294const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
295const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
296const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
297const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
300const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
307const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
310const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
311const GIT_STATUS_MAX_ENTRIES: usize = 128;
312const GIT_COMMIT_MAX_ENTRIES: usize = 12;
313const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
314const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
315const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
316const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
317const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
318const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
319const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
320const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
321const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
322const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
323 "provider-session-identity-allocation-failed";
324const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
325const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
326const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
327const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
328const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
329const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
330const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
331 "durable-state-path-semantics-unsupported";
332
333fn opaque_windows_path(value: String) -> OpaqueHostPath {
334 OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
335}
336
337fn agent_progress_from_provider_snapshot(
338 address: SessionAddress,
339 provider: &ProviderSnapshot,
340) -> Option<SessionAgentProgress> {
341 let mut truncated = false;
342 let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
343 let mut active_tool_labels = Vec::with_capacity(
344 provider
345 .active_tools
346 .len()
347 .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
348 );
349 for tool in &provider.active_tools {
350 if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
351 truncated = true;
352 break;
353 }
354 if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
355 active_tool_labels.push(label);
356 }
357 }
358 let attention = provider
359 .interactions
360 .iter()
361 .find(|interaction| {
362 matches!(
363 interaction.status,
364 ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
365 )
366 })
367 .map(|interaction| AgentProgressAttentionV1 {
368 kind: match interaction.interaction_kind {
369 ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
370 ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
371 },
372 tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
373 });
374 let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
375 let usage = (provider.completed_turns > 0
376 || provider.usage.input_tokens > 0
377 || provider.usage.output_tokens > 0
378 || provider.usage.cache_read_tokens > 0
379 || provider.usage.cache_write_tokens > 0
380 || provider.usage.reasoning_tokens > 0)
381 .then_some(AgentProgressUsageV1 {
382 input_tokens: provider.usage.input_tokens,
383 output_tokens: provider.usage.output_tokens,
384 cache_read_tokens: provider.usage.cache_read_tokens,
385 cache_write_tokens: provider.usage.cache_write_tokens,
386 reasoning_tokens: provider.usage.reasoning_tokens,
387 });
388 let progress = AgentProgressV1 {
389 provider_sequence: provider.sequence,
390 activity: provider.activity,
391 completed_turns: provider.completed_turns,
392 usage,
393 current: AgentProgressCurrentV1::from(provider.activity),
394 active_tool_labels,
395 active_tool_count,
396 attention,
397 subagent_count,
398 last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
399 gap_count: provider.gap_count,
400 stale: provider.stale,
401 truncated,
402 };
403 let entry = SessionAgentProgress { address, progress };
404 serde_json::to_vec(&entry)
405 .ok()
406 .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
407 .map(|_| entry)
408}
409
410fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
411 match u32::try_from(count) {
412 Ok(count) => count,
413 Err(_) => {
414 *truncated = true;
415 u32::MAX
416 }
417 }
418}
419
420fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
421 if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
422 return None;
423 }
424 Some(match event {
425 ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
426 ProviderEvent::SessionIdentityObserved { .. } => {
427 AgentProgressEventKindV1::SessionIdentityObserved
428 }
429 ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
430 ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
431 ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
432 ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
433 ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
434 ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
435 ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
436 ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
437 ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
438 ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
439 ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
440 ProviderEvent::InteractionRequested { .. } => {
441 AgentProgressEventKindV1::InteractionRequested
442 }
443 ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
444 ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
445 ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
446 ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
447 ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
448 ProviderEvent::UnrecognizedNotification { .. } => {
449 AgentProgressEventKindV1::UnrecognizedNotification
450 }
451 ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
452 ProviderEvent::UserMessage { .. }
463 | ProviderEvent::Plan { .. }
464 | ProviderEvent::AvailableCommandsUpdated { .. }
465 | ProviderEvent::ModeChanged { .. }
466 | ProviderEvent::SessionInfoUpdated { .. }
467 | ProviderEvent::UsageUpdated { .. }
468 | ProviderEvent::ConfigOptionsUpdated { .. } => {
469 AgentProgressEventKindV1::UnrecognizedNotification
470 }
471 })
472}
473
474fn provider_family_is_live(family: AdapterFamily) -> bool {
487 match family {
488 AdapterFamily::PtySemantic
489 | AdapterFamily::Hook
490 | AdapterFamily::ManagedHook
491 | AdapterFamily::Pipe
492 | AdapterFamily::OneShot
493 | AdapterFamily::Acp => true,
494 AdapterFamily::History
495 | AdapterFamily::Resume
496 | AdapterFamily::SessionOptions
497 | AdapterFamily::CapabilityProbe => false,
498 }
499}
500
501fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
502 match by {
503 ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
504 ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
505 ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
506 ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
507 }
508}
509
510fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
522 match authority {
523 BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
524 BlockAuthorityV1::HostPolicy => "denied by host policy",
525 BlockAuthorityV1::HostDeadline => {
526 "denied by host policy after the operator deadline expired"
527 }
528 BlockAuthorityV1::Operator => "declined by the operator",
529 BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
530 BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
531 BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
532 BlockAuthorityV1::ProviderRefusal => "refused by the provider",
533 BlockAuthorityV1::UserRejected => "rejected by the user",
534 BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
535 BlockAuthorityV1::Unknown => "blocked; no reason was reported",
536 }
537}
538
539fn host_request_denied_block(
542 method: &str,
543 by: ProviderHostDecisionAuthority,
544 reason: Option<&str>,
545) -> (String, BlockAuthorityV1, String) {
546 let authority = block_authority(by);
547 let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
548 let (reason_text, _) =
552 truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
553 (tool_class_label(method), authority, reason_text)
554}
555
556fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
575 match kind? {
576 "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
577 "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
578 _ => None,
579 }
580}
581
582fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
594 let trimmed = text.trim();
595 let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
596 return (trimmed.to_owned(), None);
597 };
598 let tail = after_reason.trim_start();
599 let Some((reason, help)) = tail.split_once(". ") else {
600 let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
604 return (reason.to_owned(), None);
605 };
606 let help = help.trim();
607 if help.is_empty() {
608 (reason.trim().to_owned(), None)
609 } else {
610 (reason.trim().to_owned(), Some(help.to_owned()))
611 }
612}
613
614fn tool_class_from_denial_help(help: Option<&str>) -> String {
624 help.and_then(|text| text.split("add a ").nth(1))
625 .and_then(|tail| tail.split(' ').next())
626 .filter(|word| !word.is_empty())
627 .map(str::to_owned)
628 .unwrap_or_else(|| "Tool".to_owned())
629}
630
631fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
639 fn names_quota(s: &str) -> bool {
640 let lower = s.to_ascii_lowercase();
641 lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
642 }
643 vendor_code.is_some_and(names_quota) || names_quota(message)
644}
645
646fn provider_session_end_block(
662 stop_reason: &ProviderStopReason,
663) -> Option<(BlockAuthorityV1, String, String)> {
664 match stop_reason {
665 ProviderStopReason::Refusal => Some((
666 BlockAuthorityV1::ProviderRefusal,
667 "refusal".to_owned(),
668 "the provider ended the turn with stopReason: refusal".to_owned(),
669 )),
670 ProviderStopReason::ProviderError { message, vendor_code, .. }
671 if provider_error_is_quota(vendor_code.as_deref(), message) =>
672 {
673 let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
674 Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
675 }
676 _ => None,
677 }
678}
679
680fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
721 let ControlEventKind::ProviderEvent {
722 sequence: provider_sequence,
723 source,
724 source_sequence,
725 event: provider_event,
726 } = &event.event
727 else {
728 return None;
729 };
730 if !provider_family_is_live(source.family) {
731 return None;
732 }
733 let kind = match provider_event {
734 ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
735 text: text.clone(),
736 is_delta: *is_delta,
737 },
738 ProviderEvent::Thinking { text } => {
739 AgentStreamChunkKindV1::Thinking { text: text.clone() }
740 }
741 ProviderEvent::InteractionRequested {
742 interaction_kind,
743 tool_name,
744 title,
745 prompt,
746 options,
747 ..
748 } => AgentStreamChunkKindV1::InteractionPrompt {
749 correlation_id: interaction_correlation(
750 event.instance_id,
751 event.generation,
752 *provider_sequence,
753 ),
754 interaction_kind: *interaction_kind,
755 tool_name: tool_name.clone(),
756 title: title.clone(),
765 prompt: prompt.clone(),
766 options: options
767 .iter()
768 .map(|option| AgentStreamInteractionOptionV1 {
769 option_id: option.option_id.clone(),
770 name: option.name.clone(),
771 kind: option.kind.clone(),
772 })
773 .collect(),
774 },
775 ProviderEvent::ConfigOptionsUpdated { options } => {
776 AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
777 }
778 ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
789 current: Some(mode_id.clone()),
790 available: available
791 .iter()
792 .map(|mode| AgentStreamNamedIdV1 {
793 id: mode.id.clone(),
794 name: mode.name.clone(),
795 description: mode.description.clone(),
796 })
797 .collect(),
798 },
799 ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
803 let ProviderHostRequestDecision::Denied { by } = decision else {
804 return None;
805 };
806 let (tool_class, authority, reason_text) =
807 host_request_denied_block(method, *by, reason.as_deref());
808 AgentStreamChunkKindV1::Blocked {
809 correlation_id: None,
810 tool_class,
811 authority,
812 reason_kind: None,
813 reason: reason_text,
814 help: None,
815 }
816 }
817 ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
821 let (authority, reason_kind) =
822 provider_tool_block_authority(non_execution_kind.as_deref())?;
823 let (reason, help) = split_provider_denial_text(output);
824 let (reason, _) =
825 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
826 let help = help.map(|help| {
827 truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
828 });
829 AgentStreamChunkKindV1::Blocked {
830 correlation_id: Some(tool_correlation(event, source, id)),
831 tool_class: tool_class_from_denial_help(help.as_deref()),
832 authority,
833 reason_kind: Some(reason_kind.to_owned()),
834 reason,
835 help,
836 }
837 }
838 ProviderEvent::SessionEnded { stop_reason, .. } => {
842 let (authority, reason_kind, reason) =
843 stop_reason.as_ref().and_then(provider_session_end_block)?;
844 let (reason, _) =
845 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
846 AgentStreamChunkKindV1::Blocked {
847 correlation_id: None,
848 tool_class: "Session".to_owned(),
849 authority,
850 reason_kind: Some(reason_kind),
851 reason,
852 help: None,
853 }
854 }
855 ProviderEvent::SessionStarted { .. }
856 | ProviderEvent::SessionIdentityObserved { .. }
857 | ProviderEvent::TurnStarted { .. }
858 | ProviderEvent::WorkingObserved
859 | ProviderEvent::ToolStarted { .. }
860 | ProviderEvent::TurnCompleted { .. }
861 | ProviderEvent::ContextWindowUsage { .. }
862 | ProviderEvent::TurnInterrupted
863 | ProviderEvent::Error { .. }
864 | ProviderEvent::Ready
865 | ProviderEvent::InteractionResolved { .. }
866 | ProviderEvent::SubagentStarted { .. }
867 | ProviderEvent::SubagentStopped { .. }
868 | ProviderEvent::RateLimited { .. }
869 | ProviderEvent::UnrecognizedNotification { .. }
870 | ProviderEvent::UserMessage { .. }
871 | ProviderEvent::Plan { .. }
872 | ProviderEvent::AvailableCommandsUpdated { .. }
873 | ProviderEvent::SessionInfoUpdated { .. }
874 | ProviderEvent::UsageUpdated { .. } => return None,
875 };
876 Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
877}
878
879async fn run_native_session_catalog_operation<T, E, F>(
880 catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
881 operation: &'static str,
882 execute: F,
883) -> Result<T, NodeFailure>
884where
885 T: Send + 'static,
886 E: std::fmt::Display + Send + 'static,
887 F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
888{
889 let task = tokio::task::spawn_blocking(move || {
890 let mut catalog = match catalog.try_lock() {
891 Ok(catalog) => catalog,
892 Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
893 Err(std::sync::TryLockError::WouldBlock) => {
894 return Err(failure(
895 NodeFailureCode::BackendBusy,
896 "native session catalog operation is already running",
897 ));
898 }
899 };
900 execute(&mut catalog).map_err(|error| {
901 failure(
902 NodeFailureCode::BackendOperationFailed,
903 &format!("{operation} failed: {error}"),
904 )
905 })
906 });
907 timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
908 .await
909 .map_err(|_| {
910 failure(
911 NodeFailureCode::BackendBusy,
912 &format!("{operation} exceeded its bounded deadline"),
913 )
914 })?
915 .map_err(|_| {
916 failure(
917 NodeFailureCode::BackendOperationFailed,
918 &format!("{operation} task failed"),
919 )
920 })?
921}
922
923fn windows_path_text(path: &OpaqueHostPath) -> &str {
924 path.as_utf8()
925 .expect("Windows node only creates UTF-8 opaque host paths")
926}
927
928fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
929 path.as_utf8()
930 .map(str::to_owned)
931 .ok_or_else(|| failure(
932 NodeFailureCode::InvalidRequest,
933 "this node requires UTF-8 host paths",
934 ))
935}
936
937fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
938 GitWorktreeSnapshot {
939 path: opaque_windows_path(worktree.path),
940 head: worktree.head,
941 branch: worktree.branch,
942 is_bare: worktree.is_bare,
943 is_main: worktree.is_main,
944 locked: worktree.locked,
945 lock_reason: worktree.lock_reason,
946 prunable: worktree.prunable,
947 prunable_reason: worktree.prunable_reason,
948 workspace_id: worktree.workspace_id.take(),
949 }
950}
951
952#[derive(Clone, Debug, Eq, PartialEq)]
953pub struct WorkspaceConfig {
954 workspace_id: WorkspaceId,
955 canonical_root: String,
956 worktree_service_mode: WorktreeServiceMode,
957 managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
958}
959
960impl WorkspaceConfig {
961 pub fn new(
962 workspace_id: WorkspaceId,
963 root: impl AsRef<Path>,
964 ) -> Result<Self, NodeServerError> {
965 let root = root.as_ref();
966 if !root.is_absolute() {
967 return Err(NodeServerError::InvalidWorkspaceRoot {
968 workspace_id,
969 path: root.to_string_lossy().into_owned(),
970 message: "path is not absolute".to_owned(),
971 });
972 }
973 let canonical = std::fs::canonicalize(root).map_err(|error| {
974 NodeServerError::InvalidWorkspaceRoot {
975 workspace_id: workspace_id.clone(),
976 path: root.to_string_lossy().into_owned(),
977 message: error.to_string(),
978 }
979 })?;
980 if !canonical.is_dir() {
981 return Err(NodeServerError::InvalidWorkspaceRoot {
982 workspace_id,
983 path: root.to_string_lossy().into_owned(),
984 message: "path is not a directory".to_owned(),
985 });
986 }
987 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
988 NodeServerError::InvalidWorkspaceRoot {
989 workspace_id: workspace_id.clone(),
990 path: path.to_string_lossy().into_owned(),
991 message: "canonical path is not valid Unicode".to_owned(),
992 }
993 })?;
994 let canonical_root = platform::normalize_canonical_root(canonical_root);
995 validate_workspace_root(&workspace_id, &canonical_root)?;
996 Ok(Self {
997 workspace_id,
998 canonical_root,
999 worktree_service_mode: WorktreeServiceMode::Manual,
1000 managed_worktree_profiles: BTreeMap::new(),
1001 })
1002 }
1003
1004 pub fn workspace_id(&self) -> &WorkspaceId {
1005 &self.workspace_id
1006 }
1007
1008 pub fn canonical_root(&self) -> &str {
1009 &self.canonical_root
1010 }
1011
1012 pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1013 self.worktree_service_mode = mode;
1014 self
1015 }
1016
1017 pub fn with_managed_worktree_profile(
1018 mut self,
1019 profile: ManagedWorktreeProfile,
1020 ) -> Result<Self, NodeServerError> {
1021 profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1022 NodeServerError::InvalidManagedWorktreeProfile {
1023 workspace_id: self.workspace_id.clone(),
1024 profile_id: profile.profile_id().clone(),
1025 message,
1026 }
1027 })?;
1028 if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1029 return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1030 workspace_id: self.workspace_id,
1031 profile_id: profile.profile_id().clone(),
1032 });
1033 }
1034 if self.managed_worktree_profiles.len()
1035 == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1036 {
1037 return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1038 workspace_id: self.workspace_id,
1039 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1040 });
1041 }
1042 self.managed_worktree_profiles
1043 .insert(profile.profile_id().clone(), profile);
1044 Ok(self)
1045 }
1046}
1047
1048#[derive(Clone)]
1049pub struct NodeServerConfig {
1050 pub endpoint: String,
1051 api_listen: Option<std::net::SocketAddr>,
1052 bridge_listen: Option<std::net::SocketAddr>,
1055 bridge_token: Option<String>,
1058 bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1061 call_home: Option<std::net::SocketAddr>,
1064 call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1068 pub node_id: NodeId,
1069 pub workspaces: Vec<WorkspaceConfig>,
1070 access_token: String,
1071 pub runtime: NativeRuntimeConfig,
1072 session_record_retention: SessionRecordRetentionConfig,
1073 state_path: Option<PathBuf>,
1074 spawn_profiles: SpawnProfileRegistry,
1075 session_environment: Option<NodeSessionEnvironmentConfig>,
1076 history: Option<NativeHistoryConfig>,
1077 harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1078 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1082 #[cfg(feature = "fixture")]
1083 fixture_raw_pty_runtime: bool,
1084}
1085
1086#[derive(Clone)]
1087struct NodeSessionEnvironmentConfig {
1088 root: PathBuf,
1089 resolver: Arc<dyn NodeSecretResolver>,
1090}
1091
1092impl NodeServerConfig {
1093 pub fn new(
1094 endpoint: impl Into<String>,
1095 access_token: impl Into<String>,
1096 node_id: NodeId,
1097 workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1098 ) -> Result<Self, NodeServerError> {
1099 let endpoint = endpoint.into();
1100 let access_token = access_token.into();
1101 if !platform::validate_endpoint(&endpoint) {
1102 return Err(NodeServerError::InvalidEndpoint);
1103 }
1104 if access_token.is_empty() || access_token.len() > 4_096 {
1105 return Err(NodeServerError::InvalidAccessToken);
1106 }
1107 let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1108 if workspaces.is_empty() {
1109 return Err(NodeServerError::NoWorkspaces);
1110 }
1111 let mut workspace_ids = BTreeMap::new();
1112 let mut workspace_roots = BTreeMap::new();
1113 for workspace in &workspaces {
1114 if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1115 && workspace.managed_worktree_profiles.is_empty()
1116 {
1117 return Err(NodeServerError::ManagedWorktreeProfileRequired(
1118 workspace.workspace_id.clone(),
1119 ));
1120 }
1121 if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1122 && !workspace.managed_worktree_profiles.is_empty()
1123 {
1124 return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1125 workspace.workspace_id.clone(),
1126 ));
1127 }
1128 if workspace_ids
1129 .insert(workspace.workspace_id.clone(), ())
1130 .is_some()
1131 {
1132 return Err(NodeServerError::DuplicateWorkspaceId(
1133 workspace.workspace_id.clone(),
1134 ));
1135 }
1136 let root_key = platform::root_identity(&workspace.canonical_root);
1137 if let Some(existing) = workspace_roots.insert(
1138 root_key,
1139 workspace.workspace_id.clone(),
1140 ) {
1141 return Err(NodeServerError::DuplicateWorkspaceRoot {
1142 first: existing,
1143 second: workspace.workspace_id.clone(),
1144 root: workspace.canonical_root.clone(),
1145 });
1146 }
1147 }
1148 Ok(Self {
1149 endpoint,
1150 api_listen: None,
1151 bridge_listen: None,
1152 bridge_token: None,
1153 bridge_underlay: None,
1154 call_home: None,
1155 call_home_identity: None,
1156 node_id,
1157 workspaces,
1158 access_token,
1159 runtime: NativeRuntimeConfig {
1162 worker_poll_interval_ms: 4,
1163 ..NativeRuntimeConfig::default()
1164 },
1165 session_record_retention: SessionRecordRetentionConfig::default(),
1166 state_path: None,
1167 spawn_profiles: SpawnProfileRegistry::default(),
1168 session_environment: None,
1169 history: None,
1170 harness_mcp_helper: None,
1171 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1172 #[cfg(feature = "fixture")]
1173 fixture_raw_pty_runtime: false,
1174 })
1175 }
1176
1177 pub fn with_api_listen(
1178 mut self,
1179 api_listen: std::net::SocketAddr,
1180 ) -> Result<Self, NodeServerError> {
1181 if !api_listen.ip().is_loopback() {
1182 return Err(NodeServerError::InvalidApiListen(api_listen));
1183 }
1184 self.api_listen = Some(api_listen);
1185 Ok(self)
1186 }
1187
1188 pub fn with_bridge_listen(
1192 mut self,
1193 bridge_listen: std::net::SocketAddr,
1194 ) -> Result<Self, NodeServerError> {
1195 if !bridge_listen.ip().is_loopback() {
1196 return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1197 }
1198 self.bridge_listen = Some(bridge_listen);
1199 Ok(self)
1200 }
1201
1202 pub fn with_bridge_token(
1205 mut self,
1206 bridge_token: impl Into<String>,
1207 ) -> Result<Self, NodeServerError> {
1208 let bridge_token = bridge_token.into();
1209 if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1210 return Err(NodeServerError::InvalidBridgeToken);
1211 }
1212 self.bridge_token = Some(bridge_token);
1213 Ok(self)
1214 }
1215
1216 pub fn with_bridge_underlay(
1221 mut self,
1222 bind: std::net::SocketAddr,
1223 transport_key: [u8; 32],
1224 underlay_token: impl Into<String>,
1225 ) -> Result<Self, NodeServerError> {
1226 if self.bridge_listen.is_none() {
1227 return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1228 }
1229 if self.bridge_token.is_none() {
1230 return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1231 }
1232 let underlay_token = underlay_token.into();
1233 if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1234 return Err(NodeServerError::InvalidBridgeUnderlayToken);
1235 }
1236 self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1238 bind,
1239 transport_key,
1240 underlay_token,
1241 });
1242 Ok(self)
1243 }
1244
1245 pub fn with_call_home(
1266 mut self,
1267 relay: std::net::SocketAddr,
1268 ) -> Result<Self, NodeServerError> {
1269 if relay.port() == 0 || relay.ip().is_unspecified() {
1272 return Err(NodeServerError::InvalidCallHome(relay));
1273 }
1274 self.call_home = Some(relay);
1275 Ok(self)
1276 }
1277
1278 pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1280 self.call_home_identity = Some(identity);
1281 self
1282 }
1283
1284 pub fn with_state_path(
1285 mut self,
1286 state_path: impl Into<PathBuf>,
1287 ) -> Result<Self, NodeServerError> {
1288 let state_path = state_path.into();
1289 if !state_path.is_absolute() || state_path.file_name().is_none() {
1290 return Err(NodeServerError::InvalidStatePath(
1291 state_path.to_string_lossy().into_owned(),
1292 ));
1293 }
1294 self.state_path = Some(state_path);
1295 Ok(self)
1296 }
1297
1298 pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1299 self.spawn_profiles = spawn_profiles;
1300 self
1301 }
1302
1303 pub fn with_session_record_retention(
1309 mut self,
1310 retention: SessionRecordRetentionConfig,
1311 ) -> Self {
1312 self.session_record_retention = retention;
1313 self
1314 }
1315
1316 pub fn with_session_environment_materialization(
1317 mut self,
1318 root: impl Into<PathBuf>,
1319 resolver: Arc<dyn NodeSecretResolver>,
1320 ) -> Result<Self, NodeServerError> {
1321 let root = root.into();
1322 if !root.is_absolute() {
1323 return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1324 }
1325 self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1326 Ok(self)
1327 }
1328
1329 pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1332 self.history = Some(history);
1333 self
1334 }
1335
1336 pub fn with_harness_mcp_helper(
1342 mut self,
1343 helper_program: impl Into<PathBuf>,
1344 ) -> Result<Self, NodeServerError> {
1345 self.harness_mcp_helper = Some(
1346 ReviewedHarnessMcpProgram::review(helper_program.into())
1347 .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1348 );
1349 Ok(self)
1350 }
1351
1352 pub fn with_network_allowlist_catalog(
1357 mut self,
1358 catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1359 ) -> Result<Self, NodeServerError> {
1360 if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1361 return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1362 max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1363 });
1364 }
1365 self.network_allowlist_catalog = catalog;
1366 Ok(self)
1367 }
1368
1369 pub fn with_network_allowlist_catalog_file(
1373 self,
1374 path: impl AsRef<std::path::Path>,
1375 ) -> Result<Self, NodeServerError> {
1376 let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1377 .map_err(NodeServerError::from)?;
1378 self.with_network_allowlist_catalog(catalog)
1379 }
1380}
1381
1382pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1383 platform::default_state_path(node_id.as_str())
1384 .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1385}
1386
1387pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1388 platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1389}
1390
1391fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1392 let parent = state_path.parent()?;
1393 let mut name = state_path.file_name()?.to_os_string();
1394 name.push(".delivery-store");
1395 Some(parent.join(name))
1396}
1397
1398fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1399 let parent = state_path.parent()?;
1400 let mut name = state_path.file_name()?.to_os_string();
1401 name.push(".context-pack-store");
1402 Some(parent.join(name))
1403}
1404
1405pub struct NodeServer {
1406 config: NodeServerConfig,
1407 runtime: NativeRuntime,
1408 shared: Arc<NodeShared>,
1409 events: EventSubscription,
1410 state_path_lock: Option<session_registry::StatePathLock>,
1411}
1412
1413#[cfg(feature = "fixture")]
1414#[derive(Clone, Default)]
1415pub struct SpawnManagedWorktreeV2FailureProbe {
1416 latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1417}
1418
1419#[cfg(feature = "fixture")]
1420impl SpawnManagedWorktreeV2FailureProbe {
1421 pub fn latest_code(&self) -> Option<NodeFailureCode> {
1422 *self
1423 .latest_code
1424 .lock()
1425 .unwrap_or_else(|poisoned| poisoned.into_inner())
1426 }
1427
1428 fn record(&self, code: Option<NodeFailureCode>) {
1429 *self
1430 .latest_code
1431 .lock()
1432 .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1433 }
1434}
1435
1436#[cfg(all(feature = "fixture", windows))]
1437struct IsolatedCodexHomeFixtureEnvironment;
1438
1439#[cfg(all(feature = "fixture", windows))]
1440impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1441 for IsolatedCodexHomeFixtureEnvironment
1442{
1443 fn resolve_child_environment(
1444 &self,
1445 ) -> Result<
1446 Vec<gate4agent_catalog::EnvMutation>,
1447 gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1448 > {
1449 Ok(vec![gate4agent_catalog::EnvMutation {
1450 key: OsString::from("USERPROFILE"),
1451 value: None,
1452 }])
1453 }
1454}
1455
1456impl NodeServer {
1457 pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1460 self.shared.m4a_accounts.as_ref()?;
1461 Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1462 }
1463
1464 pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1465 let catalog = active_registry()?;
1466 Self::new_with_registry(config, catalog)
1467 }
1468
1469 #[cfg(feature = "fixture")]
1470 pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1471 let mut spec = gate4agent_testkit::interactive_agent_spec();
1472 spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1473 spec.display_name = "Controlled Claude PTY fixture".to_owned();
1474 Self::new_fixture_with_spec(config, spec)
1475 }
1476
1477 #[cfg(feature = "fixture")]
1478 pub fn new_clean_exit_fixture(
1479 config: NodeServerConfig,
1480 fixture_root: PathBuf,
1481 started_marker: PathBuf,
1482 release_signal: PathBuf,
1483 ) -> Result<Self, NodeServerError> {
1484 let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1485 &fixture_root,
1486 &started_marker,
1487 &release_signal,
1488 )
1489 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1490 spec.id = AgentId::new("claude")
1491 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1492 spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1493 Self::new_fixture_with_spec(config, spec)
1494 }
1495
1496 #[cfg(all(feature = "fixture", windows))]
1506 pub fn new_durable_context_pack_clean_exit_fixture(
1507 config: NodeServerConfig,
1508 fixture_root: PathBuf,
1509 started_marker: PathBuf,
1510 release_signal: PathBuf,
1511 session_id: &str,
1512 ) -> Result<Self, NodeServerError> {
1513 let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1514 &fixture_root,
1515 &started_marker,
1516 &release_signal,
1517 session_id,
1518 )
1519 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1520 let source_id = AgentId::new("claude")
1521 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1522 let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1523 NodeServerError::Registry(
1524 "durable context pack clean-exit fixture source adapter is unavailable"
1525 .to_owned(),
1526 )
1527 })?;
1528 source_spec.capabilities.adapters.history =
1529 source_provider.capabilities.adapters.history.clone();
1530 source_spec.id = source_id;
1531 source_spec.display_name =
1532 "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1533 source_spec.detection.command =
1539 "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1540 source_spec.detection.aliases.clear();
1541
1542 let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1543 target_spec.id = AgentId::new("codex")
1544 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1545 target_spec.display_name =
1546 "Controlled Codex durable context-pack target fixture".to_owned();
1547 target_spec.detection.command =
1548 "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1549 target_spec.detection.aliases.clear();
1550
1551 let catalog = AgentRegistry::new([source_spec, target_spec])
1552 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1553 let mut server = Self::new_with_registry(config, catalog)?;
1554 Arc::get_mut(&mut server.shared)
1555 .expect("fixture Node shared state must remain uniquely owned before run")
1556 .fixture_semantic_hook_policy = true;
1557 Ok(server)
1558 }
1559
1560 #[cfg(feature = "fixture")]
1561 pub fn new_monitoring_hook_fixture(
1562 config: NodeServerConfig,
1563 ) -> Result<Self, NodeServerError> {
1564 let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1565 spec.id = AgentId::new("claude")
1566 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1567 let mut server = Self::new_fixture_with_spec(config, spec)?;
1568 Arc::get_mut(&mut server.shared)
1569 .expect("fixture Node shared state must remain uniquely owned before run")
1570 .fixture_semantic_hook_policy = true;
1571 Ok(server)
1572 }
1573
1574 #[cfg(all(feature = "fixture", windows))]
1575 pub fn new_provider_bundle_argv_fixture(
1576 config: NodeServerConfig,
1577 agent_id: AgentId,
1578 proof_path: PathBuf,
1579 ) -> Result<Self, NodeServerError> {
1580 let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1581 Self::new_fixture_with_spec(config, spec)
1582 }
1583
1584 #[cfg(all(feature = "fixture", windows))]
1585 pub fn new_provider_bundle_argv_hold_fixture(
1586 config: NodeServerConfig,
1587 agent_id: AgentId,
1588 proof_path: PathBuf,
1589 release_signal: PathBuf,
1590 ) -> Result<Self, NodeServerError> {
1591 let spec = Self::provider_bundle_argv_fixture_spec(
1592 agent_id,
1593 proof_path,
1594 Some(release_signal),
1595 )?;
1596 Self::new_fixture_with_spec(config, spec)
1597 }
1598
1599 #[cfg(all(feature = "fixture", windows))]
1600 fn provider_bundle_argv_fixture_spec(
1601 agent_id: AgentId,
1602 proof_path: PathBuf,
1603 release_signal: Option<PathBuf>,
1604 ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1605 if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1606 return Err(NodeServerError::Registry(
1607 "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1608 ));
1609 }
1610 let mut spec = gate4agent_testkit::interactive_agent_spec();
1611 spec.id = agent_id.clone();
1612 spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1613 let provider = builtin_registry()
1614 .get(&agent_id)
1615 .ok_or_else(|| {
1616 NodeServerError::Registry(
1617 "provider bundle argv fixture adapter is unavailable".to_owned(),
1618 )
1619 })?;
1620 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1621 if !proof_path.is_absolute()
1622 || !proof_path
1623 .parent()
1624 .is_some_and(|parent| parent.is_dir())
1625 {
1626 return Err(NodeServerError::Registry(
1627 "provider bundle argv proof path requires an existing absolute parent"
1628 .to_owned(),
1629 ));
1630 }
1631 if let Some(release_signal) = release_signal.as_ref() {
1632 if !release_signal.is_absolute()
1633 || !release_signal
1634 .parent()
1635 .is_some_and(|parent| parent.is_dir())
1636 {
1637 return Err(NodeServerError::Registry(
1638 "provider bundle argv release path requires an existing absolute parent"
1639 .to_owned(),
1640 ));
1641 }
1642 if release_signal.exists() {
1643 return Err(NodeServerError::Registry(
1644 "provider bundle argv release path must not exist".to_owned(),
1645 ));
1646 }
1647 let proof_parent = std::fs::canonicalize(
1648 proof_path
1649 .parent()
1650 .expect("validated provider bundle proof parent"),
1651 )
1652 .map_err(|_| {
1653 NodeServerError::Registry(
1654 "provider bundle argv proof parent is unavailable".to_owned(),
1655 )
1656 })?;
1657 let release_parent = std::fs::canonicalize(
1658 release_signal
1659 .parent()
1660 .expect("validated provider bundle release parent"),
1661 )
1662 .map_err(|_| {
1663 NodeServerError::Registry(
1664 "provider bundle argv release parent is unavailable".to_owned(),
1665 )
1666 })?;
1667 if proof_parent != release_parent {
1668 return Err(NodeServerError::Registry(
1669 "provider bundle argv release path must share the proof parent".to_owned(),
1670 ));
1671 }
1672 }
1673 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1674 NodeServerError::Registry(
1675 "provider bundle argv proof path is not valid Unicode".to_owned(),
1676 )
1677 })?;
1678 let release_signal = release_signal.map(|release_signal| {
1679 release_signal.into_os_string().into_string().map_err(|_| {
1680 NodeServerError::Registry(
1681 "provider bundle argv release path is not valid Unicode".to_owned(),
1682 )
1683 })
1684 }).transpose()?;
1685 let script = spec
1686 .launch
1687 .fixed_args
1688 .pop()
1689 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1690 let bundle_validation = if agent_id.as_str() == "codex" {
1691 "if ($bundleArgs.Count -ne 0) { exit 91 }; $codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process'); $cwd = (Get-Location).ProviderPath; if ([string]::IsNullOrEmpty($codexHome)) { [IO.File]::WriteAllLines($proofPath, [string[]]@('unbundled', $cwd)); [Console]::Out.WriteLine('F63_CODEX_UNBUNDLED_VALIDATED') } elseif (-not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 } else { $skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'; if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }; $files = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File); if ($files.Count -ne 1 -or [IO.Path]::GetFullPath($files[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }; $sha = [Security.Cryptography.SHA256]::Create(); try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }; if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }; [IO.File]::WriteAllLines($proofPath, [string[]]@('bundled', $codexHome, $cwd, $skillHash)); [Console]::Out.WriteLine('F63_CODEX_BUNDLE_HOME_VALIDATED') }"
1692 } else if agent_id.as_str() == "claude" {
1693 "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--plugin-dir' -and [IO.Path]::IsPathRooted($bundleArgs[1]) -and (Test-Path -LiteralPath $bundleArgs[1] -PathType Container)) { if (-not (Test-Path -LiteralPath (Join-Path $bundleArgs[1] '.claude-plugin/plugin.json') -PathType Leaf)) { exit 92 }; [IO.File]::WriteAllLines($proofPath, [string[]]@($bundleArgs[0], $bundleArgs[1])); [Console]::Out.WriteLine('F62_BUNDLE_ARGV_VALIDATED') } else { exit 91 }"
1694 } else {
1695 "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--skills-dir' -and [IO.Path]::IsPathRooted($bundleArgs[1]) -and (Test-Path -LiteralPath $bundleArgs[1] -PathType Container)) { if ((Split-Path -Leaf $bundleArgs[1]) -ne 'skills') { exit 93 }; [IO.File]::WriteAllLines($proofPath, [string[]]@($bundleArgs[0], $bundleArgs[1])); [Console]::Out.WriteLine('F62_BUNDLE_ARGV_VALIDATED') } else { exit 91 }"
1696 };
1697 if let Some(release_signal) = release_signal {
1698 spec.launch.fixed_args.push(format!(
1699 "& {{ param([string]$proofPath, [string]$releaseSignal, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; $deadline = [DateTime]::UtcNow.AddSeconds(45); while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf)) {{ if ([DateTime]::UtcNow -ge $deadline) {{ exit 96 }}; Start-Sleep -Milliseconds 20 }}; {script} }}",
1700 ));
1701 spec.launch.fixed_args.push(proof_path);
1702 spec.launch.fixed_args.push(release_signal);
1703 } else {
1704 spec.launch.fixed_args.push(format!(
1705 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1706 ));
1707 spec.launch.fixed_args.push(proof_path);
1708 }
1709 Ok(spec)
1710 }
1711
1712 #[cfg(all(feature = "fixture", windows))]
1713 pub fn new_context_pack_fixture(
1714 config: NodeServerConfig,
1715 proof_path: PathBuf,
1716 ) -> Result<Self, NodeServerError> {
1717 let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1718 Self::new_with_registry(config, catalog)
1719 }
1720
1721 #[cfg(all(feature = "fixture", windows))]
1722 pub fn new_monitoring_context_pack_fixture(
1723 config: NodeServerConfig,
1724 proof_path: PathBuf,
1725 ) -> Result<Self, NodeServerError> {
1726 let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1727 let mut server = Self::new_with_registry(config, catalog)?;
1728 Arc::get_mut(&mut server.shared)
1729 .expect("fixture Node shared state must remain uniquely owned before run")
1730 .fixture_semantic_hook_policy = true;
1731 Ok(server)
1732 }
1733
1734 #[cfg(all(feature = "fixture", windows))]
1735 pub fn install_isolated_codex_home_environment_fixture(
1736 &mut self,
1737 profile_id: SpawnEnvironmentProfileId,
1738 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1739 ) -> Result<(), NodeServerError> {
1740 let provider = AgentId::new("codex")
1741 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1742 let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1743 gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1744 "{}-pty",
1745 profile_id.as_str(),
1746 ))
1747 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1748 provider.clone(),
1749 TransportKind::Pty,
1750 vec![OsString::from("USERPROFILE")],
1751 Arc::new(IsolatedCodexHomeFixtureEnvironment),
1752 )
1753 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1754 let materialization = NodeSessionMaterializationProfile::new(
1755 Vec::new(),
1756 vec![crate::session_environment::NodeSessionPathBinding::new(
1757 "CODEX_HOME",
1758 crate::session_environment::NodeSessionPathClass::ProviderHome,
1759 )
1760 .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1761 Vec::new(),
1762 )
1763 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1764 self.install_environment_profile(
1765 NodeEnvironmentProfile::new_with_materialization(
1766 profile_id,
1767 profile_revision,
1768 provider,
1769 [native_profile],
1770 Some(materialization),
1771 )
1772 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1773 )
1774 }
1775
1776 #[cfg(all(feature = "fixture", windows))]
1777 pub fn new_context_only_proof_fixture(
1778 config: NodeServerConfig,
1779 target_provider: AgentId,
1780 proof_path: PathBuf,
1781 ) -> Result<Self, NodeServerError> {
1782 if target_provider.as_str() != "kimi" {
1783 return Err(NodeServerError::Registry(
1784 "context-only proof fixture requires Kimi".to_owned(),
1785 ));
1786 }
1787 let catalog = Self::context_pack_fixture_catalog(
1788 proof_path,
1789 Some(&target_provider),
1790 false,
1791 )?;
1792 Self::new_with_registry(config, catalog)
1793 }
1794
1795 #[cfg(all(feature = "fixture", windows))]
1796 fn context_pack_fixture_catalog(
1797 proof_path: PathBuf,
1798 context_only_target: Option<&AgentId>,
1799 monitoring_claude_source: bool,
1800 ) -> Result<AgentRegistry, NodeServerError> {
1801 if !proof_path.is_absolute()
1802 || !proof_path
1803 .parent()
1804 .is_some_and(|parent| parent.is_dir())
1805 {
1806 return Err(NodeServerError::Registry(
1807 "context pack proof path requires an existing absolute parent".to_owned(),
1808 ));
1809 }
1810 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1811 NodeServerError::Registry(
1812 "context pack proof path is not valid Unicode".to_owned(),
1813 )
1814 })?;
1815 let providers = ["claude", "codex", "grok", "kimi"];
1816 let builtins = builtin_registry();
1817 let mut specs = Vec::with_capacity(providers.len());
1818 for provider_id in providers {
1819 let agent_id = AgentId::new(provider_id)
1820 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1821 let provider = builtins.get(&agent_id).ok_or_else(|| {
1822 NodeServerError::Registry(
1823 "context pack fixture provider adapter is unavailable".to_owned(),
1824 )
1825 })?;
1826 let monitored_provider =
1827 monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1828 let mut spec = if monitored_provider {
1829 gate4agent_testkit::monitoring_hook_agent_spec()
1830 } else {
1831 gate4agent_testkit::interactive_agent_spec()
1832 };
1833 spec.id = agent_id.clone();
1834 spec.display_name = format!("Controlled {agent_id} context pack fixture");
1835 spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1836 spec.detection.aliases.clear();
1837 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1838 if provider_id == "codex" {
1839 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1840 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1841 })?;
1842 let validation = r#"
1843$bundleArgs = @($bundleArgs)
1844if ($bundleArgs.Count -ne 0) { exit 91 }
1845$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1846$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1847$cwd = (Get-Location).ProviderPath
1848if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1849$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1850if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1851$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1852if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1853$sha = [Security.Cryptography.SHA256]::Create()
1854try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1855if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1856if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1857$contextPath = Join-Path $contextRoot 'context-pack.json'
1858if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1859$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1860if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1861$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1862$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1863$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1864$separator = [IO.Path]::DirectorySeparatorChar
1865if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1866if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1867$rawContext = [IO.File]::ReadAllText($contextPath)
1868if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1869try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1870$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1871$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1872foreach ($property in @($document.PSObject.Properties.Name)) {
1873 if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1874}
1875foreach ($property in $requiredTopLevel) {
1876 if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1877}
1878if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1879if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1880$messages = @($document.retained_messages)
1881if ($messages.Count -eq 0) { exit 107 }
1882foreach ($message in $messages) {
1883 $messageProperties = @($message.PSObject.Properties.Name)
1884 foreach ($property in $messageProperties) {
1885 if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1886 }
1887 if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1888}
1889$sha = [Security.Cryptography.SHA256]::Create()
1890try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1891[IO.File]::WriteAllLines($proofPath, [string[]]@('contextual', $codexHome, $cwd, $skillHash, $contextRoot, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count, [string]$messages[0].text, [string]$messages[$messages.Count - 1].text))
1892$rawProof = [IO.File]::ReadAllText($proofPath)
1893if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1894[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1895"#;
1896 spec.launch.fixed_args.push(format!(
1897 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1898 ));
1899 spec.launch.fixed_args.push(proof_path.clone());
1900 } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1901 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1902 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1903 })?;
1904 let validation = r#"
1905$bundleArgs = @($bundleArgs)
1906if ($bundleArgs.Count -ne 0) { exit 91 }
1907$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1908$cwd = (Get-Location).ProviderPath
1909if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1910$contextPath = Join-Path $contextRoot 'context-pack.json'
1911if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1912$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1913if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1914$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1915$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1916$separator = [IO.Path]::DirectorySeparatorChar
1917if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1918try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1919if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1920if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1921$messages = @($document.retained_messages)
1922if ($messages.Count -eq 0) { exit 99 }
1923$hasUser = $false
1924$hasAssistant = $false
1925foreach ($message in $messages) {
1926 if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1927 if ([string]$message.role -ceq 'user') { $hasUser = $true }
1928 elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1929 else { exit 101 }
1930}
1931if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1932$sha = [Security.Cryptography.SHA256]::Create()
1933try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1934[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1935[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1936"#;
1937 spec.launch.fixed_args.push(format!(
1938 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1939 ));
1940 spec.launch.fixed_args.push(proof_path.clone());
1941 }
1942 specs.push(spec);
1943 }
1944 let catalog = AgentRegistry::new(specs)
1945 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1946 Ok(catalog)
1947 }
1948
1949 #[cfg(feature = "fixture")]
1950 pub fn new_exact_launcher_fixture(
1951 config: NodeServerConfig,
1952 agent_id: AgentId,
1953 launcher: String,
1954 ) -> Result<Self, NodeServerError> {
1955 if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1956 return Err(NodeServerError::Registry(
1957 "exact launcher fixture requires a supported provider".to_owned(),
1958 ));
1959 }
1960 if launcher.contains('\0') {
1961 return Err(NodeServerError::Registry(
1962 "exact launcher fixture path contains NUL".to_owned(),
1963 ));
1964 }
1965 let launcher_path = Path::new(&launcher);
1966 if !launcher_path.is_absolute() {
1967 return Err(NodeServerError::Registry(
1968 "exact launcher fixture path must be absolute".to_owned(),
1969 ));
1970 }
1971 let metadata = std::fs::metadata(launcher_path).map_err(|_| {
1972 NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
1973 })?;
1974 if !metadata.is_file() {
1975 return Err(NodeServerError::Registry(
1976 "exact launcher fixture path must be a regular file".to_owned(),
1977 ));
1978 }
1979 let mut spec = builtin_registry()
1980 .get(&agent_id)
1981 .ok_or_else(|| {
1982 NodeServerError::Registry(
1983 "exact launcher fixture provider is unavailable".to_owned(),
1984 )
1985 })?
1986 .clone();
1987 spec.launch.program = launcher;
1988 Self::new_fixture_with_spec(config, spec)
1989 }
1990
1991 #[cfg(feature = "fixture")]
2000 fn h3b_provider_spec_with_program_and_args(
2001 agent_id: AgentId,
2002 provider_program: PathBuf,
2003 fixed_args: Vec<OsString>,
2004 ) -> Result<AgentSpec, NodeServerError> {
2005 if !provider_program.is_absolute() || fixed_args.len() > 32 {
2006 return Err(NodeServerError::Registry(
2007 "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2008 ));
2009 }
2010 let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2011 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2012 })?;
2013 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2014 return Err(NodeServerError::Registry(
2015 "H3B fixture provider must be an exact regular file".to_owned(),
2016 ));
2017 }
2018 let program = provider_program.into_os_string().into_string().map_err(|_| {
2019 NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2020 })?;
2021 let fixed_args = fixed_args.into_iter().map(|argument| {
2022 argument.into_string().map_err(|_| NodeServerError::Registry(
2023 "H3B fixture argument is not Unicode".to_owned(),
2024 ))
2025 }).collect::<Result<Vec<_>, _>>()?;
2026 let argument_bytes = fixed_args.iter()
2027 .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2028 if program.contains('\0')
2029 || fixed_args.iter().any(|argument| argument.contains('\0'))
2030 || !matches!(argument_bytes, Some(total) if total <= 65_536)
2031 {
2032 return Err(NodeServerError::Registry(
2033 "H3B fixture provider or arguments are invalid".to_owned(),
2034 ));
2035 }
2036 let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2037 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2038 })?.clone();
2039 let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2040 .ok_or_else(|| NodeServerError::Registry(
2041 "H3B fixture provider basename is unavailable".to_owned(),
2042 ))?.to_owned();
2043 spec.detection.command = process_name.clone();
2044 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2045 name: process_name,
2046 }];
2047 spec.launch.program = program;
2048 spec.launch.fixed_args = fixed_args;
2049 Ok(spec)
2050 }
2051
2052 #[cfg(feature = "fixture")]
2053 pub fn new_harness_mcp_proxy_fixture(
2054 config: NodeServerConfig,
2055 provider_program: PathBuf,
2056 fixed_args: Vec<OsString>,
2057 harness_mcp_program: PathBuf,
2058 ) -> Result<Self, NodeServerError> {
2059 let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2060 let agent_id = AgentId::new("codex")
2061 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2062 let mut spec =
2063 Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2064 spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2065 config.fixture_raw_pty_runtime = true;
2066 Self::new_fixture_with_spec(config, spec)
2067 }
2068
2069 #[cfg(feature = "fixture")]
2093 pub fn new_harness_mcp_acp_launcher_fixture(
2094 config: NodeServerConfig,
2095 helper_program: PathBuf,
2096 provider: AgentId,
2097 program: PathBuf,
2098 args: Vec<OsString>,
2099 ) -> Result<Self, NodeServerError> {
2100 if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2101 return Err(NodeServerError::Registry(
2102 "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2103 ));
2104 }
2105 let config = config.with_harness_mcp_helper(helper_program)?;
2106 let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2107 let launch = spec.launch.clone();
2108 let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2109 NodeServerError::Registry(
2110 "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2111 .to_owned(),
2112 )
2113 })?;
2114 acp.launch_override = Some(launch);
2122 spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2123 Self::new_fixture_with_spec(config, spec)
2124 }
2125
2126 #[cfg(feature = "fixture")]
2127 pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2128 let mut spec = gate4agent_testkit::interactive_agent_spec();
2129 let claude_id = AgentId::new("claude")
2130 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2131 let claude = builtin_registry()
2132 .get(&claude_id)
2133 .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2134 spec.id = claude_id;
2135 spec.display_name = "Resumable Claude PTY fixture".to_owned();
2136 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2137 name: "claude".to_owned(),
2138 }];
2139 spec.capabilities.transports.pty_adapter = claude
2140 .capabilities
2141 .transports
2142 .pty_adapter
2143 .clone();
2144 spec.capabilities.adapters.resume = claude
2145 .capabilities
2146 .adapters
2147 .resume
2148 .clone();
2149 let script = spec
2150 .launch
2151 .fixed_args
2152 .pop()
2153 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2154 spec.launch.fixed_args.push(format!(
2155 "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2156 ));
2157 Self::new_fixture_with_spec(config, spec)
2158 }
2159
2160 #[cfg(feature = "fixture")]
2161 pub fn new_cmd_cwd_fixture(
2162 config: NodeServerConfig,
2163 command: String,
2164 ) -> Result<Self, NodeServerError> {
2165 let mut spec = gate4agent_testkit::interactive_agent_spec();
2166 spec.id = AgentId::new("claude")
2167 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2168 spec.display_name = "Controlled cmd cwd fixture".to_owned();
2169 spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2170 spec.launch.program = command;
2171 spec.launch.fixed_args.clear();
2172 Self::new_fixture_with_spec(config, spec)
2173 }
2174
2175 #[cfg(feature = "fixture")]
2176 fn new_fixture_with_spec(
2177 config: NodeServerConfig,
2178 spec: gate4agent_types::AgentSpec,
2179 ) -> Result<Self, NodeServerError> {
2180 let catalog = AgentRegistry::new([spec])
2181 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2182 Self::new_with_registry(config, catalog)
2183 }
2184
2185 fn new_with_registry(
2186 config: NodeServerConfig,
2187 catalog: AgentRegistry,
2188 ) -> Result<Self, NodeServerError> {
2189 for profile in config.spawn_profiles.iter() {
2190 if catalog.get(&profile.provider).is_none() {
2191 return Err(NodeServerError::Registry(
2192 "spawn profile references an unavailable provider".to_owned(),
2193 ));
2194 }
2195 }
2196 let input_settle_timeout_ms = catalog
2197 .iter()
2198 .map(|spec| spec.readiness.timeout_ms)
2199 .max()
2200 .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2201 .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2202 .max(MUTATION_SETTLE_TIMEOUT_MS);
2203 let (provider_contracts, provider_adapter_contracts) =
2204 provider_contract_manifest(&catalog)?;
2205 let compatibility_support = node_compatibility_support_for_manifest(
2206 &provider_contracts,
2207 &provider_adapter_contracts,
2208 )?;
2209 validate_node_negotiated_handshake_capacity(&compatibility_support)
2210 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2211 let enabled_providers = provider_contracts
2212 .iter()
2213 .map(|contract| contract.provider.clone())
2214 .collect();
2215 #[cfg(feature = "fixture")]
2216 let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2217 #[cfg(not(feature = "fixture"))]
2218 let fixture_raw_pty_runtime = false;
2219 let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2220 let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2221 crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2222 })).expect("fixture provider catalog remains bounded");
2223 (None, statuses)
2224 } else {
2225 let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2226 let statuses = monitor.collect();
2227 (Some(monitor), statuses)
2228 };
2229 let state_path_lock =
2230 session_registry::StatePathLock::acquire(config.state_path.as_deref())
2231 .map_err(|error| {
2232 durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2233 })?;
2234 let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2235 Some(state_path) => {
2236 let delivery_root = delivery_store_root_for_state_path(state_path)
2237 .ok_or_else(|| {
2238 NodeServerError::DeliveryStore(
2239 "delivery store root could not be derived from state path".to_owned(),
2240 )
2241 })?;
2242 let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2243 let detail = match &error {
2244 DeliveryStoreError::Storage(io_error) => {
2245 format!("{error}: {io_error}")
2246 }
2247 _ => error.to_string(),
2248 };
2249 NodeServerError::DeliveryStore(detail)
2250 })?;
2251 (Some(store), bundles)
2252 }
2253 None => (None, Vec::new()),
2254 };
2255 let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2256 NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2257 })?;
2258 let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2259 Some(state_path) => {
2260 let context_pack_root = context_pack_store_root_for_state_path(state_path)
2261 .ok_or(NodeServerError::ContextPackStore)?;
2262 let (store, packs) = ContextPackStore::open(context_pack_root)
2263 .map_err(|_| NodeServerError::ContextPackStore)?;
2264 (Some(store), packs)
2265 }
2266 None => (None, Vec::new()),
2267 };
2268 let mut durable_context_catalog = ContextPackCatalog::default();
2269 for pack in durable_context_packs {
2270 durable_context_catalog
2271 .insert(pack)
2272 .map_err(|_| NodeServerError::ContextPackStore)?;
2273 }
2274 let session_environment_materializer = config
2275 .session_environment
2276 .as_ref()
2277 .map(|environment| {
2278 SessionEnvironmentMaterializer::new(
2279 environment.root.clone(),
2280 Arc::clone(&environment.resolver),
2281 )
2282 .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2283 })
2284 .transpose()?;
2285 #[cfg(unix)]
2288 let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2289 let native_session_catalog = config.history.clone().map(|history| {
2290 Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2291 });
2292 let (handle, runtime) = match config.history.clone() {
2293 Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2294 None => NativeRuntime::new(catalog, config.runtime),
2295 };
2296 let native_launch_profile_control = runtime.native_launch_profile_control();
2297 let harness_mcp_registry = config
2298 .harness_mcp_helper
2299 .clone()
2300 .map(HarnessMcpProxyRegistry::new);
2301 let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2302 let incarnation_id = random_incarnation_id()
2303 .map_err(NodeServerError::IncarnationIdentity)?;
2304 let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2305 .map_err(durable_state_load_error)?;
2306 let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2307 let managed_worktree_tombstones =
2308 std::mem::take(&mut loaded.managed_worktree_tombstones);
2309 let materializations = std::mem::take(&mut loaded.materializations);
2310 let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2311 let (workspaces, records, persistence_warning) =
2312 merge_durable_state(&config.workspaces, loaded)?;
2313 let mut shared = NodeShared::new_with_incarnation(
2314 handle,
2315 config.access_token.clone(),
2316 config.node_id.clone(),
2317 incarnation_id,
2318 workspaces,
2319 enabled_providers,
2320 provider_runtime_statuses,
2321 provider_runtime_monitor,
2322 provider_contracts,
2323 provider_adapter_contracts,
2324 config.spawn_profiles.clone(),
2325 Some(native_launch_profile_control),
2326 native_session_catalog,
2327 session_environment_materializer,
2328 config.state_path.clone(),
2329 records,
2330 managed_worktrees,
2331 managed_worktree_tombstones,
2332 materializations,
2333 persistence_warning,
2334 input_settle_timeout_ms,
2335 );
2336 shared.harness_mcp_registry = harness_mcp_registry.clone();
2337 shared.session_record_retention = config.session_record_retention;
2338 shared.network_allowlist_catalog =
2339 RwLock::new(config.network_allowlist_catalog.clone());
2340 shared.bundle_catalog = RwLock::new(delivered_catalog);
2341 shared.delivery_store = Mutex::new(delivery_store);
2342 shared.context_catalog = RwLock::new(durable_context_catalog);
2343 shared.context_pack_store = Mutex::new(context_pack_store);
2344 shared.managed_spawn_replays = Mutex::new(
2345 managed_spawn_replays
2346 .into_iter()
2347 .map(|record| (record.idempotency_key.clone(), record))
2348 .collect(),
2349 );
2350 let shared = Arc::new(shared);
2351 if let Some(registry) = harness_mcp_registry {
2352 let weak = Arc::downgrade(&shared);
2353 registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2354 Some(shared) => shared.publish_transient(event),
2355 None => false,
2356 }));
2357 }
2358 if shared.persistence_error().is_none() {
2359 shared.persist_state().map_err(|error| {
2360 durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2361 })?;
2362 }
2363 Ok(Self {
2364 config,
2365 runtime,
2366 shared,
2367 events,
2368 state_path_lock,
2369 })
2370 }
2371
2372 pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2373 NodeShutdownHandle {
2374 shared: Arc::clone(&self.shared),
2375 }
2376 }
2377
2378 #[cfg(feature = "fixture")]
2379 pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2380 &self,
2381 ) -> SpawnManagedWorktreeV2FailureProbe {
2382 self.shared
2383 .fixture_spawn_managed_worktree_v2_failure_probe
2384 .clone()
2385 }
2386
2387 pub fn install_environment_profile(
2392 &mut self,
2393 profile: NodeEnvironmentProfile,
2394 ) -> Result<(), NodeServerError> {
2395 if !self.shared.enabled_providers.contains(profile.provider()) {
2396 return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2397 profile.provider().clone(),
2398 ));
2399 }
2400 let (binding, native_profiles, materialization) = profile.into_parts();
2401 if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2402 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2403 }
2404 let mut profiles = self
2405 .shared
2406 .environment_profiles
2407 .write()
2408 .unwrap_or_else(|poisoned| poisoned.into_inner());
2409 if profiles.contains_key(&binding.id) {
2410 return Err(NodeServerError::DuplicateEnvironmentProfile(
2411 binding.id.clone(),
2412 ));
2413 }
2414 if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2415 return Err(NodeServerError::EnvironmentProfileCapacity {
2416 max: MAX_NODE_ENVIRONMENT_PROFILES,
2417 });
2418 }
2419 for native_id in binding.native_profile_ids() {
2420 if profiles.values().any(|current| {
2421 current.native_profile_ids().any(|current_id| current_id == native_id)
2422 }) {
2423 return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2424 native_id.to_string(),
2425 ));
2426 }
2427 }
2428
2429 let mut installed = Vec::with_capacity(native_profiles.len());
2430 for native_profile in native_profiles {
2431 let native_id = native_profile.id().clone();
2432 if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2433 for installed_id in installed.into_iter().rev() {
2434 let _ = self.runtime.remove_native_launch_profile(&installed_id);
2435 }
2436 return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2437 }
2438 installed.push(native_id);
2439 }
2440 if let Some(materialization) = materialization {
2441 self.shared
2442 .environment_materialization_profiles
2443 .write()
2444 .unwrap_or_else(|poisoned| poisoned.into_inner())
2445 .insert(binding.id.clone(), materialization);
2446 }
2447 profiles.insert(binding.id.clone(), binding);
2448 Ok(())
2449 }
2450
2451 pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2453 if self.shared.session_environment_materializer.is_none() {
2454 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2455 }
2456 let mut catalog = self
2457 .shared
2458 .bundle_catalog
2459 .write()
2460 .unwrap_or_else(|poisoned| poisoned.into_inner());
2461 catalog
2462 .insert_idempotent(bundle)
2463 .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2464 Ok(())
2465 }
2466
2467 pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2468 let shutdown = self.shutdown_handle();
2469 let run = self.run();
2470 tokio::pin!(run);
2471 tokio::select! {
2472 result = &mut run => result,
2473 signal = wait_for_ctrl_signal() => {
2474 signal?;
2475 let shutdown_result = shutdown.request_shutdown().await;
2476 let run_result = run.await;
2477 shutdown_result.and(run_result)
2478 }
2479 }
2480 }
2481
2482 pub async fn run(self) -> Result<(), NodeServerError> {
2483 let NodeServer {
2484 config,
2485 mut runtime,
2486 shared,
2487 events,
2488 state_path_lock: _state_path_lock,
2489 } = self;
2490 shared.reconcile_materializations();
2491 shared.reconcile_managed_worktrees().await;
2492 shared.retire_unavailable_session_records().await;
2493 shared.reconcile_context_pack_exports().await;
2494 let endpoint = config.endpoint.clone();
2498 let call_home = config.call_home;
2499 let call_home_identity = config.call_home_identity.clone();
2500 let api_listen = config.api_listen;
2501 let bridge_listen = config.bridge_listen;
2502 let bridge_underlay = config.bridge_underlay;
2503 let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2504 .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2505 let accept_shared = Arc::clone(&shared);
2506 let api_shared = Arc::clone(&shared);
2507 let bridge_shared = Arc::clone(&shared);
2508 let shutdown_shared = Arc::clone(&shared);
2509 let shutdown_timeout = Duration::from_millis(
2510 config.runtime.provider_shutdown_timeout_ms.max(1),
2511 );
2512 let result = {
2513 let runtime_loop = drive_runtime_until_shutdown(
2514 &mut runtime,
2515 events,
2516 Arc::clone(&shared),
2517 shutdown_timeout,
2518 );
2519 let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2520 let api_loop = http_api::run(api_listen, api_shared);
2521 let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2522 tokio::pin!(runtime_loop);
2523 tokio::pin!(accept_loop);
2524 tokio::pin!(api_loop);
2525 tokio::pin!(bridge_loop);
2526 tokio::select! {
2527 runtime_result = &mut runtime_loop => {
2528 let shutdown_result = shutdown_shared
2529 .begin_shutdown()
2530 .await
2531 .map_err(NodeServerError::ShutdownDispatch);
2532 let (accept_result, api_result, bridge_result) =
2533 tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2534 match runtime_result {
2535 Err(error) => Err(error),
2536 Ok(()) => shutdown_result
2537 .and(accept_result)
2538 .and(api_result.map_err(NodeServerError::HttpApi))
2539 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2540 }
2541 }
2542 accept_result = &mut accept_loop => {
2543 let shutdown_result = shutdown_shared
2544 .begin_shutdown()
2545 .await
2546 .map_err(NodeServerError::ShutdownDispatch);
2547 let (runtime_result, api_result, bridge_result) =
2548 tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2549 match accept_result {
2550 Err(error) => Err(error),
2551 Ok(()) => shutdown_result
2552 .and(runtime_result)
2553 .and(api_result.map_err(NodeServerError::HttpApi))
2554 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2555 }
2556 }
2557 api_result = &mut api_loop => {
2558 let shutdown_result = shutdown_shared
2559 .begin_shutdown()
2560 .await
2561 .map_err(NodeServerError::ShutdownDispatch);
2562 let (runtime_result, accept_result, bridge_result) =
2563 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2564 match api_result {
2565 Err(error) => Err(NodeServerError::HttpApi(error)),
2566 Ok(()) => shutdown_result
2567 .and(runtime_result)
2568 .and(accept_result)
2569 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2570 }
2571 }
2572 bridge_result = &mut bridge_loop => {
2573 let shutdown_result = shutdown_shared
2574 .begin_shutdown()
2575 .await
2576 .map_err(NodeServerError::ShutdownDispatch);
2577 let (runtime_result, accept_result, api_result) =
2578 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2579 match bridge_result {
2580 Err(error) => Err(NodeServerError::BridgeApi(error)),
2581 Ok(()) => shutdown_result
2582 .and(runtime_result)
2583 .and(accept_result)
2584 .and(api_result.map_err(NodeServerError::HttpApi)),
2585 }
2586 }
2587 }
2588 };
2589 if let Ok(mut guard) = shared.delivery_store.lock() {
2596 *guard = None;
2597 }
2598 if let Ok(mut guard) = shared.context_pack_store.lock() {
2599 *guard = None;
2600 }
2601 result
2602 }
2603}
2604
2605async fn drive_runtime_until_shutdown(
2606 runtime: &mut NativeRuntime,
2607 events: EventSubscription,
2608 shared: Arc<NodeShared>,
2609 shutdown_timeout: Duration,
2610) -> Result<(), NodeServerError> {
2611 let _ = shared
2615 .shell_efficiency_profile
2616 .set(runtime.shell_efficiency_profile());
2617 let mut shutdown_started = None;
2618 let mut last_retention_sweep = Instant::now();
2619 let mut last_provisional_reap = Instant::now();
2620 loop {
2621 let runtime_tick_start = Instant::now();
2628 let tick_result = runtime.tick().await;
2629 let runtime_tick_elapsed = runtime_tick_start.elapsed();
2630
2631 let event_drain_start = Instant::now();
2632 for outcome in &tick_result.command_outcomes {
2640 if let Err(error) = &outcome.result {
2641 shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2642 tracing::warn!(
2643 command_id = outcome.command_id.0,
2644 cause = %error,
2645 "control command rejected"
2646 );
2647 shared.record_rejected_command(
2648 outcome.command_id,
2649 error.to_string(),
2650 error.is_unsupported_transport(),
2651 );
2652 }
2653 }
2654 let mut events_drained: u64 = 0;
2655 while let Ok(event) = events.try_recv() {
2656 events_drained += 1;
2657 let clean_exit = matches!(
2658 event.event,
2659 ControlEventKind::Exited { exit_code: Some(0), forced: false },
2660 );
2661 let instance_id = event.instance_id;
2662 let generation = event.generation;
2663 if clean_exit {
2675 if let Some((record_id, session)) =
2676 shared.managed_record_export_target_for_instance(instance_id, generation)
2677 {
2678 let export_shared = Arc::clone(&shared);
2679 tokio::spawn(async move {
2680 export_shared
2681 .reconcile_context_pack_export_for_record(&record_id, session)
2682 .await;
2683 });
2684 }
2685 }
2686 shared.publish_control(event);
2687 }
2688 let event_drain_elapsed = event_drain_start.elapsed();
2689
2690 let publish_terminal_frames_start = Instant::now();
2691 shared.publish_terminal_frames();
2692 let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2693
2694 shared
2700 .native_session_gauge
2701 .store(runtime.active_native_sessions(), Ordering::Relaxed);
2702 *shared
2703 .runtime_tick_profile
2704 .lock()
2705 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2706
2707 let remainder_start = Instant::now();
2708 if shared.shutdown.load(Ordering::Acquire) {
2709 let started = *shutdown_started.get_or_insert_with(Instant::now);
2710 let snapshot = shared.handle.snapshot();
2711 let sessions_settled = snapshot.sessions.iter().all(|session| {
2712 !matches!(
2713 session.status,
2714 gate4agent_types::SessionStatus::Starting
2715 | gate4agent_types::SessionStatus::Running
2716 | gate4agent_types::SessionStatus::Stopping
2717 )
2718 });
2719 if runtime.active_native_sessions() == 0 && sessions_settled {
2720 return Ok(());
2721 }
2722 if started.elapsed() >= shutdown_timeout {
2723 return Err(NodeServerError::ShutdownTimedOut {
2724 active_native_sessions: runtime.active_native_sessions(),
2725 });
2726 }
2727 }
2728 let remainder_elapsed = remainder_start.elapsed();
2729
2730 {
2731 let mut profile = shared
2732 .drive_loop_profile
2733 .lock()
2734 .unwrap_or_else(|poisoned| poisoned.into_inner());
2735 profile.record_runtime_tick(runtime_tick_elapsed);
2736 profile.record_event_drain(event_drain_elapsed);
2737 profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2738 profile.record_remainder(remainder_elapsed);
2739 profile.note_iteration(Instant::now());
2740 }
2741
2742 shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2754 if tick_result.observations_applied == 0 && events_drained == 0 {
2755 shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2756 }
2757
2758 if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2763 shared.retire_unavailable_session_records().await;
2764 last_retention_sweep = Instant::now();
2765 }
2766 if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2768 for address in shared.take_due_provisional_spawns() {
2769 shared.reap_provisional_spawn(address).await;
2770 }
2771 last_provisional_reap = Instant::now();
2772 }
2773
2774 let hot = shared
2777 .pty_input_marks
2778 .lock()
2779 .unwrap_or_else(|poisoned| poisoned.into_inner())
2780 .values()
2781 .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2782 if hot {
2783 sleep(Duration::from_millis(1)).await;
2784 } else {
2785 tokio::select! {
2786 _ = sleep(Duration::from_millis(10)) => {}
2787 _ = shared.pty_input_wake.notified() => {}
2788 }
2789 }
2790 }
2791}
2792
2793#[derive(Clone)]
2794pub struct NodeShutdownHandle {
2795 shared: Arc<NodeShared>,
2796}
2797
2798impl NodeShutdownHandle {
2799 pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2800 self.shared
2801 .begin_shutdown()
2802 .await
2803 .map_err(NodeServerError::ShutdownDispatch)
2804 }
2805}
2806
2807async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2808 #[cfg(windows)]
2809 {
2810 let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2811 let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2812 return tokio::select! {
2813 signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2814 signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2815 };
2816 }
2817 #[cfg(unix)]
2818 {
2819 let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2820 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2821 tokio::select! {
2822 signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2823 signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2824 }
2825 }
2826}
2827
2828fn shell_host_spec() -> AgentSpec {
2842 AgentSpec {
2843 id: AgentId::new("shell").expect("shell agent id"),
2844 revision: "hatchery:shell-pty-1".to_owned(),
2845 display_name: "Host shell".to_owned(),
2846 detection: DetectionSpec {
2847 command: "bash".to_owned(),
2848 aliases: vec!["sh".to_owned(), "shell".to_owned()],
2849 required_commands: Vec::new(),
2850 unsupported_platforms: Vec::new(),
2851 },
2852 launch: LaunchSpec {
2853 program: "/bin/bash".to_owned(),
2855 fixed_args: vec!["-i".to_owned()],
2856 },
2857 expected_processes: vec![ProcessMatcher::Exact {
2858 name: "bash".to_owned(),
2859 }],
2860 prompt: PromptSpec {
2861 initial: InitialPromptMode::None,
2862 native_draft: None,
2863 },
2864 readiness: AgentReadinessSpec::default(),
2865 capabilities: AgentCapabilities {
2866 agent_commands: None,
2867 transports: AgentTransportCapabilities {
2868 pty: true,
2869 pty_adapter: None,
2870 pipe: None,
2871 acp: None,
2872 },
2873 adapters: AgentAdapterCapabilities::default(),
2874 },
2875 verification: SpecVerification::Gate4AgentVerified,
2876 }
2877}
2878
2879fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2880 let mut specs = vec![shell_host_spec()];
2881 specs.extend(
2882 builtin_registry()
2883 .iter()
2884 .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2885 .cloned(),
2886 );
2887 AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2888}
2889
2890fn provider_contract_manifest(
2891 catalog: &AgentRegistry,
2892) -> Result<
2893 (
2894 Vec<ProviderContractSupport>,
2895 Vec<ProviderAdapterContractSupport>,
2896 ),
2897 NodeServerError,
2898> {
2899 let mut provider_contracts = Vec::new();
2900 let mut provider_adapter_contracts = Vec::new();
2901 for spec in catalog.iter() {
2902 let provider = spec.id.clone();
2903 let revision = ProviderContractRevision::new(spec.revision.clone())
2904 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2905 provider_contracts.push(ProviderContractSupport {
2906 provider: provider.clone(),
2907 revision,
2908 });
2909 for (family, binding) in declared_adapter_bindings(spec)? {
2910 let revision = AdapterContractRevision::new(binding.revision.clone())
2911 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2912 provider_adapter_contracts.push(ProviderAdapterContractSupport {
2913 provider: provider.clone(),
2914 family,
2915 adapter_id: binding.id.clone(),
2916 revision,
2917 });
2918 }
2919 }
2920 validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2921 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2922 Ok((provider_contracts, provider_adapter_contracts))
2923}
2924
2925fn declared_adapter_bindings(
2926 spec: &AgentSpec,
2927) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2928 let mut bindings = Vec::new();
2929 push_declared_binding(
2930 &mut bindings,
2931 AdapterFamily::PtySemantic,
2932 spec.capabilities.transports.pty_adapter.as_ref(),
2933 )?;
2934 push_declared_binding(
2935 &mut bindings,
2936 AdapterFamily::Pipe,
2937 spec.capabilities.adapters.pty_sidecar.as_ref(),
2938 )?;
2939 let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2940 let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2941 gate4agent_types::PipeProtocol::SemanticNdjson
2942 | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2943 gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2944 });
2945 push_declared_binding(
2946 &mut bindings,
2947 AdapterFamily::Pipe,
2948 pipe_transport
2949 .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2950 .map(|transport| &transport.adapter),
2951 )?;
2952 push_declared_binding(
2953 &mut bindings,
2954 AdapterFamily::Acp,
2955 spec.capabilities
2956 .transports
2957 .acp
2958 .as_ref()
2959 .map(|transport| &transport.adapter),
2960 )?;
2961 push_declared_binding(
2962 &mut bindings,
2963 AdapterFamily::Hook,
2964 spec.capabilities.adapters.hook.as_ref(),
2965 )?;
2966 push_declared_binding(
2967 &mut bindings,
2968 AdapterFamily::ManagedHook,
2969 spec.capabilities.adapters.managed_hook.as_ref(),
2970 )?;
2971 push_declared_binding(
2972 &mut bindings,
2973 AdapterFamily::OneShot,
2974 pipe_transport
2975 .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
2976 .map(|transport| &transport.adapter),
2977 )?;
2978 push_declared_binding(
2979 &mut bindings,
2980 AdapterFamily::OneShot,
2981 spec.capabilities.adapters.one_shot.as_ref(),
2982 )?;
2983 push_declared_binding(
2984 &mut bindings,
2985 AdapterFamily::History,
2986 spec.capabilities.adapters.history.as_ref(),
2987 )?;
2988 push_declared_binding(
2989 &mut bindings,
2990 AdapterFamily::Resume,
2991 spec.capabilities.adapters.resume.as_ref(),
2992 )?;
2993 push_declared_binding(
2994 &mut bindings,
2995 AdapterFamily::SessionOptions,
2996 spec.capabilities.adapters.session_options.as_ref(),
2997 )?;
2998 push_declared_binding(
2999 &mut bindings,
3000 AdapterFamily::CapabilityProbe,
3001 spec.capabilities.adapters.capability_probe.as_ref(),
3002 )?;
3003 Ok(bindings)
3004}
3005
3006fn push_declared_binding<'a>(
3007 bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3008 family: AdapterFamily,
3009 binding: Option<&'a AdapterBinding>,
3010) -> Result<(), NodeServerError> {
3011 let Some(binding) = binding else {
3012 return Ok(());
3013 };
3014 if let Some((_, existing)) = bindings
3015 .iter()
3016 .find(|(existing_family, _)| *existing_family == family)
3017 {
3018 if *existing == binding {
3019 return Ok(());
3020 }
3021 return Err(NodeServerError::ProviderContractManifest(format!(
3022 "provider declares conflicting {family:?} adapter bindings"
3023 )));
3024 }
3025 bindings.push((family, binding));
3026 Ok(())
3027}
3028
3029fn merge_durable_state(
3030 configured: &[WorkspaceConfig],
3031 mut loaded: LoadedNodeState,
3032) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3033 let mut workspaces = configured
3034 .iter()
3035 .cloned()
3036 .map(|workspace| (workspace.workspace_id.clone(), workspace))
3037 .collect::<BTreeMap<_, _>>();
3038 let mut roots = workspaces
3039 .values()
3040 .map(|workspace| {
3041 (
3042 platform::root_identity(&workspace.canonical_root),
3043 workspace.workspace_id.clone(),
3044 )
3045 })
3046 .collect::<BTreeMap<_, _>>();
3047 let mut warning = loaded
3048 .warning
3049 .take()
3050 .map(|message| session_registry::sanitized_persistence_summary(&message));
3051
3052 for (workspace_id, canonical_root) in loaded.workspaces {
3053 if let Some(existing) = workspaces.get(&workspace_id) {
3054 if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3055 return Err(NodeServerError::DurableState(io::Error::new(
3056 io::ErrorKind::InvalidData,
3057 DURABLE_STATE_CONFLICT_ERROR,
3058 )));
3059 }
3060 continue;
3061 }
3062 if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3063 return Err(NodeServerError::DurableState(io::Error::new(
3064 io::ErrorKind::InvalidData,
3065 DURABLE_STATE_CONFLICT_ERROR,
3066 )));
3067 }
3068 if !Path::new(&canonical_root).is_dir() {
3069 warning.get_or_insert_with(|| {
3070 session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3071 });
3072 continue;
3073 }
3074 let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3075 NodeServerError::DurableState(io::Error::new(
3076 io::ErrorKind::InvalidData,
3077 DURABLE_STATE_CONFLICT_ERROR,
3078 ))
3079 })?;
3080 roots.insert(
3081 platform::root_identity(&workspace.canonical_root),
3082 workspace.workspace_id.clone(),
3083 );
3084 workspaces.insert(workspace_id, workspace);
3085 }
3086
3087 for record in &mut loaded.records {
3088 match workspaces.get(&record.workspace_id) {
3089 Some(workspace)
3090 if platform::roots_equal(
3091 &workspace.canonical_root,
3092 windows_path_text(&record.canonical_root),
3093 ) => {}
3094 Some(_) => {
3095 return Err(NodeServerError::DurableState(io::Error::new(
3096 io::ErrorKind::InvalidData,
3097 DURABLE_STATE_CONFLICT_ERROR,
3098 )));
3099 }
3100 None => {
3101 record.state = ManagedSessionState::Unavailable;
3102 record.active_session = None;
3103 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3104 }
3105 }
3106 }
3107 Ok((workspaces.into_values().collect(), loaded.records, warning))
3108}
3109
3110struct RejectedCommandReason {
3113 message: String,
3114 unsupported_transport: bool,
3121 expires_at: Instant,
3122}
3123
3124struct NodeShared {
3125 handle: Gate4AgentHandle,
3126 access_token: String,
3127 node_id: NodeId,
3128 incarnation_id: NodeIncarnationId,
3129 started_at_unix_ms: u64,
3130 workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3131 worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3132 managed_worktree_profiles:
3133 BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3134 managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3135 enabled_providers: Vec<AgentId>,
3136 provider_runtime_statuses: ProviderRuntimeStatuses,
3137 provider_runtime_status_updates:
3138 RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3139 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3140 provider_contracts: Vec<ProviderContractSupport>,
3141 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3142 spawn_profiles: SpawnProfileRegistry,
3143 environment_profiles:
3144 RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3145 environment_materialization_profiles:
3146 RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3147 network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3155 #[cfg(feature = "dig2-station-probe")]
3159 browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3160 bundle_catalog: RwLock<BundleCatalog>,
3161 delivery_store: Mutex<Option<DeliveryStore>>,
3162 context_pack_store: Mutex<Option<ContextPackStore>>,
3163 context_catalog: RwLock<ContextPackCatalog>,
3164 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3165 harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3166 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3167 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3168 m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3170 m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3172 materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3173 spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3174 managed_spawn_replays:
3175 Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3176 controller: Mutex<Option<ControllerLease>>,
3177 provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3180 history: Mutex<NodeEventHistory>,
3181 event_tx: broadcast::Sender<NodeEventEnvelope>,
3182 terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3183 agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3191 harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3200 terminal_frame_watermarks:
3201 Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3202 pty_input_wake: tokio::sync::Notify,
3204 pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3207 next_connection_id: AtomicU64,
3208 next_instance_id: AtomicU64,
3209 next_command_id: AtomicU64,
3210 shutdown: AtomicBool,
3211 shutdown_notify: Notify,
3212 preauth_slots: Arc<Semaphore>,
3213 authenticated_slots: Arc<Semaphore>,
3214 inspection_slots: Arc<Semaphore>,
3215 mutation_gate: AsyncMutex<()>,
3216 session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3217 session_records: Mutex<SessionRecords>,
3218 state_path: Option<PathBuf>,
3219 persistence_error: RwLock<Option<String>>,
3220 state_transaction: Mutex<()>,
3221 input_settle_timeout_ms: u64,
3222 drive_loop_profile: Mutex<DriveLoopProfiler>,
3226 runtime_tick_profile: Mutex<TickProfileSnapshot>,
3232 shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3244 native_session_gauge: AtomicUsize,
3251 connection_loop_iterations: AtomicU64,
3262 connection_events_sent: AtomicU64,
3263 rejected_commands_total: AtomicU64,
3272 rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3292 drive_loop_iterations_total: AtomicU64,
3293 drive_loop_iterations_idle: AtomicU64,
3294 session_record_retention: SessionRecordRetentionConfig,
3298 retired_records_total: AtomicU64,
3304 #[cfg(feature = "fixture")]
3305 fixture_semantic_hook_policy: bool,
3306 #[cfg(feature = "fixture")]
3307 fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3308}
3309
3310impl Drop for NodeShared {
3311 fn drop(&mut self) {
3312 SessionLifecycleHook::on_shutdown(self);
3313 }
3314}
3315
3316#[derive(Clone, Debug, Eq, PartialEq)]
3317struct SessionBinding {
3318 workspace_id: WorkspaceId,
3319 generation: SessionGeneration,
3320 runtime_policy: ProviderRuntimePolicy,
3321 pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3322 record_id: Option<SessionRecordId>,
3323 managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3324 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3325 bundle: Option<ResolvedBundleReceipt>,
3326 context: Option<ResolvedContextPackReceipt>,
3327 materialization_id: Option<MaterializationId>,
3328}
3329
3330#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3331enum SpawnRecordPolicy {
3332 ProviderIdentityOnly,
3333 Always,
3334}
3335
3336struct NativeEnvironmentSelectionGuard {
3337 control: Option<NativeLaunchProfileControl>,
3338 instance_id: AgentInstanceId,
3339}
3340
3341struct NativeInstanceOverlayGuard {
3342 control: Option<NativeLaunchProfileControl>,
3343 instance_id: AgentInstanceId,
3344}
3345
3346struct NativeHarnessMcpOverlayGuard {
3347 control: NativeLaunchProfileControl,
3348 instance_id: AgentInstanceId,
3349}
3350
3351impl NativeHarnessMcpOverlayGuard {
3352 fn retain(self) { std::mem::forget(self); }
3353}
3354
3355impl Drop for NativeHarnessMcpOverlayGuard {
3356 fn drop(&mut self) {
3357 self.control
3358 .clear_native_mcp_server_launch_overlay(self.instance_id);
3359 }
3360}
3361
3362impl NativeInstanceOverlayGuard {
3363 fn retain(mut self) {
3364 self.control = None;
3365 }
3366}
3367
3368impl Drop for NativeInstanceOverlayGuard {
3369 fn drop(&mut self) {
3370 if let Some(control) = self.control.as_ref() {
3371 control.clear_native_instance_launch_overlay(self.instance_id);
3372 }
3373 }
3374}
3375
3376enum PreparedNativeLaunchOverlay {
3377 Environment(NativeLaunchEnvironmentOverlay),
3378 Instance(NativeInstanceLaunchOverlay),
3379}
3380
3381impl NativeEnvironmentSelectionGuard {
3382 fn retain(mut self) {
3383 self.control = None;
3384 }
3385}
3386
3387impl Drop for NativeEnvironmentSelectionGuard {
3388 fn drop(&mut self) {
3389 if let Some(control) = self.control.as_ref() {
3390 control.clear_native_launch_profile_selection(self.instance_id);
3391 }
3392 }
3393}
3394
3395struct SessionMaterializationGuard<'a> {
3396 shared: &'a NodeShared,
3397 id: Option<MaterializationId>,
3398 provider_home: Option<PathBuf>,
3401}
3402
3403impl SessionMaterializationGuard<'_> {
3404 fn id(&self) -> Option<&MaterializationId> {
3405 self.id.as_ref()
3406 }
3407
3408 fn provider_home(&self) -> Option<&Path> {
3409 self.provider_home.as_deref()
3410 }
3411
3412 fn retain(mut self) {
3413 self.id = None;
3414 }
3415}
3416
3417impl Drop for SessionMaterializationGuard<'_> {
3418 fn drop(&mut self) {
3419 if let Some(id) = self.id.take() {
3420 let _ = self.shared.cleanup_materialization(&id);
3421 }
3422 }
3423}
3424
3425
3426trait SessionLifecycleHook {
3429 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3431 fn on_session_ended(&self, address: &SessionAddress);
3433 fn on_shutdown(&self);
3435 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3437 fn cleanup_session_materialization(
3439 &self,
3440 address: &SessionAddress,
3441 binding: &SessionBinding,
3442 ) -> Result<(), NodeFailure>;
3443}
3444
3445impl SessionLifecycleHook for NodeShared {
3446 fn on_session_ended(&self, address: &SessionAddress) {
3447 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3448 registry.revoke_session(address);
3449 }
3450 if let Some(control) = self.native_launch_profile_control.as_ref() {
3451 control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3452 }
3453 }
3454
3455 fn on_shutdown(&self) {
3456 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3457 let instances = registry.shutdown();
3458 if let Some(control) = self.native_launch_profile_control.as_ref() {
3459 for instance_id in instances {
3460 control.clear_native_mcp_server_launch_overlay(instance_id);
3461 }
3462 }
3463 }
3464 }
3465
3466 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3467 if let Some(control) = self.native_launch_profile_control.as_ref() {
3468 control.clear_native_mcp_server_launch_overlay(instance_id);
3469 }
3470 }
3471
3472 fn cleanup_session_materialization(
3473 &self,
3474 address: &SessionAddress,
3475 binding: &SessionBinding,
3476 ) -> Result<(), NodeFailure> {
3477 self.cleanup_session_owned_materialization(address, binding)
3478 }
3479
3480 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3481 #[cfg(feature = "dig2-station-probe")]
3484 {
3485 crate::dig2_station_lease::release_lease_for_holder(
3486 &self.browser_station_leases,
3487 address.session.instance_id,
3488 );
3489 }
3490 if let Some(control) = self.native_launch_profile_control.as_ref() {
3491 control.clear_native_mcp_server_launch_overlay(
3492 address.session.instance_id,
3493 );
3494 }
3495 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3496 registry.revoke_session(address);
3497 }
3498 if removed
3499 .is_some_and(|binding| {
3500 binding.environment_profile.is_some()
3501 || binding.bundle.is_some()
3502 || binding.context.is_some()
3503 })
3504 {
3505 if let Some(control) = self.native_launch_profile_control.as_ref() {
3506 control.clear_native_launch_profile_selection(
3507 address.session.instance_id,
3508 );
3509 control.clear_native_instance_launch_overlay(
3510 address.session.instance_id,
3511 );
3512 }
3513 }
3514 }
3515}
3516
3517struct SpawnPrepareRequest<'r> {
3518 instance_id: AgentInstanceId,
3519 address: &'r SessionAddress,
3520 provider: &'r AgentId,
3521 mode: SessionMode,
3522 environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3523 bundle: Option<&'r ResolvedBundleReceipt>,
3524 context: Option<&'r ResolvedContextPackReceipt>,
3525 managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3526 harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3527 approval_level: ApprovalLevel,
3528 network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3529 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3530}
3531
3532struct PreparedSpawn<'a> {
3536 instance_overlay: Option<NativeInstanceOverlayGuard>,
3537 environment_selection: Option<NativeEnvironmentSelectionGuard>,
3538 materialization_guard: Option<SessionMaterializationGuard<'a>>,
3539 harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3540 #[cfg(feature = "dig2-station-probe")]
3541 browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3542 materialization_id: Option<MaterializationId>,
3543}
3544
3545impl PreparedSpawn<'_> {
3546 fn retain_session_guards(&mut self) {
3548 if let Some(selection) = self.environment_selection.take() {
3549 selection.retain();
3550 }
3551 if let Some(overlay) = self.instance_overlay.take() {
3552 overlay.retain();
3553 }
3554 if let Some(guard) = self.materialization_guard.take() {
3555 guard.retain();
3556 }
3557 }
3558
3559 fn retain_launch_guards(&mut self) {
3561 if let Some(overlay) = self.harness_mcp_overlay.take() {
3562 overlay.retain();
3563 }
3564 #[cfg(feature = "dig2-station-probe")]
3565 if let Some(guard) = self.browser_station_lease_guard.take() {
3566 guard.retain();
3567 }
3568 }
3569}
3570
3571trait SpawnPreparer {
3575 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3576 fn select_environment(
3578 &self,
3579 instance_id: AgentInstanceId,
3580 provider: &AgentId,
3581 mode: SessionMode,
3582 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3583 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3584 fn install_launch_overlay(
3586 &self,
3587 instance_id: AgentInstanceId,
3588 overlay: PreparedNativeLaunchOverlay,
3589 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3590}
3591
3592impl SpawnPreparer for NodeShared {
3593 fn select_environment(
3594 &self,
3595 instance_id: AgentInstanceId,
3596 provider: &AgentId,
3597 mode: SessionMode,
3598 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3599 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3600 self.select_environment_profile(instance_id, provider, mode, environment_profile)
3601 }
3602
3603 fn install_launch_overlay(
3604 &self,
3605 instance_id: AgentInstanceId,
3606 overlay: PreparedNativeLaunchOverlay,
3607 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3608 self.install_prepared_launch_overlay(instance_id, overlay)
3609 }
3610
3611 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3612 let SpawnPrepareRequest {
3613 instance_id,
3614 address,
3615 provider,
3616 mode,
3617 environment_profile,
3618 bundle,
3619 context,
3620 managed_authority,
3621 harness_mcp,
3622 approval_level,
3623 network_allowlist,
3624 browser_profile_id,
3625 } = request;
3626 #[cfg(feature = "dig2-station-probe")]
3631 let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3632 use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3633 match acquire_lease(
3634 &self.browser_station_leases,
3635 profile_id,
3636 instance_id,
3637 unix_time_ms(),
3638 ) {
3639 Ok(guard) => Some(guard),
3640 Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3641 return Err(failure(
3642 NodeFailureCode::BrowserStationProfileBusy,
3643 "dig2browser station profile is already leased by another session",
3644 ));
3645 }
3646 Err(BrowserStationLeaseError::CapacityExceeded) => {
3647 return Err(failure(
3648 NodeFailureCode::BrowserStationProfileBusy,
3649 "dig2browser station profile lease capacity exhausted",
3650 ));
3651 }
3652 Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3653 return Err(failure(
3654 NodeFailureCode::BrowserStationProfileBusy,
3655 "session already holds a dig2browser station profile lease",
3656 ));
3657 }
3658 }
3659 } else {
3660 None
3661 };
3662 #[cfg(not(feature = "dig2-station-probe"))]
3663 let _ = browser_profile_id;
3664 let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3665 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3674 {
3675 return Err(failure(
3676 NodeFailureCode::BindingMismatch,
3677 "harness MCP reservation does not match the exact PTY/ACP provider",
3678 ));
3679 }
3680 prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3681 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3682 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3683 })?;
3684 let launch = prepared.launch();
3689 let mut env_entries = vec![
3690 (
3691 OsString::from(launch.endpoint_env.as_str()),
3692 prepared.endpoint().as_os_str().to_os_string(),
3693 ),
3694 (
3695 OsString::from(launch.token_env.as_str()),
3696 prepared.token().expose().into(),
3697 ),
3698 (
3699 OsString::from(launch.program_env.as_str()),
3700 prepared.helper_program().as_os_str().to_os_string(),
3701 ),
3702 ];
3703 if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3704 env_entries.push(trace_entry);
3705 }
3706 if let Some(door) = crate::door::door_listen_addr() {
3707 env_entries.push((
3708 OsString::from("HATCHERY_NODE_DOOR"),
3709 OsString::from(door.to_string()),
3710 ));
3711 let session_key = format!(
3715 "pty-{}-{}-{}",
3716 self.incarnation_id,
3717 address.workspace_id.as_str(),
3718 address.session.instance_id.0
3719 );
3720 env_entries.push((
3721 OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3722 OsString::from(session_key),
3723 ));
3724 }
3725 let overlay = NativeMcpServerLaunchOverlay::new(
3726 provider.clone(),
3727 launch.server_name.as_str(),
3728 prepared.helper_program().as_os_str().to_os_string(),
3729 launch.args.iter().map(OsString::from).collect(),
3730 env_entries,
3731 launch.scrub_env.iter().map(OsString::from).collect(),
3732 ).map_err(|_| failure(
3733 NodeFailureCode::HarnessMcpUnavailable,
3734 "harness MCP launch overlay is unavailable",
3735 ))?;
3736 control
3737 .install_native_mcp_server_launch_overlay(instance_id, overlay)
3738 .map_err(|_| failure(
3739 NodeFailureCode::HarnessMcpUnavailable,
3740 "harness MCP launch overlay could not be installed",
3741 ))?;
3742 tracing::info!(
3748 node_id = %self.node_id,
3749 instance_id = ?instance_id,
3750 provider = %provider,
3751 mode = ?mode,
3752 "harness MCP overlay installed for spawn",
3753 );
3754 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3755 } else {
3756 None
3757 };
3758 let prepared_materialization = self.prepare_session_materialization(
3759 &address,
3760 &provider,
3761 mode,
3762 environment_profile,
3763 bundle,
3764 context,
3765 managed_authority.cloned(),
3766 )?;
3767 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3768 Some((overlay, guard)) => (Some(overlay), Some(guard)),
3769 None => (None, None),
3770 };
3771 let materialization_id = materialization_guard
3772 .as_ref()
3773 .and_then(SessionMaterializationGuard::id)
3774 .cloned();
3775 let mut environment_selection = SpawnPreparer::select_environment(self,
3776 instance_id,
3777 &provider,
3778 mode,
3779 environment_profile,
3780 )?;
3781 let provider_home = materialization_guard
3784 .as_ref()
3785 .and_then(SessionMaterializationGuard::provider_home);
3786 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3787 &provider,
3788 mode,
3789 approval_level,
3790 network_allowlist,
3791 provider_home,
3792 )?;
3793 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3794 (prepared, args) if args.is_empty() => prepared,
3795 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3796 overlay.append_extra_args(args).map_err(|_| {
3797 failure(
3798 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3799 "network allowlist overlay could not merge with instance launch args",
3800 )
3801 })?;
3802 Some(PreparedNativeLaunchOverlay::Instance(overlay))
3803 }
3804 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3805 Some(PreparedNativeLaunchOverlay::Instance(
3806 overlay
3807 .into_instance_with_extra_args(args)
3808 .map_err(|_| {
3809 failure(
3810 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3811 "network allowlist overlay could not merge with environment overlay",
3812 )
3813 })?,
3814 ))
3815 }
3816 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3817 NativeInstanceLaunchOverlay::new(
3818 provider.clone(),
3819 match mode {
3820 SessionMode::Pty => TransportKind::Pty,
3821 SessionMode::Acp => TransportKind::Acp,
3822 SessionMode::Inline => TransportKind::Pipe,
3823 },
3824 Vec::new(),
3825 args,
3826 )
3827 .map_err(|_| {
3828 failure(
3829 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3830 "network allowlist overlay is invalid for this transport",
3831 )
3832 })?,
3833 )),
3834 };
3835 let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str())) {
3838 (SessionMode::Pty, Some(accounts), Some(m4a_provider)) => {
3839 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3840 match accounts.provision(m4a_provider, &session_key) {
3841 Ok(account) => {
3842 let identity = account.env_mutations();
3843 let merged = match environment_overlay {
3844 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3845 .map(PreparedNativeLaunchOverlay::Instance),
3846 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3847 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3848 }
3849 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3850 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3851 }
3852 }
3853 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3854 tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3855 self.m4a_live
3856 .lock()
3857 .unwrap_or_else(|p| p.into_inner())
3858 .insert(account.nick.clone(), (instance_id, provider.clone()));
3859 Some(merged)
3860 }
3861 Err(error) => {
3862 tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3863 environment_overlay
3864 }
3865 }
3866 }
3867 (SessionMode::Pty, Some(_), None) => {
3868 tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
3869 environment_overlay
3870 }
3871 _ => environment_overlay,
3872 };
3873 let mut instance_overlay = environment_overlay
3874 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
3875 .transpose()?
3876 .flatten();
3877 Ok(PreparedSpawn {
3878 instance_overlay,
3879 environment_selection,
3880 materialization_guard,
3881 harness_mcp_overlay,
3882 #[cfg(feature = "dig2-station-probe")]
3883 browser_station_lease_guard,
3884 materialization_id,
3885 })
3886 }
3887}
3888
3889struct SessionRecords {
3890 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3891}
3892
3893struct SpawnIdempotencyCache {
3894 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3895}
3896
3897struct SpawnIdempotencyEntry {
3898 value: SpawnIdempotencyValue,
3899 expires_at: Instant,
3900}
3901
3902enum SpawnIdempotencyValue {
3903 Standard {
3904 spec: SpawnSpec,
3905 result: Result<ResolvedSpawnReceipt, NodeFailure>,
3906 },
3907 Managed {
3908 request: ManagedWorktreeSpawnRequest,
3909 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3910 },
3911 HarnessMcp,
3912}
3913
3914enum DurableManagedSpawnReplayDecision {
3915 Reserved,
3916 PendingLinked,
3917 Committed(ManagedWorktreeSpawnReceipt),
3918}
3919
3920impl SpawnIdempotencyValue {
3921 fn references_context(&self, context_id: &SpawnContextId) -> bool {
3922 match self {
3923 Self::Standard { result, .. } => result
3924 .as_ref()
3925 .ok()
3926 .and_then(|receipt| receipt.context.as_ref()),
3927 Self::Managed { result, .. } => result
3928 .as_ref()
3929 .ok()
3930 .and_then(|receipt| receipt.spawn.context.as_ref()),
3931 Self::HarnessMcp => None,
3932 }
3933 .is_some_and(|receipt| &receipt.id == context_id)
3934 }
3935}
3936
3937#[derive(Clone, Copy)]
3938struct ControllerLease {
3939 connection_id: u64,
3940 expires_at: Instant,
3941}
3942
3943struct NodeEventHistory {
3944 last_sequence: u64,
3945 replay_floor_sequence: u64,
3946 events: VecDeque<NodeEventEnvelope>,
3947 record_providers: BTreeMap<SessionRecordId, AgentId>,
3948 removed_record_providers: BTreeMap<u64, AgentId>,
3949}
3950
3951impl NodeEventHistory {
3952 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
3953 Self {
3954 last_sequence: 0,
3955 replay_floor_sequence: 1,
3956 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
3957 record_providers,
3958 removed_record_providers: BTreeMap::new(),
3959 }
3960 }
3961}
3962
3963impl NodeShared {
3964 fn new_with_incarnation(
3965 handle: Gate4AgentHandle,
3966 access_token: String,
3967 node_id: NodeId,
3968 incarnation_id: NodeIncarnationId,
3969 workspaces: Vec<WorkspaceConfig>,
3970 enabled_providers: Vec<AgentId>,
3971 provider_runtime_statuses: ProviderRuntimeStatuses,
3972 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3973 provider_contracts: Vec<ProviderContractSupport>,
3974 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3975 spawn_profiles: SpawnProfileRegistry,
3976 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3977 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3978 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3979 state_path: Option<PathBuf>,
3980 records: Vec<ManagedSessionRecord>,
3981 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
3982 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
3983 materializations: Vec<MaterializationOwnershipRecord>,
3984 persistence_warning: Option<String>,
3985 input_settle_timeout_ms: u64,
3986 ) -> Self {
3987 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
3988 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
3989 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
3990 let (harness_mcp_proxy_event_tx, _) =
3991 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
3992 let record_providers = records
3993 .iter()
3994 .map(|record| (record.record_id.clone(), record.provider.clone()))
3995 .collect();
3996 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
3997 .iter()
3998 .map(|workspace| {
3999 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4000 })
4001 .collect();
4002 for lease in &managed_worktrees {
4003 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4004 }
4005 let managed_worktree_profiles = workspaces.iter()
4006 .map(|workspace| (
4007 workspace.workspace_id.clone(),
4008 workspace.managed_worktree_profiles.clone(),
4009 ))
4010 .collect();
4011 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4012 managed_worktrees,
4013 managed_worktree_tombstones,
4014 ).expect("durable managed worktree registry was validated while loading");
4015 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4016 managed_worktrees.reattach_record_holders(
4017 &managed_worktree_record_holders(&records),
4018 unix_time_ms(),
4019 );
4020 Self {
4021 handle,
4022 access_token,
4023 node_id,
4024 incarnation_id,
4025 started_at_unix_ms: std::time::SystemTime::now()
4026 .duration_since(std::time::UNIX_EPOCH)
4027 .unwrap_or_default()
4028 .as_millis()
4029 .min(u64::MAX as u128) as u64,
4030 workspaces: RwLock::new(
4031 workspaces
4032 .into_iter()
4033 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4034 .collect(),
4035 ),
4036 worktree_service_modes,
4037 managed_worktree_profiles,
4038 managed_worktrees: Mutex::new(managed_worktrees),
4039 enabled_providers,
4040 provider_runtime_statuses,
4041 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4042 provider_runtime_monitor,
4043 provider_contracts,
4044 provider_adapter_contracts,
4045 spawn_profiles,
4046 environment_profiles: RwLock::new(BTreeMap::new()),
4047 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4048 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4049 #[cfg(feature = "dig2-station-probe")]
4050 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4051 bundle_catalog: RwLock::new(BundleCatalog::default()),
4052 delivery_store: Mutex::new(None),
4053 context_pack_store: Mutex::new(None),
4054 context_catalog: RwLock::new(ContextPackCatalog::default()),
4055 native_launch_profile_control,
4056 harness_mcp_registry: None,
4057 native_session_catalog,
4058 session_environment_materializer,
4059 m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4060 m4a_live: Mutex::new(BTreeMap::new()),
4061 materializations: Mutex::new(
4062 materializations
4063 .into_iter()
4064 .map(|record| (record.id().clone(), record))
4065 .collect(),
4066 ),
4067 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4068 entries: BTreeMap::new(),
4069 }),
4070 managed_spawn_replays: Mutex::new(BTreeMap::new()),
4071 controller: Mutex::new(None),
4072 provisional_spawns: Mutex::new(BTreeMap::new()),
4073 history: Mutex::new(NodeEventHistory::new(record_providers)),
4074 event_tx,
4075 terminal_event_tx,
4076 agent_stream_event_tx,
4077 harness_mcp_proxy_event_tx,
4078 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4079 pty_input_wake: tokio::sync::Notify::new(),
4080 pty_input_marks: Mutex::new(BTreeMap::new()),
4081 next_connection_id: AtomicU64::new(1),
4082 next_instance_id: AtomicU64::new(1),
4083 next_command_id: AtomicU64::new(1),
4084 shutdown: AtomicBool::new(false),
4085 shutdown_notify: Notify::new(),
4086 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4087 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4088 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4089 mutation_gate: AsyncMutex::new(()),
4090 session_bindings: Mutex::new(BTreeMap::new()),
4091 session_records: Mutex::new(SessionRecords {
4092 records: records
4093 .into_iter()
4094 .map(|record| (record.record_id.clone(), record))
4095 .collect(),
4096 }),
4097 state_path,
4098 persistence_error: RwLock::new(
4099 persistence_warning
4100 .map(|message| session_registry::sanitized_persistence_summary(&message)),
4101 ),
4102 state_transaction: Mutex::new(()),
4103 input_settle_timeout_ms,
4104 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4105 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4106 shell_efficiency_profile: OnceLock::new(),
4107 native_session_gauge: AtomicUsize::new(0),
4108 connection_loop_iterations: AtomicU64::new(0),
4109 connection_events_sent: AtomicU64::new(0),
4110 rejected_commands_total: AtomicU64::new(0),
4111 rejected_command_reasons: Mutex::new(BTreeMap::new()),
4112 drive_loop_iterations_total: AtomicU64::new(0),
4113 drive_loop_iterations_idle: AtomicU64::new(0),
4114 session_record_retention: SessionRecordRetentionConfig::default(),
4115 retired_records_total: AtomicU64::new(0),
4116 #[cfg(feature = "fixture")]
4117 fixture_semantic_hook_policy: false,
4118 #[cfg(feature = "fixture")]
4119 fixture_spawn_managed_worktree_v2_failure_probe:
4120 SpawnManagedWorktreeV2FailureProbe::default(),
4121 }
4122 }
4123
4124 #[cfg(test)]
4125 fn new(
4126 handle: Gate4AgentHandle,
4127 access_token: String,
4128 node_id: NodeId,
4129 workspaces: Vec<WorkspaceConfig>,
4130 enabled_providers: Vec<AgentId>,
4131 ) -> Self {
4132 Self::new_with_incarnation(
4133 handle,
4134 access_token,
4135 node_id,
4136 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4137 workspaces,
4138 enabled_providers,
4139 ProviderRuntimeStatuses::default(),
4140 None,
4141 Vec::new(),
4142 Vec::new(),
4143 SpawnProfileRegistry::default(),
4144 None,
4145 None,
4146 None,
4147 None,
4148 Vec::new(),
4149 Vec::new(),
4150 Vec::new(),
4151 Vec::new(),
4152 None,
4153 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4154 )
4155 }
4156
4157 fn persistence_error(&self) -> Option<String> {
4158 self.persistence_error
4159 .read()
4160 .unwrap_or_else(|poisoned| poisoned.into_inner())
4161 .clone()
4162 }
4163
4164 async fn admit_provider_runtime(
4165 &self,
4166 provider: &AgentId,
4167 requirement: ProviderRuntimeRequirement,
4168 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4169 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4170 let pipe_transport = monitor.supports_pipe_transport(provider);
4171 let acp_transport = monitor.supports_acp_transport(provider);
4172 let provider = provider.clone();
4173 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4174 let (status, admission) = timeout(
4175 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4176 refresh,
4177 )
4178 .await
4179 .map_err(|_| failure(
4180 NodeFailureCode::BackendBusy,
4181 "provider runtime probe exceeded its bounded deadline",
4182 ))?
4183 .map_err(|_| failure(
4184 NodeFailureCode::BackendOperationFailed,
4185 "provider runtime probe task failed",
4186 ))?;
4187 if let Some(status) = status {
4188 self
4189 .provider_runtime_status_updates
4190 .write()
4191 .unwrap_or_else(|poisoned| poisoned.into_inner())
4192 .insert(status.provider().clone(), status);
4193 }
4194 admission.and_then(|policy| {
4195 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4196 })
4197 } else {
4198 crate::provider_runtime::admit_status(
4204 &self.provider_runtime_statuses,
4205 provider,
4206 false,
4207 false,
4208 requirement,
4209 )
4210 };
4211 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4212 admission.map_err(|error| match error {
4213 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4214 NodeFailureCode::BackendOperationFailed,
4215 "provider launcher is unavailable",
4216 ),
4217 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4218 NodeFailureCode::UnsupportedCapability,
4219 "provider semantic capability is not verified",
4220 ),
4221 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4222 NodeFailureCode::BackendBusy,
4223 "provider runtime probe is already in progress",
4224 ),
4225 })
4226 }
4227
4228 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4234 match self.provider_runtime_monitor.as_deref() {
4235 Some(monitor) => monitor.supports_pipe_transport(provider),
4236 None => false,
4237 }
4238 }
4239
4240 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4246 match self.provider_runtime_monitor.as_deref() {
4247 Some(monitor) => monitor.supports_acp_transport(provider),
4248 None => false,
4249 }
4250 }
4251
4252 fn admit_declared_hook_semantics(
4268 &self,
4269 provider: &AgentId,
4270 policy: ProviderRuntimePolicy,
4271 ) -> ProviderRuntimePolicy {
4272 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4273 return policy;
4274 }
4275 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4276 contract.provider == *provider && contract.family == AdapterFamily::Hook
4277 });
4278 if !declares_hook {
4279 return policy;
4280 }
4281 ProviderRuntimePolicy::new(
4282 policy.raw_pty_lifecycle,
4283 policy.semantic_readiness,
4284 policy.structured_prompt,
4285 policy.provider_session_identity,
4286 policy.semantic_resume,
4287 true,
4288 )
4289 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4290 }
4291
4292 fn resolve_network_allowlist_overlay_args(
4307 &self,
4308 provider: &AgentId,
4309 mode: SessionMode,
4310 approval_level: ApprovalLevel,
4311 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4312 provider_home: Option<&Path>,
4313 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4314 let Some(allowlist_id) = network_allowlist else {
4315 return Ok(Vec::new());
4316 };
4317 let catalog = self
4318 .network_allowlist_catalog
4319 .read()
4320 .unwrap_or_else(|poisoned| poisoned.into_inner());
4321 let Some(entry) = catalog.get(allowlist_id) else {
4322 return Err(failure(
4323 NodeFailureCode::UnknownNetworkAllowlist,
4324 "spawn network allowlist is unavailable on this node",
4325 ));
4326 };
4327
4328 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4330 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4331 |message| {
4332 failure(
4333 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4334 &message,
4335 )
4336 },
4337 )?;
4338 if !matches!(mode, SessionMode::Pty) {
4339 return Err(failure(
4340 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4341 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4342 ));
4343 }
4344 let Some(home) = provider_home else {
4345 return Err(failure(
4346 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4347 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4348 ));
4349 };
4350 let domains =
4351 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4352 if domains.is_empty() {
4353 return Err(failure(
4354 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4355 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4356 ));
4357 }
4358 let settings_path = home.join(
4359 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4360 );
4361 let json =
4362 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4363 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4364 |_| {
4365 failure(
4366 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4367 "Claude station network settings sidecar could not be written under ProviderHome",
4368 )
4369 },
4370 )?;
4371 let overlay =
4372 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4373 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4374 }
4375
4376 let Some(native) = entry.provider_native.as_ref() else {
4377 return Ok(Vec::new());
4378 };
4379 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4380 provider.as_str(),
4381 approval_level,
4382 native,
4383 )
4384 .map_err(|message| {
4385 failure(
4386 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4387 &message,
4388 )
4389 })?;
4390 if overlay.is_empty() {
4391 return Ok(Vec::new());
4392 }
4393 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4394 return Err(failure(
4395 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4396 "Codex network_access -c overlay requires PTY or ACP transport",
4397 ));
4398 }
4399 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4400 }
4401
4402 fn resolve_codex_network_access_overlay_args(
4404 &self,
4405 provider: &AgentId,
4406 mode: SessionMode,
4407 approval_level: ApprovalLevel,
4408 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4409 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4410 self.resolve_network_allowlist_overlay_args(
4411 provider,
4412 mode,
4413 approval_level,
4414 network_allowlist,
4415 None,
4416 )
4417 }
4418
4419 fn resolve_environment_profile(
4420 &self,
4421 resolved: &ResolvedSpawnSpec,
4422 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4423 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4429 let catalog = self
4430 .network_allowlist_catalog
4431 .read()
4432 .unwrap_or_else(|poisoned| poisoned.into_inner());
4433 let Some(entry) = catalog.get(allowlist_id) else {
4434 return Err(failure(
4435 NodeFailureCode::UnknownNetworkAllowlist,
4436 "spawn network allowlist is unavailable on this node",
4437 ));
4438 };
4439 if let Some(native) = entry.provider_native.as_ref() {
4442 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4443 resolved.provider.as_str(),
4444 resolved.approval_level,
4445 native,
4446 ) {
4447 return Err(failure(
4448 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4449 &message,
4450 ));
4451 }
4452 }
4453 }
4454 #[cfg(feature = "dig2-station-probe")]
4463 if resolved.browser_profile_id.is_some() {
4464 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4465 match probe_station_reachable() {
4466 Ok(()) => {}
4467 Err(StationProbeError::UnsupportedPlatform)
4468 | Err(StationProbeError::InvalidPipeSuffix) => {
4469 return Err(failure(
4470 NodeFailureCode::BrowserStationProbeUnavailable,
4471 "dig2browser station probe unavailable on this platform",
4472 ));
4473 }
4474 Err(StationProbeError::Unreachable) => {
4475 return Err(failure(
4476 NodeFailureCode::BrowserStationUnreachable,
4477 "dig2browser station named-pipe is unreachable",
4478 ));
4479 }
4480 }
4481 }
4482 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4483 return Ok(None);
4484 };
4485 let profiles = self
4486 .environment_profiles
4487 .read()
4488 .unwrap_or_else(|poisoned| poisoned.into_inner());
4489 let binding = profiles.get(profile_id).ok_or_else(|| {
4490 failure(
4491 NodeFailureCode::UnknownEnvironmentProfile,
4492 "spawn environment profile is unavailable on this node",
4493 )
4494 })?;
4495 if binding.provider != resolved.provider
4496 || binding.native_profile_id(resolved.mode).is_none()
4497 {
4498 return Err(failure(
4499 NodeFailureCode::EnvironmentProfileBindingMismatch,
4500 "spawn environment profile does not match the provider and mode",
4501 ));
4502 }
4503 Ok(Some(ResolvedEnvironmentProfileReceipt {
4507 profile_id: binding.id.clone(),
4508 profile_revision: binding.revision.clone(),
4509 network_allowlist: resolved.network_allowlist.clone(),
4510 browser_profile_id: resolved.browser_profile_id.clone(),
4511 }))
4512 }
4513
4514 fn resolve_bundle(
4515 &self,
4516 resolved: &ResolvedSpawnSpec,
4517 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4518 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4519 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4520 return Ok(None);
4521 };
4522 let catalog = self.bundle_catalog
4523 .read()
4524 .unwrap_or_else(|poisoned| poisoned.into_inner());
4525 let bundle = catalog.get(bundle_id).ok_or_else(|| {
4526 failure(
4527 NodeFailureCode::UnknownBundle,
4528 "spawn bundle is unavailable on this node",
4529 )
4530 })?;
4531 self.bundle_layout(
4532 &resolved.provider,
4533 resolved.mode,
4534 environment_profile,
4535 bundle,
4536 )?;
4537 Ok(Some(bundle.receipt()))
4538 }
4539
4540 fn begin_delivery_stage(
4541 &self,
4542 manifest: DeliveryBundleManifestV2,
4543 ) -> Result<NodeResponse, NodeFailure> {
4544 let mut store = self
4545 .delivery_store
4546 .lock()
4547 .unwrap_or_else(|poisoned| poisoned.into_inner());
4548 let store = store.as_mut().ok_or_else(|| {
4549 failure(
4550 NodeFailureCode::DeliveryStageStorageFailed,
4551 "node delivery store is unavailable",
4552 )
4553 })?;
4554 let begun = store.begin(manifest).map_err(delivery_failure)?;
4555 Ok(NodeResponse::DeliveryStageBegun {
4556 stage_id: begun.stage_id,
4557 manifest_digest: begun.manifest_digest,
4558 missing_blobs: begun.missing_blobs,
4559 })
4560 }
4561
4562 fn put_delivery_blob_chunk(
4563 &self,
4564 stage_id: DeliveryStageId,
4565 blob_digest: DeliveryBlobDigestV1,
4566 offset: u64,
4567 chunk_hex: DeliveryBlobChunkHexV1,
4568 ) -> Result<NodeResponse, NodeFailure> {
4569 let mut store = self
4570 .delivery_store
4571 .lock()
4572 .unwrap_or_else(|poisoned| poisoned.into_inner());
4573 let store = store.as_mut().ok_or_else(|| {
4574 failure(
4575 NodeFailureCode::DeliveryStageStorageFailed,
4576 "node delivery store is unavailable",
4577 )
4578 })?;
4579 let accepted = store
4580 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4581 .map_err(delivery_failure)?;
4582 Ok(NodeResponse::DeliveryBlobChunkAccepted {
4583 stage_id: accepted.stage_id,
4584 blob_digest: accepted.blob_digest,
4585 next_offset: accepted.next_offset,
4586 })
4587 }
4588
4589 fn commit_delivery_stage(
4590 &self,
4591 stage_id: DeliveryStageId,
4592 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4593 let mut store = self
4594 .delivery_store
4595 .lock()
4596 .unwrap_or_else(|poisoned| poisoned.into_inner());
4597 let store = store.as_mut().ok_or_else(|| {
4598 failure(
4599 NodeFailureCode::DeliveryStageStorageFailed,
4600 "node delivery store is unavailable",
4601 )
4602 })?;
4603 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4604 let mut catalog = self
4605 .bundle_catalog
4606 .write()
4607 .unwrap_or_else(|poisoned| poisoned.into_inner());
4608 let mut next_catalog = catalog.clone();
4609 next_catalog
4610 .insert_idempotent(prepared.bundle().clone())
4611 .map_err(|_| {
4612 failure(
4613 NodeFailureCode::DeliveryStageConflict,
4614 "delivery bundle identity conflicts with installed content",
4615 )
4616 })?;
4617 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4618 *catalog = next_catalog;
4619 Ok(receipt)
4620 }
4621
4622 fn abort_delivery_stage(
4623 &self,
4624 stage_id: &DeliveryStageId,
4625 ) -> Result<(), NodeFailure> {
4626 let mut store = self
4627 .delivery_store
4628 .lock()
4629 .unwrap_or_else(|poisoned| poisoned.into_inner());
4630 let store = store.as_mut().ok_or_else(|| {
4631 failure(
4632 NodeFailureCode::DeliveryStageStorageFailed,
4633 "node delivery store is unavailable",
4634 )
4635 })?;
4636 store.abort(stage_id).map_err(delivery_failure)
4637 }
4638
4639 fn resolve_context(
4640 &self,
4641 resolved: &ResolvedSpawnSpec,
4642 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4643 let Some(context_id) = resolved.context_id.as_ref() else {
4644 return Ok(None);
4645 };
4646 self.context_catalog
4647 .read()
4648 .unwrap_or_else(|poisoned| poisoned.into_inner())
4649 .get(context_id)
4650 .map(|context| context.receipt().clone())
4651 .ok_or_else(|| {
4652 failure(
4653 NodeFailureCode::UnknownContextPack,
4654 "spawn context pack is unavailable on this node",
4655 )
4656 })
4657 .map(Some)
4658 }
4659
4660 fn bundle_layout(
4661 &self,
4662 provider: &AgentId,
4663 mode: SessionMode,
4664 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4665 bundle: &NodeBundle,
4666 ) -> Result<BundleProviderLayout, NodeFailure> {
4667 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4668 failure(
4669 NodeFailureCode::BundleBindingMismatch,
4670 "spawn bundle does not match the provider and mode",
4671 )
4672 })?;
4673 if layout != BundleProviderLayout::Codex {
4674 return Ok(layout);
4675 }
4676 let profile = environment_profile.and_then(|receipt| {
4677 let profiles = self
4678 .environment_profiles
4679 .read()
4680 .unwrap_or_else(|poisoned| poisoned.into_inner());
4681 let binding = profiles.get(&receipt.profile_id)?;
4682 if binding.revision != receipt.profile_revision
4683 || &binding.provider != provider
4684 || binding.native_profile_id(mode).is_none()
4685 {
4686 return None;
4687 }
4688 self.environment_materialization_profiles
4689 .read()
4690 .unwrap_or_else(|poisoned| poisoned.into_inner())
4691 .get(&receipt.profile_id)
4692 .cloned()
4693 });
4694 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4695 return Err(failure(
4696 NodeFailureCode::BundleBindingMismatch,
4697 "Codex bundle requires an exact isolated CODEX_HOME profile",
4698 ));
4699 }
4700 Ok(layout)
4701 }
4702
4703 fn select_environment_profile(
4704 &self,
4705 instance_id: AgentInstanceId,
4706 provider: &AgentId,
4707 mode: SessionMode,
4708 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4709 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4710 let Some(environment_profile) = environment_profile else {
4711 return Ok(None);
4712 };
4713 let native_profile_id = {
4714 let profiles = self
4715 .environment_profiles
4716 .read()
4717 .unwrap_or_else(|poisoned| poisoned.into_inner());
4718 let binding = profiles
4719 .get(&environment_profile.profile_id)
4720 .ok_or_else(|| {
4721 failure(
4722 NodeFailureCode::UnknownEnvironmentProfile,
4723 "session environment profile is unavailable on this node",
4724 )
4725 })?;
4726 if &binding.revision != &environment_profile.profile_revision
4727 || &binding.provider != provider
4728 {
4729 return Err(failure(
4730 NodeFailureCode::EnvironmentProfileBindingMismatch,
4731 "session environment profile revision or provider changed",
4732 ));
4733 }
4734 binding.native_profile_id(mode).cloned().ok_or_else(|| {
4735 failure(
4736 NodeFailureCode::EnvironmentProfileBindingMismatch,
4737 "session environment profile does not support this mode",
4738 )
4739 })?
4740 };
4741 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4742 failure(
4743 NodeFailureCode::BackendOperationFailed,
4744 "native environment profile controller is unavailable",
4745 )
4746 })?;
4747 control
4748 .select_native_launch_profile(instance_id, native_profile_id)
4749 .map_err(|_| {
4750 failure(
4751 NodeFailureCode::BackendOperationFailed,
4752 "native environment profile selection failed",
4753 )
4754 })?;
4755 Ok(Some(NativeEnvironmentSelectionGuard {
4756 control: Some(control),
4757 instance_id,
4758 }))
4759 }
4760
4761 fn install_prepared_launch_overlay(
4762 &self,
4763 instance_id: AgentInstanceId,
4764 overlay: PreparedNativeLaunchOverlay,
4765 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4766 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4767 failure(
4768 NodeFailureCode::BackendOperationFailed,
4769 "native launch profile controller is unavailable",
4770 )
4771 })?;
4772 match overlay {
4773 PreparedNativeLaunchOverlay::Environment(overlay) => {
4774 control
4775 .install_native_launch_environment_overlay(instance_id, overlay)
4776 .map_err(|_| failure(
4777 NodeFailureCode::BackendOperationFailed,
4778 "session environment overlay installation failed",
4779 ))?;
4780 Ok(None)
4781 }
4782 PreparedNativeLaunchOverlay::Instance(overlay) => {
4783 control
4784 .install_native_instance_launch_overlay(instance_id, overlay)
4785 .map_err(|_| failure(
4786 NodeFailureCode::BundleBindingMismatch,
4787 "session bundle launch overlay installation failed",
4788 ))?;
4789 Ok(Some(NativeInstanceOverlayGuard {
4790 control: Some(control),
4791 instance_id,
4792 }))
4793 }
4794 }
4795 }
4796
4797 fn environment_materialization_profile(
4798 &self,
4799 environment_profile: &ResolvedEnvironmentProfileReceipt,
4800 ) -> Option<NodeSessionMaterializationProfile> {
4801 self.environment_materialization_profiles
4802 .read()
4803 .unwrap_or_else(|poisoned| poisoned.into_inner())
4804 .get(&environment_profile.profile_id)
4805 .cloned()
4806 }
4807
4808 fn materialization_profile(
4809 &self,
4810 provider: &AgentId,
4811 mode: SessionMode,
4812 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4813 bundle: Option<&ResolvedBundleReceipt>,
4814 context: Option<&ResolvedContextPackReceipt>,
4815 ) -> Result<Option<(
4816 NodeSessionMaterializationProfile,
4817 bool,
4818 Option<BundleProviderLayout>,
4819 )>, NodeFailure> {
4820 let environment = environment_profile
4821 .and_then(|receipt| self.environment_materialization_profile(receipt));
4822 let (bundle, bundle_layout) = match bundle {
4823 Some(receipt) => {
4824 let catalog = self.bundle_catalog.read()
4825 .unwrap_or_else(|poisoned| poisoned.into_inner());
4826 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4827 NodeFailureCode::UnknownBundle,
4828 "session bundle is unavailable on this node",
4829 ))?;
4830 if installed.receipt() != *receipt {
4831 return Err(failure(
4832 NodeFailureCode::BundleBindingMismatch,
4833 "session bundle revision or digest changed",
4834 ));
4835 }
4836 let layout = self.bundle_layout(
4837 provider,
4838 mode,
4839 environment_profile,
4840 installed,
4841 )?;
4842 (Some(installed.clone()), Some(layout))
4843 }
4844 None => (None, None),
4845 };
4846 let has_environment_overlay = environment.is_some() || context.is_some();
4847 let profile = match (environment, bundle.as_ref(), bundle_layout) {
4848 (None, None, None) => None,
4849 (Some(profile), None, None) => Some(Ok(profile)),
4850 (None, Some(bundle), Some(layout)) => {
4851 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4852 }
4853 (Some(profile), Some(bundle), Some(layout)) => {
4854 Some(profile.with_bundle(bundle, layout))
4855 }
4856 _ => {
4857 return Err(failure(
4858 NodeFailureCode::BundleBindingMismatch,
4859 "session bundle provider layout is unavailable",
4860 ));
4861 }
4862 };
4863 let profile = profile
4864 .transpose()
4865 .map_err(|_| failure(
4866 NodeFailureCode::BundleMaterializationFailed,
4867 "session bundle materialization profile is invalid",
4868 ))?;
4869 let profile = match (profile, context) {
4870 (Some(profile), Some(receipt)) => {
4871 let catalog = self.context_catalog
4872 .read()
4873 .unwrap_or_else(|poisoned| poisoned.into_inner());
4874 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4875 NodeFailureCode::UnknownContextPack,
4876 "session context pack is unavailable on this node",
4877 ))?;
4878 if pack.receipt() != receipt {
4879 return Err(failure(
4880 NodeFailureCode::ContextPackMaterializationFailed,
4881 "session context pack identity changed",
4882 ));
4883 }
4884 profile.with_context(pack)
4885 }
4886 (None, Some(receipt)) => {
4887 let catalog = self.context_catalog
4888 .read()
4889 .unwrap_or_else(|poisoned| poisoned.into_inner());
4890 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4891 NodeFailureCode::UnknownContextPack,
4892 "session context pack is unavailable on this node",
4893 ))?;
4894 if pack.receipt() != receipt {
4895 return Err(failure(
4896 NodeFailureCode::ContextPackMaterializationFailed,
4897 "session context pack identity changed",
4898 ));
4899 }
4900 NodeSessionMaterializationProfile::from_context(pack)
4901 }
4902 (Some(profile), None) => Ok(profile),
4903 (None, None) => return Ok(None),
4904 }
4905 .map_err(|_| failure(
4906 NodeFailureCode::ContextPackMaterializationFailed,
4907 "session context pack materialization profile is invalid",
4908 ))?;
4909 Ok(Some((profile, has_environment_overlay, bundle_layout)))
4910 }
4911
4912 fn prepare_session_materialization<'a>(
4913 &'a self,
4914 address: &SessionAddress,
4915 provider: &AgentId,
4916 mode: SessionMode,
4917 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4918 bundle: Option<&ResolvedBundleReceipt>,
4919 context: Option<&ResolvedContextPackReceipt>,
4920 managed_lease_id: Option<ManagedWorktreeLeaseId>,
4921 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
4922 let Some((profile, has_environment_overlay, bundle_layout)) =
4923 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
4924 else {
4925 return Ok(None);
4926 };
4927 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4928 failure(
4929 NodeFailureCode::BackendOperationFailed,
4930 "session environment materializer is unavailable",
4931 )
4932 })?;
4933 let materialization_failure_code = if context.is_some() {
4934 NodeFailureCode::ContextPackMaterializationFailed
4935 } else if bundle.is_some() {
4936 NodeFailureCode::BundleMaterializationFailed
4937 } else {
4938 NodeFailureCode::BackendOperationFailed
4939 };
4940 let id = MaterializationId::new(format!(
4941 "mat-{}-{}-{}",
4942 self.incarnation_id,
4943 address.session.instance_id.0,
4944 address.session.generation.0,
4945 ))
4946 .map_err(|_| {
4947 failure(
4948 NodeFailureCode::BackendOperationFailed,
4949 "session environment materialization identity failed",
4950 )
4951 })?;
4952 let owner = MaterializationOwner::Session {
4953 incarnation_id: self.incarnation_id,
4954 instance_id: address.session.instance_id,
4955 generation: address.session.generation,
4956 };
4957 let mut ownership = materializer
4958 .begin(
4959 id.clone(),
4960 environment_profile.cloned(),
4961 bundle.cloned(),
4962 context.cloned(),
4963 owner,
4964 managed_lease_id,
4965 &profile,
4966 unix_time_ms(),
4967 )
4968 .map_err(|_| {
4969 failure(
4970 materialization_failure_code,
4971 "session materialization preparation failed",
4972 )
4973 })?;
4974 {
4975 let _transaction = self
4976 .state_transaction
4977 .lock()
4978 .unwrap_or_else(|poisoned| poisoned.into_inner());
4979 let duplicate = self
4980 .materializations
4981 .lock()
4982 .unwrap_or_else(|poisoned| poisoned.into_inner())
4983 .insert(id.clone(), ownership.clone());
4984 if duplicate.is_some() {
4985 return Err(failure(
4986 NodeFailureCode::BackendOperationFailed,
4987 "session environment materialization identity conflict",
4988 ));
4989 }
4990 if let Err(error) = self.persist_state_locked() {
4991 self.materializations
4992 .lock()
4993 .unwrap_or_else(|poisoned| poisoned.into_inner())
4994 .remove(&id);
4995 return Err(persistence_failure(error));
4996 }
4997 }
4998 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
4999 Ok(environment) => environment,
5000 Err(_) => {
5001 let transaction = self
5002 .state_transaction
5003 .lock()
5004 .unwrap_or_else(|poisoned| poisoned.into_inner());
5005 self.materializations
5006 .lock()
5007 .unwrap_or_else(|poisoned| poisoned.into_inner())
5008 .insert(id.clone(), ownership.clone());
5009 if self.persist_state_locked().is_err() {
5010 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5011 }
5012 drop(transaction);
5013 if ownership.state() == MaterializationState::CleanupRequired {
5014 let _ = self.cleanup_materialization(&id);
5015 }
5016 return Err(failure(
5017 materialization_failure_code,
5018 "session materialization failed",
5019 ));
5020 }
5021 };
5022 {
5023 let _transaction = self
5024 .state_transaction
5025 .lock()
5026 .unwrap_or_else(|poisoned| poisoned.into_inner());
5027 self.materializations
5028 .lock()
5029 .unwrap_or_else(|poisoned| poisoned.into_inner())
5030 .insert(id.clone(), ownership.clone());
5031 if let Err(error) = self.persist_state_locked() {
5032 drop(_transaction);
5033 let _ = self.cleanup_materialization(&id);
5034 return Err(persistence_failure(error));
5035 }
5036 }
5037 let guard = SessionMaterializationGuard {
5038 shared: self,
5039 id: Some(id),
5040 provider_home: Some(ownership.provider_home().to_path_buf()),
5041 };
5042 let bundle_arguments = match bundle {
5043 Some(receipt) => {
5044 let catalog = self.bundle_catalog.read()
5045 .unwrap_or_else(|poisoned| poisoned.into_inner());
5046 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5047 NodeFailureCode::UnknownBundle,
5048 "session bundle is unavailable on this node",
5049 ))?;
5050 if installed.receipt() != *receipt {
5051 return Err(failure(
5052 NodeFailureCode::BundleBindingMismatch,
5053 "session bundle revision or digest changed",
5054 ));
5055 }
5056 let layout = bundle_layout.ok_or_else(|| failure(
5057 NodeFailureCode::BundleBindingMismatch,
5058 "materialized bundle provider layout is unavailable",
5059 ))?;
5060 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5061 failure(
5062 NodeFailureCode::BundleMaterializationFailed,
5063 "session bundle byte revalidation failed",
5064 )
5065 })?;
5066 Some(bundle_launch_arguments(
5067 layout,
5068 ownership.bundle_root(),
5069 ownership.provider_home(),
5070 ).map_err(|_| failure(
5071 NodeFailureCode::BundleBindingMismatch,
5072 "session bundle launch binding is invalid",
5073 ))?)
5074 }
5075 None => None,
5076 };
5077 if let Some(receipt) = context {
5078 materializer
5079 .revalidate_context(&ownership, receipt)
5080 .map_err(|_| failure(
5081 NodeFailureCode::ContextPackMaterializationFailed,
5082 "session context pack byte revalidation failed",
5083 ))?;
5084 }
5085 let transport = match mode {
5086 SessionMode::Pty => TransportKind::Pty,
5087 SessionMode::Inline => TransportKind::Pipe,
5088 SessionMode::Acp => TransportKind::Acp,
5089 };
5090 let overlay = if let Some(extra_args) = bundle_arguments {
5091 Some(PreparedNativeLaunchOverlay::Instance(
5092 NativeInstanceLaunchOverlay::new(
5093 provider.clone(),
5094 transport,
5095 if has_environment_overlay {
5096 environment
5097 } else {
5098 Vec::new()
5099 },
5100 extra_args,
5101 )
5102 .map_err(|_| {
5103 failure(
5104 NodeFailureCode::BundleBindingMismatch,
5105 "session bundle launch overlay is invalid",
5106 )
5107 })?,
5108 ))
5109 } else if has_environment_overlay {
5110 let context_only = environment_profile.is_none()
5111 && bundle.is_none()
5112 && context.is_some();
5113 let overlay = if context_only {
5114 NativeLaunchEnvironmentOverlay::new_context_root(
5115 provider.clone(),
5116 transport,
5117 environment,
5118 )
5119 .map_err(|_| {
5120 failure(
5121 NodeFailureCode::ContextPackMaterializationFailed,
5122 "context-only launch overlay is invalid",
5123 )
5124 })?
5125 } else {
5126 NativeLaunchEnvironmentOverlay::new(
5127 provider.clone(),
5128 transport,
5129 environment,
5130 )
5131 .map_err(|_| {
5132 failure(
5133 NodeFailureCode::BackendOperationFailed,
5134 "session environment overlay is invalid",
5135 )
5136 })?
5137 };
5138 Some(PreparedNativeLaunchOverlay::Environment(overlay))
5139 } else {
5140 None
5141 };
5142 Ok(Some((overlay, guard)))
5143 }
5144
5145 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5146 let Some(mut ownership) = self
5147 .materializations
5148 .lock()
5149 .unwrap_or_else(|poisoned| poisoned.into_inner())
5150 .get(id)
5151 .cloned()
5152 else {
5153 return Ok(());
5154 };
5155 if ownership.state() != MaterializationState::CleanupRequired {
5156 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5157 failure(
5158 NodeFailureCode::BackendOperationFailed,
5159 "session environment cleanup state is invalid",
5160 )
5161 })?;
5162 }
5163 {
5164 let _transaction = self
5165 .state_transaction
5166 .lock()
5167 .unwrap_or_else(|poisoned| poisoned.into_inner());
5168 let previous = self
5169 .materializations
5170 .lock()
5171 .unwrap_or_else(|poisoned| poisoned.into_inner())
5172 .insert(id.clone(), ownership.clone());
5173 if let Err(error) = self.persist_state_locked() {
5174 let mut materializations = self
5175 .materializations
5176 .lock()
5177 .unwrap_or_else(|poisoned| poisoned.into_inner());
5178 if let Some(previous) = previous {
5179 materializations.insert(id.clone(), previous);
5180 }
5181 return Err(persistence_failure(error));
5182 }
5183 }
5184 let cleanup = self
5185 .session_environment_materializer
5186 .as_ref()
5187 .ok_or(())
5188 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5189 if cleanup.is_err() {
5190 let _ = ownership.mark_recovery_required(unix_time_ms());
5191 self.materializations
5192 .lock()
5193 .unwrap_or_else(|poisoned| poisoned.into_inner())
5194 .insert(id.clone(), ownership);
5195 if self.persist_state().is_err() {
5196 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5197 }
5198 return Err(failure(
5199 NodeFailureCode::BackendOperationFailed,
5200 "session environment cleanup requires recovery",
5201 ));
5202 }
5203 let _transaction = self
5204 .state_transaction
5205 .lock()
5206 .unwrap_or_else(|poisoned| poisoned.into_inner());
5207 self.materializations
5208 .lock()
5209 .unwrap_or_else(|poisoned| poisoned.into_inner())
5210 .remove(id);
5211 if let Err(error) = self.persist_state_locked() {
5212 self.materializations
5213 .lock()
5214 .unwrap_or_else(|poisoned| poisoned.into_inner())
5215 .insert(id.clone(), ownership);
5216 return Err(persistence_failure(error));
5217 }
5218 Ok(())
5219 }
5220
5221 fn resolve_record_materialization(
5222 &self,
5223 record_id: &SessionRecordId,
5224 provider: &AgentId,
5225 mode: SessionMode,
5226 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5227 bundle: Option<&ResolvedBundleReceipt>,
5228 context: Option<&ResolvedContextPackReceipt>,
5229 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5230 let ownership = {
5231 let materializations = self
5232 .materializations
5233 .lock()
5234 .unwrap_or_else(|poisoned| poisoned.into_inner());
5235 let mut matches = materializations.values().filter(|record| {
5236 record.owner()
5237 == &MaterializationOwner::Record {
5238 record_id: record_id.clone(),
5239 }
5240 });
5241 let ownership = matches.next().cloned();
5242 if matches.next().is_some() {
5243 return Err(failure(
5244 NodeFailureCode::BackendOperationFailed,
5245 "managed session has conflicting environment ownership",
5246 ));
5247 }
5248 ownership
5249 };
5250 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5251 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5252 failure(
5253 NodeFailureCode::ContextPackMaterializationFailed,
5254 "session context materializer is unavailable",
5255 )
5256 })?;
5257 let pack = materializer
5258 .revalidate_context(ownership, receipt)
5259 .map_err(|_| {
5260 self.mark_materialization_recovery_required(ownership.id());
5261 failure(
5262 NodeFailureCode::ContextPackMaterializationFailed,
5263 "managed session context pack revalidation failed",
5264 )
5265 })?;
5266 self.context_catalog
5267 .write()
5268 .unwrap_or_else(|poisoned| poisoned.into_inner())
5269 .insert(pack)
5270 .map_err(|_| failure(
5271 NodeFailureCode::ContextPackMaterializationFailed,
5272 "managed session context pack catalog restore failed",
5273 ))?;
5274 }
5275 let materialization = self.materialization_profile(
5276 provider,
5277 mode,
5278 environment_profile,
5279 bundle,
5280 context,
5281 )?;
5282 match (ownership, materialization) {
5283 (None, None) => Ok(None),
5284 (None, Some(_)) | (Some(_), None) => Err(failure(
5285 NodeFailureCode::BackendOperationFailed,
5286 "managed session materialization is unavailable",
5287 )),
5288 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5289 if ownership.environment_profile() != environment_profile
5290 || ownership.bundle() != bundle
5291 || ownership.context() != context
5292 || ownership.state() != MaterializationState::Ready
5293 {
5294 self.mark_materialization_recovery_required(ownership.id());
5295 return Err(failure(
5296 NodeFailureCode::BackendOperationFailed,
5297 "managed session environment ownership requires recovery",
5298 ));
5299 }
5300 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5301 failure(
5302 NodeFailureCode::BackendOperationFailed,
5303 "session environment materializer is unavailable",
5304 )
5305 })?;
5306 if materializer.revalidate(&ownership).is_err() {
5307 self.mark_materialization_recovery_required(ownership.id());
5308 return Err(failure(
5309 NodeFailureCode::BackendOperationFailed,
5310 "managed session environment revalidation failed",
5311 ));
5312 }
5313 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5314 let catalog = self.bundle_catalog.read()
5315 .unwrap_or_else(|poisoned| poisoned.into_inner());
5316 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5317 return Err(failure(
5318 NodeFailureCode::UnknownBundle,
5319 "managed session bundle is unavailable",
5320 ));
5321 };
5322 let layout = bundle_layout.ok_or_else(|| failure(
5323 NodeFailureCode::BundleBindingMismatch,
5324 "record bundle provider layout is unavailable",
5325 ))?;
5326 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5327 drop(catalog);
5328 self.mark_materialization_recovery_required(ownership.id());
5329 return Err(failure(
5330 NodeFailureCode::BundleMaterializationFailed,
5331 "managed session bundle revalidation failed",
5332 ));
5333 }
5334 Some(bundle_launch_arguments(
5335 layout,
5336 ownership.bundle_root(),
5337 ownership.provider_home(),
5338 ).map_err(|_| failure(
5339 NodeFailureCode::BundleBindingMismatch,
5340 "managed session bundle launch binding is invalid",
5341 ))?)
5342 } else {
5343 None
5344 };
5345 if !has_environment_materialization {
5346 let overlay = bundle_arguments.map(|extra_args| {
5347 NativeInstanceLaunchOverlay::new(
5348 provider.clone(),
5349 TransportKind::Pty,
5350 Vec::new(),
5351 extra_args,
5352 )
5353 .map(PreparedNativeLaunchOverlay::Instance)
5354 .map_err(|_| failure(
5355 NodeFailureCode::BundleBindingMismatch,
5356 "managed session bundle launch overlay is invalid",
5357 ))
5358 }).transpose()?;
5359 return Ok(Some((ownership.id().clone(), overlay)));
5360 }
5361 let environment = match materializer.resolve_environment(&ownership, &profile) {
5362 Ok(environment) => environment,
5363 Err(
5364 SessionEnvironmentMaterializeError::SecretUnavailable
5365 | SessionEnvironmentMaterializeError::SecretDenied
5366 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5367 ) => {
5368 return Err(failure(
5369 NodeFailureCode::BackendOperationFailed,
5370 "managed session environment resolution failed",
5371 ));
5372 }
5373 Err(
5374 SessionEnvironmentMaterializeError::InvalidRoot
5375 | SessionEnvironmentMaterializeError::OwnershipMismatch
5376 | SessionEnvironmentMaterializeError::Filesystem(_)
5377 | SessionEnvironmentMaterializeError::Record(_),
5378 ) => {
5379 self.mark_materialization_recovery_required(ownership.id());
5380 return Err(failure(
5381 NodeFailureCode::BackendOperationFailed,
5382 "managed session environment resolution failed",
5383 ));
5384 }
5385 };
5386 let transport = match mode {
5387 SessionMode::Pty => TransportKind::Pty,
5388 SessionMode::Inline => TransportKind::Pipe,
5389 SessionMode::Acp => TransportKind::Acp,
5390 };
5391 let overlay = match bundle_arguments {
5392 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5393 NativeInstanceLaunchOverlay::new(
5394 provider.clone(),
5395 transport,
5396 environment,
5397 extra_args,
5398 )
5399 .map_err(|_| failure(
5400 NodeFailureCode::BundleBindingMismatch,
5401 "managed session bundle launch overlay is invalid",
5402 ))?,
5403 ),
5404 None => PreparedNativeLaunchOverlay::Environment(
5405 NativeLaunchEnvironmentOverlay::new(
5406 provider.clone(),
5407 transport,
5408 environment,
5409 )
5410 .map_err(|_| {
5411 failure(
5412 NodeFailureCode::BackendOperationFailed,
5413 "managed session environment overlay is invalid",
5414 )
5415 })?,
5416 ),
5417 };
5418 Ok(Some((ownership.id().clone(), Some(overlay))))
5419 }
5420 }
5421 }
5422
5423 fn destroy_record_materialization(
5424 &self,
5425 record_id: &SessionRecordId,
5426 ) -> Result<bool, NodeFailure> {
5427 let ids = self
5428 .materializations
5429 .lock()
5430 .unwrap_or_else(|poisoned| poisoned.into_inner())
5431 .values()
5432 .filter_map(|record| {
5433 (record.owner()
5434 == &MaterializationOwner::Record {
5435 record_id: record_id.clone(),
5436 })
5437 .then(|| record.id().clone())
5438 })
5439 .collect::<Vec<_>>();
5440 if ids.len() > 1 {
5441 return Err(failure(
5442 NodeFailureCode::BackendOperationFailed,
5443 "managed session has conflicting environment ownership",
5444 ));
5445 }
5446 let Some(id) = ids.first().cloned() else {
5447 return Ok(false);
5448 };
5449 let (terminal_record, mut cleanup_ownership) = {
5450 let _transaction = self
5451 .state_transaction
5452 .lock()
5453 .unwrap_or_else(|poisoned| poisoned.into_inner());
5454 let original_record = self.record(record_id)?;
5455 let mut terminal_record = original_record.clone();
5456 terminal_record.provider_session = None;
5457 terminal_record.active_session = None;
5458 terminal_record.state = ManagedSessionState::Unavailable;
5459 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5460 terminal_record.updated_at_unix_ms = unix_time_ms();
5461 let original_ownership = self
5462 .materializations
5463 .lock()
5464 .unwrap_or_else(|poisoned| poisoned.into_inner())
5465 .get(&id)
5466 .cloned()
5467 .ok_or_else(|| {
5468 failure(
5469 NodeFailureCode::BackendOperationFailed,
5470 "managed session environment ownership disappeared during cleanup",
5471 )
5472 })?;
5473 let mut cleanup_ownership = original_ownership.clone();
5474 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5475 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5476 failure(
5477 NodeFailureCode::BackendOperationFailed,
5478 "managed session environment cleanup state is invalid",
5479 )
5480 })?;
5481 }
5482 self.session_records
5483 .lock()
5484 .unwrap_or_else(|poisoned| poisoned.into_inner())
5485 .records
5486 .insert(record_id.clone(), terminal_record.clone());
5487 self.materializations
5488 .lock()
5489 .unwrap_or_else(|poisoned| poisoned.into_inner())
5490 .insert(id.clone(), cleanup_ownership.clone());
5491 if let Err(error) = self.persist_state_locked() {
5492 self.session_records
5493 .lock()
5494 .unwrap_or_else(|poisoned| poisoned.into_inner())
5495 .records
5496 .insert(record_id.clone(), original_record);
5497 self.materializations
5498 .lock()
5499 .unwrap_or_else(|poisoned| poisoned.into_inner())
5500 .insert(id.clone(), original_ownership);
5501 return Err(persistence_failure(error));
5502 }
5503 (terminal_record, cleanup_ownership)
5504 };
5505 let cleanup = self
5506 .session_environment_materializer
5507 .as_ref()
5508 .ok_or(())
5509 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5510 if cleanup.is_err() {
5511 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5512 self.materializations
5513 .lock()
5514 .unwrap_or_else(|poisoned| poisoned.into_inner())
5515 .insert(id.clone(), cleanup_ownership);
5516 if self.persist_state().is_err() {
5517 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5518 }
5519 return Err(failure(
5520 NodeFailureCode::BackendOperationFailed,
5521 "managed session environment cleanup requires recovery",
5522 ));
5523 }
5524 let _transaction = self
5525 .state_transaction
5526 .lock()
5527 .unwrap_or_else(|poisoned| poisoned.into_inner());
5528 self.remove_record_memory(record_id);
5529 self.materializations
5530 .lock()
5531 .unwrap_or_else(|poisoned| poisoned.into_inner())
5532 .remove(&id);
5533 if let Err(error) = self.persist_state_locked() {
5534 self.session_records
5535 .lock()
5536 .unwrap_or_else(|poisoned| poisoned.into_inner())
5537 .records
5538 .insert(record_id.clone(), terminal_record);
5539 self.materializations
5540 .lock()
5541 .unwrap_or_else(|poisoned| poisoned.into_inner())
5542 .insert(id, cleanup_ownership);
5543 return Err(persistence_failure(error));
5544 }
5545 Ok(true)
5546 }
5547
5548 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5549 let changed = {
5550 let mut materializations = self
5551 .materializations
5552 .lock()
5553 .unwrap_or_else(|poisoned| poisoned.into_inner());
5554 let Some(ownership) = materializations.get_mut(id) else {
5555 return;
5556 };
5557 ownership.mark_recovery_required(unix_time_ms()).is_ok()
5558 };
5559 if changed && self.persist_state().is_err() {
5560 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5561 }
5562 }
5563
5564 fn reconcile_materializations(&self) {
5565 let records = self
5566 .materializations
5567 .lock()
5568 .unwrap_or_else(|poisoned| poisoned.into_inner())
5569 .values()
5570 .cloned()
5571 .collect::<Vec<_>>();
5572 for mut ownership in records {
5573 let result = match (ownership.state(), ownership.owner()) {
5574 (MaterializationState::Preparing, _)
5575 | (MaterializationState::CleanupRequired, _) => {
5576 self.cleanup_materialization(ownership.id())
5577 }
5578 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5579 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5580 self.materializations
5581 .lock()
5582 .unwrap_or_else(|poisoned| poisoned.into_inner())
5583 .insert(ownership.id().clone(), ownership.clone());
5584 self.persist_state().map_err(persistence_failure)
5585 } else {
5586 Err(failure(
5587 NodeFailureCode::BackendOperationFailed,
5588 "session environment reconciliation failed",
5589 ))
5590 }
5591 }
5592 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5593 .session_environment_materializer
5594 .as_ref()
5595 .ok_or_else(|| {
5596 failure(
5597 NodeFailureCode::BackendOperationFailed,
5598 "session environment materializer is unavailable",
5599 )
5600 })
5601 .and_then(|materializer| {
5602 materializer.revalidate(&ownership).map_err(|_| failure(
5603 NodeFailureCode::BackendOperationFailed,
5604 "session materialization revalidation failed",
5605 ))?;
5606 let record = self.record(record_id)?;
5607 if record.environment_profile.as_ref() != ownership.environment_profile()
5608 || record.bundle.as_ref() != ownership.bundle()
5609 || record.context.as_ref() != ownership.context()
5610 {
5611 return Err(failure(
5612 NodeFailureCode::BackendOperationFailed,
5613 "session materialization ownership changed after restart",
5614 ));
5615 }
5616 if let Some(receipt) = ownership.bundle() {
5617 let catalog = self.bundle_catalog.read()
5618 .unwrap_or_else(|poisoned| poisoned.into_inner());
5619 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5620 NodeFailureCode::UnknownBundle,
5621 "session bundle is unavailable after restart",
5622 ))?;
5623 if bundle.receipt() != *receipt {
5624 return Err(failure(
5625 NodeFailureCode::BundleBindingMismatch,
5626 "session bundle changed after restart",
5627 ));
5628 }
5629 let layout = self.bundle_layout(
5630 &record.provider,
5631 record.mode,
5632 record.environment_profile.as_ref(),
5633 bundle,
5634 )?;
5635 materializer
5636 .revalidate_bundle(&ownership, bundle, layout)
5637 .map_err(|_| failure(
5638 NodeFailureCode::BundleMaterializationFailed,
5639 "session bundle revalidation failed",
5640 ))?;
5641 }
5642 if let Some(receipt) = ownership.context() {
5643 let pack = materializer
5644 .revalidate_context(&ownership, receipt)
5645 .map_err(|_| {
5646 failure(
5647 NodeFailureCode::ContextPackMaterializationFailed,
5648 "session context revalidation failed",
5649 )
5650 })?;
5651 self.context_catalog
5652 .write()
5653 .unwrap_or_else(|poisoned| poisoned.into_inner())
5654 .insert(pack)
5655 .map_err(|_| {
5656 failure(
5657 NodeFailureCode::ContextPackMaterializationFailed,
5658 "session context catalog restore failed",
5659 )
5660 })?;
5661 }
5662 Ok(())
5663 }),
5664 (MaterializationState::RecoveryRequired, _) => Ok(()),
5665 };
5666 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5667 let _ = ownership.mark_recovery_required(unix_time_ms());
5668 self.materializations
5669 .lock()
5670 .unwrap_or_else(|poisoned| poisoned.into_inner())
5671 .insert(ownership.id().clone(), ownership);
5672 if self.persist_state().is_err() {
5673 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5674 }
5675 }
5676 }
5677 let materialized_profiles = self
5678 .environment_materialization_profiles
5679 .read()
5680 .unwrap_or_else(|poisoned| poisoned.into_inner())
5681 .keys()
5682 .cloned()
5683 .collect::<Vec<_>>();
5684 let ownership = self
5685 .materializations
5686 .lock()
5687 .unwrap_or_else(|poisoned| poisoned.into_inner())
5688 .values()
5689 .cloned()
5690 .collect::<Vec<_>>();
5691 let changed = {
5692 let mut session_records = self
5693 .session_records
5694 .lock()
5695 .unwrap_or_else(|poisoned| poisoned.into_inner());
5696 let mut changed = false;
5697 for record in session_records.records.values_mut() {
5698 let environment_profile = record.environment_profile.as_ref();
5699 if environment_profile.is_none()
5700 && record.bundle.is_none()
5701 && record.context.is_none()
5702 {
5703 continue;
5704 }
5705 if record.bundle.is_none()
5706 && environment_profile.is_some_and(|profile| {
5707 !materialized_profiles.contains(&profile.profile_id)
5708 })
5709 {
5710 continue;
5711 }
5712 let has_exact_ownership = ownership.iter().any(|candidate| {
5713 candidate.state() == MaterializationState::Ready
5714 && candidate.environment_profile() == environment_profile
5715 && candidate.bundle() == record.bundle.as_ref()
5716 && candidate.context() == record.context.as_ref()
5717 && candidate.owner()
5718 == &MaterializationOwner::Record {
5719 record_id: record.record_id.clone(),
5720 }
5721 });
5722 if !has_exact_ownership {
5723 record.provider_session = None;
5724 record.active_session = None;
5725 record.state = ManagedSessionState::Unavailable;
5726 record.last_error = Some(if record.bundle.is_some() {
5727 "bundle-unavailable".to_owned()
5728 } else if record.context.is_some() {
5729 "context-unavailable".to_owned()
5730 } else {
5731 "environment-profile-unavailable".to_owned()
5732 });
5733 record.updated_at_unix_ms = unix_time_ms();
5734 changed = true;
5735 }
5736 }
5737 changed
5738 };
5739 if changed && self.persist_state().is_err() {
5740 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5741 }
5742 }
5743
5744 fn replay_spawn_spec(
5745 &self,
5746 spec: &SpawnSpec,
5747 now: Instant,
5748 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5749 let mut cache = self
5750 .spawn_idempotency
5751 .lock()
5752 .unwrap_or_else(|poisoned| poisoned.into_inner());
5753 cache.entries.retain(|_, entry| entry.expires_at > now);
5754 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5755 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5756 return Err(failure(
5757 NodeFailureCode::SpawnIdempotencyConflict,
5758 "spawn idempotency key was reused across spawn kinds",
5759 ));
5760 };
5761 if existing != spec {
5762 return Err(failure(
5763 NodeFailureCode::SpawnIdempotencyConflict,
5764 "spawn idempotency key was reused with a different specification",
5765 ));
5766 }
5767 return Ok(Some(result.clone()));
5768 }
5769 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5770 return Err(failure(
5771 NodeFailureCode::SpawnIdempotencyCapacity,
5772 "spawn idempotency cache reached its bounded capacity",
5773 ));
5774 }
5775 Ok(None)
5776 }
5777
5778 fn remember_spawn_spec(
5779 &self,
5780 spec: SpawnSpec,
5781 result: Result<ResolvedSpawnReceipt, NodeFailure>,
5782 accepted_at: Instant,
5783 ) {
5784 self
5785 .spawn_idempotency
5786 .lock()
5787 .unwrap_or_else(|poisoned| poisoned.into_inner())
5788 .entries
5789 .insert(
5790 spec.idempotency_key.clone(),
5791 SpawnIdempotencyEntry {
5792 value: SpawnIdempotencyValue::Standard { spec, result },
5793 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5794 },
5795 );
5796 }
5797
5798 fn claim_harness_mcp_spawn(
5799 &self,
5800 _reservation_id: &HarnessMcpReservationId,
5801 _activation_digest: &HarnessMcpActivationDigest,
5802 spec: &SpawnSpec,
5803 now: Instant,
5804 ) -> Result<(), NodeFailure> {
5805 let mut cache = self.spawn_idempotency.lock()
5806 .unwrap_or_else(|poisoned| poisoned.into_inner());
5807 cache.entries.retain(|_, entry| entry.expires_at > now);
5808 if cache.entries.contains_key(&spec.idempotency_key) {
5809 return Err(failure(
5810 NodeFailureCode::SpawnIdempotencyConflict,
5811 "spawn idempotency key was reused across spawn kinds",
5812 ));
5813 }
5814 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5815 return Err(failure(
5816 NodeFailureCode::SpawnIdempotencyCapacity,
5817 "spawn idempotency cache reached its bounded capacity",
5818 ));
5819 }
5820 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5821 value: SpawnIdempotencyValue::HarnessMcp,
5822 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5823 });
5824 Ok(())
5825 }
5826
5827 fn replay_managed_spawn(
5828 &self,
5829 request: &ManagedWorktreeSpawnRequest,
5830 now: Instant,
5831 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5832 let mut cache = self.spawn_idempotency.lock()
5833 .unwrap_or_else(|poisoned| poisoned.into_inner());
5834 cache.entries.retain(|_, entry| entry.expires_at > now);
5835 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5836 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5837 return Err(failure(
5838 NodeFailureCode::SpawnIdempotencyConflict,
5839 "spawn idempotency key was reused across spawn kinds",
5840 ));
5841 };
5842 if existing != request {
5843 return Err(failure(
5844 NodeFailureCode::SpawnIdempotencyConflict,
5845 "spawn idempotency key was reused with a different managed specification",
5846 ));
5847 }
5848 return Ok(Some(result.clone()));
5849 }
5850 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5851 return Err(failure(
5852 NodeFailureCode::SpawnIdempotencyCapacity,
5853 "spawn idempotency cache reached its bounded capacity",
5854 ));
5855 }
5856 Ok(None)
5857 }
5858
5859 fn remember_managed_spawn(
5860 &self,
5861 request: ManagedWorktreeSpawnRequest,
5862 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5863 accepted_at: Instant,
5864 ) {
5865 self.spawn_idempotency.lock()
5866 .unwrap_or_else(|poisoned| poisoned.into_inner())
5867 .entries
5868 .insert(
5869 request.spawn_spec.idempotency_key.clone(),
5870 SpawnIdempotencyEntry {
5871 value: SpawnIdempotencyValue::Managed { request, result },
5872 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5873 },
5874 );
5875 }
5876
5877 fn remember_managed_spawn_attempt(
5878 &self,
5879 durable_v2_key: Option<&SpawnIdempotencyKey>,
5880 request: ManagedWorktreeSpawnRequest,
5881 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5882 accepted_at: Instant,
5883 ) {
5884 if durable_v2_key.is_none() {
5885 self.remember_managed_spawn(request, result, accepted_at);
5886 }
5887 }
5888
5889 fn acquire_durable_managed_spawn_v2(
5890 &self,
5891 request: &ManagedWorktreeSpawnRequestV2,
5892 request_digest: String,
5893 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5894 if self.state_path.is_none() {
5895 return Err(failure(
5896 NodeFailureCode::BackendOperationFailed,
5897 "managed worktree V2 requires durable node state",
5898 ));
5899 }
5900 let _transaction = self.state_transaction
5901 .lock()
5902 .unwrap_or_else(|poisoned| poisoned.into_inner());
5903 let key = request.spawn_spec.idempotency_key.clone();
5904 let mut previous = None;
5905 {
5906 let mut replays = self.managed_spawn_replays
5907 .lock()
5908 .unwrap_or_else(|poisoned| poisoned.into_inner());
5909 if let Some(record) = replays.get_mut(&key) {
5910 if record.request_digest != request_digest {
5911 return Err(failure(
5912 NodeFailureCode::SpawnIdempotencyConflict,
5913 "managed worktree V2 idempotency key was reused with a different request",
5914 ));
5915 }
5916 match &record.state {
5917 ManagedWorktreeSpawnReplayStateV10::Pending {
5918 owner_incarnation_id,
5919 lease_id: None,
5920 } if owner_incarnation_id != &self.incarnation_id => {
5921 previous = Some(record.clone());
5922 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
5923 owner_incarnation_id: self.incarnation_id,
5924 lease_id: None,
5925 };
5926 record.updated_at_unix_ms = unix_time_ms();
5927 }
5928 ManagedWorktreeSpawnReplayStateV10::Pending {
5929 lease_id: None,
5930 ..
5931 } => {
5932 return Err(failure(
5933 NodeFailureCode::BackendBusy,
5934 "managed worktree V2 reservation is already active",
5935 ));
5936 }
5937 ManagedWorktreeSpawnReplayStateV10::Pending {
5938 lease_id: Some(_),
5939 ..
5940 } => {
5941 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
5942 }
5943 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
5944 return Ok(DurableManagedSpawnReplayDecision::Committed(
5945 receipt.clone(),
5946 ));
5947 }
5948 }
5949 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
5950 return Err(failure(
5951 NodeFailureCode::SpawnIdempotencyCapacity,
5952 "durable managed worktree replay ledger reached its bounded capacity",
5953 ));
5954 } else {
5955 let now = unix_time_ms();
5956 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
5957 idempotency_key: key.clone(),
5958 request_digest,
5959 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
5960 profile_id: request.worktree_profile_id.clone(),
5961 expected_profile_revision: request.expected_profile_revision.clone(),
5962 state: ManagedWorktreeSpawnReplayStateV10::Pending {
5963 owner_incarnation_id: self.incarnation_id,
5964 lease_id: None,
5965 },
5966 created_at_unix_ms: now,
5967 updated_at_unix_ms: now,
5968 });
5969 }
5970 }
5971 if let Err(error) = self.persist_state_locked() {
5972 let mut replays = self.managed_spawn_replays
5973 .lock()
5974 .unwrap_or_else(|poisoned| poisoned.into_inner());
5975 match previous {
5976 Some(previous) => {
5977 replays.insert(key, previous);
5978 }
5979 None => {
5980 replays.remove(&key);
5981 }
5982 }
5983 return Err(persistence_failure(error));
5984 }
5985 Ok(DurableManagedSpawnReplayDecision::Reserved)
5986 }
5987
5988 fn link_durable_managed_spawn_v2_lease_locked(
5989 &self,
5990 key: &SpawnIdempotencyKey,
5991 lease_id: &ManagedWorktreeLeaseId,
5992 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
5993 let mut replays = self.managed_spawn_replays
5994 .lock()
5995 .unwrap_or_else(|poisoned| poisoned.into_inner());
5996 let record = replays.get_mut(key).ok_or_else(|| {
5997 failure(
5998 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5999 "managed worktree V2 reservation disappeared before allocation",
6000 )
6001 })?;
6002 let previous = record.clone();
6003 match &mut record.state {
6004 ManagedWorktreeSpawnReplayStateV10::Pending {
6005 lease_id: current,
6006 ..
6007 }
6008 if current.is_none() =>
6009 {
6010 *current = Some(lease_id.clone());
6011 record.updated_at_unix_ms = unix_time_ms();
6012 Ok(previous)
6013 }
6014 _ => Err(failure(
6015 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6016 "managed worktree V2 reservation is not allocation-ready",
6017 )),
6018 }
6019 }
6020
6021 fn restore_durable_managed_spawn_v2_locked(
6022 &self,
6023 key: &SpawnIdempotencyKey,
6024 previous: ManagedWorktreeSpawnReplayRecordV10,
6025 ) {
6026 self.managed_spawn_replays
6027 .lock()
6028 .unwrap_or_else(|poisoned| poisoned.into_inner())
6029 .insert(key.clone(), previous);
6030 }
6031
6032 fn commit_durable_managed_spawn_v2(
6033 &self,
6034 request: &ManagedWorktreeSpawnRequestV2,
6035 receipt: ManagedWorktreeSpawnReceipt,
6036 ) -> Result<(), NodeFailure> {
6037 let _transaction = self.state_transaction
6038 .lock()
6039 .unwrap_or_else(|poisoned| poisoned.into_inner());
6040 let key = &request.spawn_spec.idempotency_key;
6041 let previous = {
6042 let mut replays = self.managed_spawn_replays
6043 .lock()
6044 .unwrap_or_else(|poisoned| poisoned.into_inner());
6045 let record = replays.get_mut(key).ok_or_else(|| {
6046 failure(
6047 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6048 "managed worktree V2 reservation disappeared before commit",
6049 )
6050 })?;
6051 let previous = record.clone();
6052 match &record.state {
6053 ManagedWorktreeSpawnReplayStateV10::Pending {
6054 lease_id: Some(lease_id),
6055 ..
6056 } if lease_id == &receipt.lease.lease_id => {}
6057 _ => {
6058 return Err(failure(
6059 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6060 "managed worktree V2 reservation is not linked to the spawned lease",
6061 ));
6062 }
6063 }
6064 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6065 record.updated_at_unix_ms = unix_time_ms();
6066 previous
6067 };
6068 if let Err(error) = self.persist_state_locked() {
6069 self.restore_durable_managed_spawn_v2_locked(key, previous);
6070 return Err(persistence_failure(error));
6071 }
6072 Ok(())
6073 }
6074
6075 fn remove_unallocated_durable_managed_spawn_v2(
6076 &self,
6077 request: &ManagedWorktreeSpawnRequestV2,
6078 ) -> Result<(), NodeFailure> {
6079 let _transaction = self.state_transaction
6080 .lock()
6081 .unwrap_or_else(|poisoned| poisoned.into_inner());
6082 let key = &request.spawn_spec.idempotency_key;
6083 let removed = {
6084 let mut replays = self.managed_spawn_replays
6085 .lock()
6086 .unwrap_or_else(|poisoned| poisoned.into_inner());
6087 match replays.get(key).map(|record| &record.state) {
6088 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6089 lease_id: None,
6090 ..
6091 }) => {
6092 replays.remove(key)
6093 }
6094 _ => None,
6095 }
6096 };
6097 let Some(removed) = removed else {
6098 return Ok(());
6099 };
6100 if let Err(error) = self.persist_state_locked() {
6101 self.managed_spawn_replays
6102 .lock()
6103 .unwrap_or_else(|poisoned| poisoned.into_inner())
6104 .insert(key.clone(), removed);
6105 return Err(persistence_failure(error));
6106 }
6107 Ok(())
6108 }
6109
6110 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6111 if spec.target.node_id != self.node_id {
6112 return Err(failure(
6113 NodeFailureCode::SpawnTargetMismatch,
6114 "spawn target does not match this node",
6115 ));
6116 }
6117 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6118 failure(
6119 NodeFailureCode::UnknownSpawnProfile,
6120 "spawn profile is unavailable on this node",
6121 )
6122 })?;
6123 let resolved = spec.resolve(defaults).map_err(|error| match error {
6124 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6125 NodeFailureCode::SpawnProfileRevisionMismatch,
6126 "spawn profile revision does not match the loaded profile",
6127 ),
6128 _ => failure(
6129 NodeFailureCode::InvalidRequest,
6130 "spawn specification could not be resolved",
6131 ),
6132 })?;
6133 if resolved.target.worktree_id.is_some() {
6134 self.require_worktree_service(&resolved.target.workspace_id)?;
6135 }
6136 let environment_profile = self.resolve_environment_profile(&resolved)?;
6137 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6138 self.resolve_context(&resolved)?;
6139 Ok(resolved)
6140 }
6141
6142 async fn resolve_spawn_workspace(
6143 &self,
6144 resolved: &ResolvedSpawnSpec,
6145 deadline: Instant,
6146 ) -> Result<WorkspaceId, NodeFailure> {
6147 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6148 return Ok(resolved.target.workspace_id.clone());
6149 };
6150 if self.managed_worktrees.lock()
6151 .unwrap_or_else(|poisoned| poisoned.into_inner())
6152 .lease_for_workspace(worktree_id)
6153 .is_some()
6154 {
6155 return Err(failure(
6156 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6157 "managed worktree targets may only be spawned through their lease request",
6158 ));
6159 }
6160 self.require_worktree_service(&resolved.target.workspace_id)?;
6161 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6162 let selected_root = self.workspace_root(worktree_id)?;
6163 let remaining = spawn_deadline_remaining(deadline)?;
6164 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6165 .await
6166 .map_err(|_| {
6167 failure(
6168 NodeFailureCode::SpawnDeadlineExceeded,
6169 "Git worktree selection exceeded the spawn deadline",
6170 )
6171 })?
6172 .map_err(git_worktree_failure)?;
6173 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6174 Ok(worktree_id.clone())
6175 }
6176
6177 async fn spawn_from_spec(
6178 &self,
6179 spec: SpawnSpec,
6180 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6181 self.spawn_from_spec_with_proxy(spec, None).await
6182 }
6183
6184 async fn spawn_from_spec_with_proxy(
6185 &self,
6186 spec: SpawnSpec,
6187 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6188 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6189 let accepted_at = Instant::now();
6190 if harness_mcp.is_none() {
6191 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6192 return replayed;
6193 }
6194 }
6195 let resolved = self.resolve_spawn_spec(&spec)?;
6196 let environment_profile = self.resolve_environment_profile(&resolved)?;
6197 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6198 let context = self.resolve_context(&resolved)?;
6199 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6200 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6201 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6202 let spawn_workspace_id = match spawn_workspace_id {
6203 Ok(workspace_id) => workspace_id,
6204 Err(error) => {
6205 let result = Err(error);
6206 if harness_mcp.is_none() {
6207 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6208 }
6209 return result;
6210 }
6211 };
6212 if self.managed_worktrees.lock()
6213 .unwrap_or_else(|poisoned| poisoned.into_inner())
6214 .lease_for_workspace(&spawn_workspace_id)
6215 .is_some()
6216 {
6217 let result = Err(failure(
6218 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6219 "managed worktree targets may only be spawned through their lease request",
6220 ));
6221 if harness_mcp.is_none() {
6222 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6223 }
6224 return result;
6225 }
6226 let result = self
6227 .spawn_session_with_deadline(
6228 spawn_workspace_id,
6229 resolved.provider.clone(),
6230 resolved.mode,
6231 resolved.terminal_size,
6232 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6233 environment_profile.clone(),
6234 bundle.clone(),
6235 context.clone(),
6236 None,
6237 None,
6238 SpawnRecordPolicy::Always,
6239 Some(deadline),
6240 &required_capabilities,
6241 harness_mcp,
6242 resolved.approval_level,
6243 resolved.network_allowlist.clone(),
6244 resolved.browser_profile_id.clone(),
6245 )
6246 .await
6247 .map(|(session, _runtime_policy)| {
6248 resolved.receipt_with_materialization(
6249 self.incarnation_id,
6250 session,
6251 environment_profile,
6252 bundle,
6253 context,
6254 )
6255 });
6256 if harness_mcp.is_none() {
6257 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6258 }
6259 result
6260 }
6261
6262 async fn spawn_from_spec_with_harness_mcp(
6263 &self,
6264 reservation_id: HarnessMcpReservationId,
6265 activation_digest: HarnessMcpActivationDigest,
6266 spec: SpawnSpec,
6267 deadline_unix_ms: u64,
6268 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6269 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6270 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6271 })?;
6272 if let Some(receipt) = registry
6273 .replay_spawn(&reservation_id, &activation_digest, &spec)
6274 .map_err(harness_mcp_failure)?
6275 {
6276 return Ok(receipt);
6277 }
6278 let resolved = self.resolve_spawn_spec(&spec)?;
6279 let prepared = registry
6280 .prepare_spawn(
6281 &reservation_id,
6282 &activation_digest,
6283 &spec,
6284 deadline_unix_ms,
6285 resolved.provider.clone(),
6286 )
6287 .map_err(harness_mcp_failure)?;
6288 self.claim_harness_mcp_spawn(
6289 &reservation_id,
6290 &activation_digest,
6291 &spec,
6292 Instant::now(),
6293 )?;
6294 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6295 let mut receipt = match spawn {
6296 Ok(receipt) => receipt,
6297 Err(error) => {
6298 let _ = registry.abort(&reservation_id, &activation_digest);
6299 return Err(error);
6300 }
6301 };
6302 let record_id = self
6303 .session_bindings
6304 .lock()
6305 .unwrap_or_else(|poisoned| poisoned.into_inner())
6306 .get(&receipt.session.session.instance_id)
6307 .and_then(|binding| binding.record_id.clone());
6308 let record_id = match record_id {
6309 Some(record_id) => record_id,
6310 None => {
6311 let rollback = self.remove_session(&receipt.session).await;
6312 let _ = registry.abort(&reservation_id, &activation_digest);
6313 if let Err(rollback_error) = rollback {
6314 return Err(rollback_error);
6315 }
6316 return Err(failure(
6317 NodeFailureCode::BindingMismatch,
6318 "harness MCP spawn did not retain its exact managed record",
6319 ));
6320 }
6321 };
6322 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6323 reservation_id: reservation_id.clone(),
6324 activation_digest: activation_digest.clone(),
6325 });
6326 if let Err(error) = registry.mark_spawned(
6327 &prepared,
6328 receipt.session.clone(),
6329 record_id,
6330 receipt.clone(),
6331 ) {
6332 let rollback = self.remove_session(&receipt.session).await;
6333 let _ = registry.abort(&reservation_id, &activation_digest);
6334 if let Err(rollback_error) = rollback {
6335 return Err(rollback_error);
6336 }
6337 return Err(harness_mcp_failure(error));
6338 }
6339 Ok(receipt)
6340 }
6341
6342 async fn spawn_managed_worktree(
6343 &self,
6344 request: ManagedWorktreeSpawnRequest,
6345 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6346 self.spawn_managed_worktree_inner(request, None).await
6347 }
6348
6349 async fn spawn_managed_worktree_inner(
6350 &self,
6351 request: ManagedWorktreeSpawnRequest,
6352 durable_v2_key: Option<&SpawnIdempotencyKey>,
6353 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6354 let accepted_at = Instant::now();
6355 if durable_v2_key.is_none() {
6356 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6357 return replayed;
6358 }
6359 }
6360 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6361 if request.spawn_spec.target.worktree_id.is_some() {
6362 let result = Err(failure(
6363 NodeFailureCode::InvalidRequest,
6364 "managed worktree spawn must not select a caller-provided worktree",
6365 ));
6366 self.remember_managed_spawn_attempt(
6367 durable_v2_key,
6368 request,
6369 result.clone(),
6370 accepted_at,
6371 );
6372 return result;
6373 }
6374 let environment_profile = self.resolve_environment_profile(&resolved)?;
6375 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6376 let context = self.resolve_context(&resolved)?;
6377 let source_workspace_id = resolved.target.workspace_id.clone();
6378 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6379 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6380 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6381 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6382 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6383 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6384 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6385 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6386 };
6387 let runtime_policy = timeout(
6388 spawn_deadline_remaining(deadline)?,
6389 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6390 ).await.map_err(|_| failure(
6391 NodeFailureCode::SpawnDeadlineExceeded,
6392 "provider admission exceeded the managed spawn deadline",
6393 ))??;
6394 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6395 return Err(failure(
6396 NodeFailureCode::UnsupportedSpawnCapability,
6397 "provider runtime does not admit a required spawn capability",
6398 ));
6399 }
6400 self.ensure_binding_capacity()?;
6401 let source_root = self.workspace_root(&source_workspace_id)?;
6402 profile.validate_for_workspace(&source_root)
6403 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6404 let base_timeout_ms = u64::try_from(
6405 spawn_deadline_remaining(deadline)?.as_millis(),
6406 ).unwrap_or(u64::MAX).max(1);
6407 let base_commit = resolve_base_commit_with_timeout(
6408 &source_root,
6409 profile.base(),
6410 base_timeout_ms,
6411 ).await.map_err(|error| {
6412 if Instant::now() >= deadline || error.message.contains("timed out") {
6413 failure(
6414 NodeFailureCode::SpawnDeadlineExceeded,
6415 "managed worktree base resolution exceeded the spawn deadline",
6416 )
6417 } else {
6418 managed_git_worktree_failure(error)
6419 }
6420 })?;
6421
6422 let lease = {
6423 let _transaction = self.state_transaction.lock()
6424 .unwrap_or_else(|poisoned| poisoned.into_inner());
6425 let previous = self.managed_worktrees.lock()
6426 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6427 let lease = self.managed_worktrees.lock()
6428 .unwrap_or_else(|poisoned| poisoned.into_inner())
6429 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6430 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6431 let previous_replay = match durable_v2_key
6432 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6433 .transpose()
6434 {
6435 Ok(previous) => previous,
6436 Err(error) => {
6437 *self.managed_worktrees.lock()
6438 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6439 return Err(error);
6440 }
6441 };
6442 if let Err(error) = self.persist_state_locked() {
6443 *self.managed_worktrees.lock()
6444 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6445 if let (Some(key), Some(previous_replay)) =
6446 (durable_v2_key, previous_replay)
6447 {
6448 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6449 }
6450 return Err(persistence_failure(error));
6451 }
6452 lease
6453 };
6454 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6455 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6456 self.managed_worktrees.lock()
6457 .unwrap_or_else(|poisoned| poisoned.into_inner())
6458 .remove_unmutated(&lease.lease_id);
6459 if self.persist_state().is_err() {
6460 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6461 }
6462 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6463 self.remember_managed_spawn_attempt(
6464 durable_v2_key,
6465 request,
6466 result.clone(),
6467 accepted_at,
6468 );
6469 return result;
6470 }
6471 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6472 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6473 Err(error) => {
6474 self.reconcile_failed_allocation(&lease, &source_root).await;
6475 let result = Err(error);
6476 self.remember_managed_spawn_attempt(
6477 durable_v2_key,
6478 request,
6479 result.clone(),
6480 accepted_at,
6481 );
6482 return result;
6483 }
6484 };
6485 let created = match create_git_worktree_with_timeout(
6486 &source_root,
6487 &lease.target_root,
6488 &lease.branch,
6489 Some(&base_commit),
6490 mutation_timeout_ms,
6491 ).await {
6492 Ok(created) => created,
6493 Err(error) => {
6494 self.reconcile_failed_allocation(&lease, &source_root).await;
6495 let result = Err(if Instant::now() >= deadline
6496 || error.message.contains("timed out")
6497 || error.message.contains("deadline")
6498 {
6499 failure(
6500 NodeFailureCode::SpawnDeadlineExceeded,
6501 "managed worktree creation exceeded the spawn deadline",
6502 )
6503 } else {
6504 managed_git_worktree_failure(error)
6505 });
6506 self.remember_managed_spawn_attempt(
6507 durable_v2_key,
6508 request,
6509 result.clone(),
6510 accepted_at,
6511 );
6512 return result;
6513 }
6514 };
6515 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6516 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6517 let result = Err(failure(
6518 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6519 "created worktree identity did not match its durable lease",
6520 ));
6521 self.remember_managed_spawn_attempt(
6522 durable_v2_key,
6523 request,
6524 result.clone(),
6525 accepted_at,
6526 );
6527 return result;
6528 }
6529 {
6530 let mut registry = self.managed_worktrees.lock()
6531 .unwrap_or_else(|poisoned| poisoned.into_inner());
6532 let current = registry.get_mut(&lease.lease_id)
6533 .expect("allocated managed worktree lease remains present");
6534 current.expected_head = Some(created.head.clone());
6535 current.state = ManagedWorktreeLeaseState::Ready;
6536 current.updated_at_unix_ms = unix_time_ms();
6537 }
6538 if let Err(error) = self.persist_state() {
6539 self.mark_managed_recovery_required(
6540 &lease.lease_id,
6541 ManagedWorktreeCleanupFailure::Backend,
6542 );
6543 let result = Err(persistence_failure(error));
6544 self.remember_managed_spawn_attempt(
6545 durable_v2_key,
6546 request,
6547 result.clone(),
6548 accepted_at,
6549 );
6550 return result;
6551 }
6552 let ready = self.managed_worktrees.lock()
6553 .unwrap_or_else(|poisoned| poisoned.into_inner())
6554 .get(&lease.lease_id)
6555 .expect("ready managed worktree lease remains present")
6556 .snapshot();
6557 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6558 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6559 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6560 let result = Err(error);
6561 self.remember_managed_spawn_attempt(
6562 durable_v2_key,
6563 request,
6564 result.clone(),
6565 accepted_at,
6566 );
6567 return result;
6568 }
6569 let spawn = self.spawn_session_with_deadline(
6570 lease.workspace_id.clone(),
6571 resolved.provider.clone(),
6572 resolved.mode,
6573 resolved.terminal_size,
6574 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6575 environment_profile.clone(),
6576 bundle.clone(),
6577 context.clone(),
6578 Some(lease.lease_id.clone()),
6579 Some(runtime_policy),
6580 SpawnRecordPolicy::Always,
6581 Some(deadline),
6582 &required_capabilities,
6583 None,
6584 resolved.approval_level,
6585 resolved.network_allowlist.clone(),
6586 resolved.browser_profile_id.clone(),
6587 ).await;
6588 let (session, effective_runtime_policy) = match spawn {
6589 Ok(spawned) => spawned,
6590 Err(error) => {
6591 if self
6592 .cleanup_managed_worktree(&lease.lease_id, true)
6593 .await
6594 .is_err()
6595 {
6596 self.mark_managed_recovery_required(
6597 &lease.lease_id,
6598 ManagedWorktreeCleanupFailure::Backend,
6599 );
6600 }
6601 let result = Err(error);
6602 self.remember_managed_spawn_attempt(
6603 durable_v2_key,
6604 request,
6605 result.clone(),
6606 accepted_at,
6607 );
6608 return result;
6609 }
6610 };
6611 let (record_id, raw_inventory_record_id) = {
6612 let mut bindings = self.session_bindings.lock()
6613 .unwrap_or_else(|poisoned| poisoned.into_inner());
6614 let binding = bindings.get_mut(&session.session.instance_id)
6615 .expect("successful spawn retains its session binding");
6616 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6617 managed_spawn_record_ownership(binding, effective_runtime_policy)
6618 };
6619 let bound = self.managed_worktrees.lock()
6620 .unwrap_or_else(|poisoned| poisoned.into_inner())
6621 .bind_session(
6622 &lease.lease_id,
6623 ManagedWorktreeSessionHolder {
6624 incarnation_id: self.incarnation_id,
6625 instance_id: session.session.instance_id,
6626 generation: session.session.generation,
6627 },
6628 record_id,
6629 unix_time_ms(),
6630 )
6631 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6632 let snapshot = match bound {
6633 Ok(snapshot) => snapshot,
6634 Err(error) => {
6635 if raw_inventory_record_id
6636 .as_ref()
6637 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6638 {
6639 self.mark_managed_recovery_required(
6640 &lease.lease_id,
6641 ManagedWorktreeCleanupFailure::Backend,
6642 );
6643 }
6644 let _ = self.remove_session(&session).await;
6645 self.mark_managed_recovery_required(
6646 &lease.lease_id,
6647 ManagedWorktreeCleanupFailure::Backend,
6648 );
6649 let result = Err(error);
6650 self.remember_managed_spawn_attempt(
6651 durable_v2_key,
6652 request,
6653 result.clone(),
6654 accepted_at,
6655 );
6656 return result;
6657 }
6658 };
6659 if let Err(error) = self.persist_state() {
6660 if raw_inventory_record_id
6661 .as_ref()
6662 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6663 {
6664 self.mark_managed_recovery_required(
6665 &lease.lease_id,
6666 ManagedWorktreeCleanupFailure::Backend,
6667 );
6668 }
6669 let _ = self.remove_session(&session).await;
6670 if self
6671 .cleanup_managed_worktree(&lease.lease_id, true)
6672 .await
6673 .is_err()
6674 {
6675 self.mark_managed_recovery_required(
6676 &lease.lease_id,
6677 ManagedWorktreeCleanupFailure::Backend,
6678 );
6679 }
6680 let result = Err(persistence_failure(error));
6681 self.remember_managed_spawn_attempt(
6682 durable_v2_key,
6683 request,
6684 result.clone(),
6685 accepted_at,
6686 );
6687 return result;
6688 }
6689 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6690 let receipt = managed_spawn_receipt(
6691 &resolved,
6692 self.incarnation_id,
6693 session,
6694 snapshot,
6695 environment_profile,
6696 bundle,
6697 context,
6698 );
6699 let result = Ok(receipt);
6700 self.remember_managed_spawn_attempt(
6701 durable_v2_key,
6702 request,
6703 result.clone(),
6704 accepted_at,
6705 );
6706 result
6707 }
6708
6709 async fn spawn_managed_worktree_v2(
6710 &self,
6711 request: ManagedWorktreeSpawnRequestV2,
6712 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6713 if request.spawn_spec.target.node_id != self.node_id {
6714 return Err(failure(
6715 NodeFailureCode::SpawnTargetMismatch,
6716 "spawn target does not match this node",
6717 ));
6718 }
6719 if request.spawn_spec.target.worktree_id.is_some() {
6720 return Err(failure(
6721 NodeFailureCode::InvalidRequest,
6722 "managed worktree spawn must not select a caller-provided worktree",
6723 ));
6724 }
6725 let request_digest = managed_spawn_request_digest_v2(&request)?;
6726 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6727 match replay {
6728 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6729 DurableManagedSpawnReplayDecision::PendingLinked => {
6730 return Err(failure(
6731 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6732 "managed worktree V2 reservation has an incomplete durable allocation",
6733 ));
6734 }
6735 DurableManagedSpawnReplayDecision::Reserved => {}
6736 }
6737 let validated = (|| {
6738 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6739 let profile = self.managed_profile(
6740 &resolved.target.workspace_id,
6741 &request.worktree_profile_id,
6742 )?;
6743 if profile.revision() != &request.expected_profile_revision {
6744 return Err(failure(
6745 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6746 "managed worktree profile revision does not match the loaded profile",
6747 ));
6748 }
6749 Ok(())
6750 })();
6751 match validated {
6752 Ok(()) => {}
6753 Err(error) => {
6754 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6755 return Err(error);
6756 }
6757 }
6758 let legacy = ManagedWorktreeSpawnRequest {
6759 spawn_spec: request.spawn_spec.clone(),
6760 worktree_profile_id: request.worktree_profile_id.clone(),
6761 };
6762 match self
6763 .spawn_managed_worktree_inner(
6764 legacy,
6765 Some(&request.spawn_spec.idempotency_key),
6766 )
6767 .await
6768 {
6769 Ok(receipt) => {
6770 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6771 Ok(receipt)
6772 }
6773 Err(error) => {
6774 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6775 Err(error)
6776 }
6777 }
6778 }
6779
6780 fn managed_profile(
6781 &self,
6782 workspace_id: &WorkspaceId,
6783 profile_id: &WorktreeProfileId,
6784 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6785 if self.worktree_service_modes.get(workspace_id).copied()
6786 != Some(WorktreeServiceMode::Managed)
6787 {
6788 return Err(failure(
6789 NodeFailureCode::UnsupportedSpawnCapability,
6790 "workspace does not enable managed worktrees",
6791 ));
6792 }
6793 self.managed_worktree_profiles.get(workspace_id)
6794 .and_then(|profiles| profiles.get(profile_id))
6795 .cloned()
6796 .ok_or_else(|| failure(
6797 NodeFailureCode::InvalidRequest,
6798 "managed worktree profile is unavailable for this workspace",
6799 ))
6800 }
6801
6802 async fn reconcile_failed_allocation(
6803 &self,
6804 lease: &ManagedWorktreeLeaseRecord,
6805 source_root: &str,
6806 ) {
6807 match list_git_worktrees(source_root).await {
6808 Ok(worktrees) => {
6809 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6810 None => {
6811 match std::fs::symlink_metadata(&lease.target_root) {
6812 Err(error) if error.kind() == io::ErrorKind::NotFound => {
6813 self.managed_worktrees.lock()
6814 .unwrap_or_else(|poisoned| poisoned.into_inner())
6815 .remove_unmutated(&lease.lease_id);
6816 let _ = self.persist_state();
6817 }
6818 _ => self.mark_managed_recovery_required(
6819 &lease.lease_id,
6820 ManagedWorktreeCleanupFailure::OwnershipConflict,
6821 ),
6822 }
6823 }
6824 Some(item) if exact_owned_worktree(
6825 lease,
6826 &item.path,
6827 item.branch.as_deref(),
6828 &item.head,
6829 ) => {
6830 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6831 }
6832 Some(_) => self.mark_managed_recovery_required(
6833 &lease.lease_id,
6834 ManagedWorktreeCleanupFailure::OwnershipConflict,
6835 ),
6836 }
6837 }
6838 Err(_) => self.mark_managed_recovery_required(
6839 &lease.lease_id,
6840 ManagedWorktreeCleanupFailure::Backend,
6841 ),
6842 }
6843 }
6844
6845 async fn reconcile_managed_worktrees(&self) {
6846 let leases = self.managed_worktrees.lock()
6847 .unwrap_or_else(|poisoned| poisoned.into_inner())
6848 .active_records()
6849 .cloned()
6850 .collect::<Vec<_>>();
6851 for lease in leases {
6852 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6853 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6854 _ => {
6855 self.mark_managed_recovery_required(
6856 &lease.lease_id,
6857 ManagedWorktreeCleanupFailure::OwnershipConflict,
6858 );
6859 continue;
6860 }
6861 };
6862 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6863 self.mark_managed_recovery_required(
6864 &lease.lease_id,
6865 ManagedWorktreeCleanupFailure::OwnershipConflict,
6866 );
6867 continue;
6868 }
6869 let source_root = match self.workspace_root(&lease.source_workspace_id) {
6870 Ok(root) => root,
6871 Err(_) => {
6872 self.mark_managed_recovery_required(
6873 &lease.lease_id,
6874 ManagedWorktreeCleanupFailure::OwnershipConflict,
6875 );
6876 continue;
6877 }
6878 };
6879 let worktrees = match list_git_worktrees(&source_root).await {
6880 Ok(worktrees) => worktrees,
6881 Err(_) => {
6882 self.mark_managed_recovery_required(
6883 &lease.lease_id,
6884 ManagedWorktreeCleanupFailure::Backend,
6885 );
6886 continue;
6887 }
6888 };
6889 let listed = worktrees.iter()
6890 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6891 let Some(listed) = listed else {
6892 match std::fs::symlink_metadata(&lease.target_root) {
6893 Err(error) if error.kind() == io::ErrorKind::NotFound
6894 && lease.state == ManagedWorktreeLeaseState::Allocating
6895 && !lease.has_holders() => {
6896 let removed = self.managed_worktrees.lock()
6897 .unwrap_or_else(|poisoned| poisoned.into_inner())
6898 .tombstone(&lease.lease_id, unix_time_ms());
6899 if self.persist_state().is_ok() && removed.is_some() {
6900 self.publish(NodeEvent::ManagedWorktreeRemoved {
6901 lease_id: lease.lease_id.clone(),
6902 });
6903 }
6904 }
6905 _ => self.mark_managed_recovery_required(
6906 &lease.lease_id,
6907 ManagedWorktreeCleanupFailure::OwnershipConflict,
6908 ),
6909 }
6910 continue;
6911 };
6912 if listed.is_main || listed.is_bare || !exact_owned_worktree(
6913 &lease,
6914 &listed.path,
6915 listed.branch.as_deref(),
6916 &listed.head,
6917 ) {
6918 self.mark_managed_recovery_required(
6919 &lease.lease_id,
6920 ManagedWorktreeCleanupFailure::OwnershipConflict,
6921 );
6922 continue;
6923 }
6924 if !self.workspaces.read()
6925 .unwrap_or_else(|poisoned| poisoned.into_inner())
6926 .contains_key(&lease.workspace_id)
6927 {
6928 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
6929 self.mark_managed_recovery_required(
6930 &lease.lease_id,
6931 ManagedWorktreeCleanupFailure::Backend,
6932 );
6933 continue;
6934 }
6935 }
6936 let snapshot = {
6937 let mut registry = self.managed_worktrees.lock()
6938 .unwrap_or_else(|poisoned| poisoned.into_inner());
6939 let current = registry.get_mut(&lease.lease_id)
6940 .expect("reconciled managed lease remains present");
6941 current.expected_head = Some(listed.head.clone());
6942 current.cleanup_failure = None;
6943 current.state = if current.has_holders() {
6944 ManagedWorktreeLeaseState::InUse
6945 } else if current.retention == ManagedWorktreeRetention::Retain {
6946 ManagedWorktreeLeaseState::Retained
6947 } else {
6948 ManagedWorktreeLeaseState::Ready
6949 };
6950 current.updated_at_unix_ms = unix_time_ms();
6951 current.snapshot()
6952 };
6953 if self.persist_state().is_err() {
6954 self.mark_managed_recovery_required(
6955 &lease.lease_id,
6956 ManagedWorktreeCleanupFailure::Backend,
6957 );
6958 continue;
6959 }
6960 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6961 if snapshot.state == ManagedWorktreeLeaseState::Ready {
6962 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
6963 }
6964 }
6965 }
6966
6967 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
6987 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
6988 .session_records
6989 .lock()
6990 .unwrap_or_else(|poisoned| poisoned.into_inner())
6991 .records
6992 .values()
6993 .filter(|record| {
6994 record.state == ManagedSessionState::Live
6995 && record.exported_context.is_none()
6996 && record.provider_session.is_some()
6997 })
6998 .filter_map(|record| {
6999 record
7000 .active_session
7001 .clone()
7002 .map(|session| (record.record_id.clone(), session))
7003 })
7004 .collect();
7005 for (record_id, session) in candidates {
7006 let shared = Arc::clone(self);
7007 tokio::spawn(async move {
7008 shared
7009 .reconcile_context_pack_export_for_record(&record_id, session)
7010 .await;
7011 });
7012 }
7013 }
7014
7015 async fn reconcile_context_pack_export_for_record(
7034 &self,
7035 record_id: &SessionRecordId,
7036 session: SessionAddress,
7037 ) {
7038 let Ok(record) = self.record(record_id) else {
7039 return;
7040 };
7041 if !matches!(
7042 record.state,
7043 ManagedSessionState::Live | ManagedSessionState::Dormant
7044 ) || record.exported_context.is_some()
7045 || record.provider_session.is_none()
7046 {
7047 return;
7048 }
7049 let Ok(receipt) = self
7056 .export_context_pack_for_session_record(&record.record_id, &session, true)
7057 .await
7058 else {
7059 return;
7060 };
7061 self.set_exported_context(&record.record_id, receipt);
7062 }
7063
7064 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7069 let transaction = self
7070 .state_transaction
7071 .lock()
7072 .unwrap_or_else(|poisoned| poisoned.into_inner());
7073 let (previous, updated) = {
7074 let mut records = self
7075 .session_records
7076 .lock()
7077 .unwrap_or_else(|poisoned| poisoned.into_inner());
7078 let Some(record) = records.records.get_mut(record_id) else {
7079 return;
7080 };
7081 if record.exported_context.is_some() {
7082 return;
7083 }
7084 let previous = record.clone();
7085 record.exported_context = Some(receipt);
7086 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7087 (previous, record.clone())
7088 };
7089 if self.persist_state_locked().is_err() {
7090 self.session_records
7091 .lock()
7092 .unwrap_or_else(|poisoned| poisoned.into_inner())
7093 .records
7094 .insert(record_id.clone(), previous);
7095 return;
7096 }
7097 drop(transaction);
7098 self.publish_record(updated);
7099 }
7100
7101 fn managed_record_export_target_for_instance(
7111 &self,
7112 instance_id: AgentInstanceId,
7113 generation: SessionGeneration,
7114 ) -> Option<(SessionRecordId, SessionAddress)> {
7115 let bindings = self
7116 .session_bindings
7117 .lock()
7118 .unwrap_or_else(|poisoned| poisoned.into_inner());
7119 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7120 let record_id = binding.record_id.clone()?;
7121 Some((
7122 record_id,
7123 SessionAddress {
7124 workspace_id: binding.workspace_id.clone(),
7125 session: SessionKey { instance_id, generation },
7126 },
7127 ))
7128 }
7129
7130 fn mark_managed_recovery_required(
7131 &self,
7132 lease_id: &ManagedWorktreeLeaseId,
7133 failure_kind: ManagedWorktreeCleanupFailure,
7134 ) {
7135 let snapshot = {
7136 let mut registry = self.managed_worktrees.lock()
7137 .unwrap_or_else(|poisoned| poisoned.into_inner());
7138 let Some(lease) = registry.get_mut(lease_id) else { return };
7139 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7140 lease.cleanup_failure = Some(failure_kind);
7141 lease.updated_at_unix_ms = unix_time_ms();
7142 lease.snapshot()
7143 };
7144 let _ = self.persist_state();
7145 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7146 }
7147
7148 fn set_persistence_error(&self, error: Option<String>) {
7149 *self
7150 .persistence_error
7151 .write()
7152 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7153 .map(|message| session_registry::sanitized_persistence_summary(&message));
7154 }
7155
7156 fn persist_state(&self) -> io::Result<()> {
7157 let _transaction = self
7158 .state_transaction
7159 .lock()
7160 .unwrap_or_else(|poisoned| poisoned.into_inner());
7161 self.persist_state_locked()
7162 }
7163
7164 fn persist_state_locked(&self) -> io::Result<()> {
7165 let workspaces = self
7166 .workspaces
7167 .read()
7168 .unwrap_or_else(|poisoned| poisoned.into_inner())
7169 .clone();
7170 let records = self
7171 .session_records
7172 .lock()
7173 .unwrap_or_else(|poisoned| poisoned.into_inner())
7174 .records
7175 .values()
7176 .cloned()
7177 .collect::<Vec<_>>();
7178 let mut managed = self.managed_worktrees
7179 .lock()
7180 .unwrap_or_else(|poisoned| poisoned.into_inner());
7181 managed.reattach_record_holders(
7182 &managed_worktree_record_holders(&records),
7183 unix_time_ms(),
7184 );
7185 let materializations = self
7186 .materializations
7187 .lock()
7188 .unwrap_or_else(|poisoned| poisoned.into_inner())
7189 .values()
7190 .cloned()
7191 .collect::<Vec<_>>();
7192 let managed_spawn_replays = self.managed_spawn_replays
7193 .lock()
7194 .unwrap_or_else(|poisoned| poisoned.into_inner())
7195 .values()
7196 .cloned()
7197 .collect::<Vec<_>>();
7198 let result = session_registry::save_v10(
7199 self.state_path.as_deref(),
7200 &self.node_id,
7201 &workspaces,
7202 &records,
7203 &managed.records(),
7204 &managed.tombstones(),
7205 &materializations,
7206 &managed_spawn_replays,
7207 );
7208 match &result {
7209 Ok(warning) => self.set_persistence_error(warning.clone()),
7210 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7211 }
7212 result.map(|_| ())
7213 }
7214
7215 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7216 let _mutation_guard = self.mutation_gate.lock().await;
7217 self.begin_shutdown_locked().await
7218 }
7219
7220 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7221 if self.shutdown.swap(true, Ordering::AcqRel) {
7222 return Ok(());
7223 }
7224 SessionLifecycleHook::on_shutdown(self);
7225 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7226 let mut first_error = None;
7227 for session in &self.handle.snapshot().sessions {
7228 let remaining = deadline.saturating_duration_since(Instant::now());
7229 if remaining.is_zero() {
7230 first_error.get_or_insert_with(|| {
7231 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7232 });
7233 break;
7234 }
7235 if let Err(error) = self
7236 .dispatch_bounded(
7237 ControlCommand::Stop {
7238 instance_id: session.instance_id,
7239 force: true,
7240 },
7241 remaining,
7242 )
7243 .await
7244 {
7245 first_error.get_or_insert(error);
7246 }
7247 }
7248 self.shutdown_notify.notify_waiters();
7249 match first_error {
7250 Some(error) => Err(error),
7251 None => Ok(()),
7252 }
7253 }
7254
7255 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7256 let bindings = self
7257 .session_bindings
7258 .lock()
7259 .unwrap_or_else(|poisoned| poisoned.into_inner());
7260 if bindings.len() >= CONTROL_SESSIONS_MAX {
7261 Err(failure(
7262 NodeFailureCode::BackendBusy,
7263 "node session-binding capacity is full; remove an existing session first",
7264 ))
7265 } else {
7266 Ok(())
7267 }
7268 }
7269
7270 fn ensure_workspace_environment_profile_compatible(
7277 &self,
7278 workspace_id: &WorkspaceId,
7279 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7280 ) -> Result<(), NodeFailure> {
7281 let bindings = self
7282 .session_bindings
7283 .lock()
7284 .unwrap_or_else(|poisoned| poisoned.into_inner());
7285 for binding in bindings.values() {
7286 if &binding.workspace_id != workspace_id {
7287 continue;
7288 }
7289 if binding.environment_profile.as_ref() != environment_profile {
7290 return Err(failure(
7291 NodeFailureCode::WorkspaceBusy,
7292 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7293 ));
7294 }
7295 }
7296 Ok(())
7297 }
7298
7299 #[cfg(test)]
7300 fn bind_session(
7301 &self,
7302 instance_id: AgentInstanceId,
7303 workspace_id: WorkspaceId,
7304 generation: SessionGeneration,
7305 ) {
7306 self.bind_session_with_policy(
7307 &SessionAddress {
7308 workspace_id,
7309 session: SessionKey { instance_id, generation },
7310 },
7311 ProviderRuntimePolicy::raw_pty(),
7312 None,
7313 );
7314 }
7315
7316 #[cfg(test)]
7317 fn bind_session_with_policy(
7318 &self,
7319 address: &SessionAddress,
7320 runtime_policy: ProviderRuntimePolicy,
7321 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7322 ) {
7323 self.bind_session_with_materialization(
7324 address,
7325 runtime_policy,
7326 environment_profile,
7327 None,
7328 None,
7329 None,
7330 );
7331 }
7332
7333 fn bind_session_with_materialization(
7334 &self,
7335 address: &SessionAddress,
7336 runtime_policy: ProviderRuntimePolicy,
7337 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7338 bundle: Option<ResolvedBundleReceipt>,
7339 context: Option<ResolvedContextPackReceipt>,
7340 materialization_id: Option<MaterializationId>,
7341 ) {
7342 let mut bindings = self
7343 .session_bindings
7344 .lock()
7345 .unwrap_or_else(|poisoned| poisoned.into_inner());
7346 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7347 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7348 bindings.insert(address.session.instance_id, SessionBinding {
7349 workspace_id: address.workspace_id.clone(),
7350 generation: address.session.generation,
7351 runtime_policy,
7352 pending_resume: None,
7353 record_id: None,
7354 managed_worktree_lease_id: None,
7355 environment_profile,
7356 bundle,
7357 context,
7358 materialization_id,
7359 });
7360 }
7361
7362 #[cfg(test)]
7363 fn bind_managed_session(
7364 &self,
7365 address: &SessionAddress,
7366 record_id: SessionRecordId,
7367 runtime_policy: ProviderRuntimePolicy,
7368 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7369 ) {
7370 self.bind_managed_session_with_materialization(
7371 address,
7372 record_id,
7373 runtime_policy,
7374 environment_profile,
7375 None,
7376 None,
7377 None,
7378 );
7379 }
7380
7381 fn bind_managed_session_with_materialization(
7382 &self,
7383 address: &SessionAddress,
7384 record_id: SessionRecordId,
7385 runtime_policy: ProviderRuntimePolicy,
7386 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7387 bundle: Option<ResolvedBundleReceipt>,
7388 context: Option<ResolvedContextPackReceipt>,
7389 materialization_id: Option<MaterializationId>,
7390 ) {
7391 let mut bindings = self
7392 .session_bindings
7393 .lock()
7394 .unwrap_or_else(|poisoned| poisoned.into_inner());
7395 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7396 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7397 bindings.insert(
7398 address.session.instance_id,
7399 SessionBinding {
7400 workspace_id: address.workspace_id.clone(),
7401 generation: address.session.generation,
7402 runtime_policy,
7403 pending_resume: None,
7404 record_id: Some(record_id),
7405 managed_worktree_lease_id: None,
7406 environment_profile,
7407 bundle,
7408 context,
7409 materialization_id,
7410 },
7411 );
7412 }
7413
7414 #[cfg(test)]
7415 fn bind_spawn_session(
7416 &self,
7417 address: &SessionAddress,
7418 provider: AgentId,
7419 mode: SessionMode,
7420 runtime_policy: ProviderRuntimePolicy,
7421 record_policy: SpawnRecordPolicy,
7422 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7423 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7424 self.bind_spawn_session_with_materialization(
7425 address,
7426 provider,
7427 mode,
7428 runtime_policy,
7429 record_policy,
7430 environment_profile,
7431 None,
7432 None,
7433 None,
7434 )
7435 }
7436
7437 fn bind_spawn_session_with_materialization(
7438 &self,
7439 address: &SessionAddress,
7440 provider: AgentId,
7441 mode: SessionMode,
7442 runtime_policy: ProviderRuntimePolicy,
7443 record_policy: SpawnRecordPolicy,
7444 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7445 bundle: Option<ResolvedBundleReceipt>,
7446 context: Option<ResolvedContextPackReceipt>,
7447 materialization_id: Option<MaterializationId>,
7448 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7449 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7450 && !runtime_policy.provider_session_identity
7451 {
7452 self.bind_session_with_materialization(
7453 address,
7454 runtime_policy,
7455 environment_profile,
7456 bundle,
7457 context,
7458 materialization_id,
7459 );
7460 return Ok(None);
7461 }
7462 let record_owns_lifecycle = runtime_policy.provider_session_identity;
7463 let record = self.new_record(
7464 address,
7465 provider,
7466 mode,
7467 active_record_state(runtime_policy.provider_session_identity, false),
7468 None,
7469 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7470 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7471 record_owns_lifecycle.then(|| context.clone()).flatten(),
7472 )?;
7473 let record_id = record.record_id.clone();
7474 let transaction = self
7475 .state_transaction
7476 .lock()
7477 .unwrap_or_else(|poisoned| poisoned.into_inner());
7478 self.insert_record(record.clone())?;
7479 let previous_materialization = if record_owns_lifecycle {
7480 if let Some(materialization_id) = materialization_id.as_ref() {
7481 let mut materializations = self
7482 .materializations
7483 .lock()
7484 .unwrap_or_else(|poisoned| poisoned.into_inner());
7485 let Some(ownership) = materializations.get_mut(materialization_id) else {
7486 drop(materializations);
7487 self.remove_record_memory(&record_id);
7488 return Err(failure(
7489 NodeFailureCode::BackendOperationFailed,
7490 "session environment ownership disappeared before binding",
7491 ));
7492 };
7493 let previous = ownership.clone();
7494 if ownership
7495 .transfer_to_record(record_id.clone(), unix_time_ms())
7496 .is_err()
7497 {
7498 drop(materializations);
7499 self.remove_record_memory(&record_id);
7500 return Err(failure(
7501 NodeFailureCode::BackendOperationFailed,
7502 "session environment ownership transfer failed",
7503 ));
7504 }
7505 Some(previous)
7506 } else {
7507 None
7508 }
7509 } else {
7510 None
7511 };
7512 self.bind_managed_session_with_materialization(
7513 address,
7514 record_id.clone(),
7515 runtime_policy,
7516 environment_profile,
7517 bundle,
7518 context,
7519 materialization_id.clone(),
7520 );
7521 if let Err(error) = self.persist_state_locked() {
7522 self.remove_binding(address);
7523 self.remove_record_memory(&record_id);
7524 if let (Some(materialization_id), Some(previous)) =
7525 (materialization_id.as_ref(), previous_materialization)
7526 {
7527 self.materializations
7528 .lock()
7529 .unwrap_or_else(|poisoned| poisoned.into_inner())
7530 .insert(materialization_id.clone(), previous);
7531 }
7532 return Err(persistence_failure(error));
7533 }
7534 drop(transaction);
7535 self.publish_record(record);
7536 Ok(Some(record_id))
7537 }
7538
7539 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7540 for _ in 0..8 {
7541 let bytes = random_nonce().map_err(|error| {
7542 failure(
7543 NodeFailureCode::BackendOperationFailed,
7544 &format!("session record identity generation failed: {error}"),
7545 )
7546 })?;
7547 let mut value = String::with_capacity(27);
7548 value.push_str("sr-");
7549 for byte in &bytes[..12] {
7550 use std::fmt::Write as _;
7551 let _ = write!(&mut value, "{byte:02x}");
7552 }
7553 let record_id = SessionRecordId::new(value).map_err(|error| {
7554 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7555 })?;
7556 let records = self
7557 .session_records
7558 .lock()
7559 .unwrap_or_else(|poisoned| poisoned.into_inner());
7560 if !records.records.contains_key(&record_id) {
7561 return Ok(record_id);
7562 }
7563 }
7564 Err(failure(
7565 NodeFailureCode::BackendBusy,
7566 "could not allocate a unique session record ID",
7567 ))
7568 }
7569
7570 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7571 for _ in 0..8 {
7572 let bytes = random_nonce().map_err(|error| {
7573 failure(
7574 NodeFailureCode::BackendOperationFailed,
7575 &format!("task identity generation failed: {error}"),
7576 )
7577 })?;
7578 let mut nonce = [0_u8; 12];
7579 nonce.copy_from_slice(&bytes[..12]);
7580 let task_id = TaskId::from_nonce(nonce);
7581 let records = self
7582 .session_records
7583 .lock()
7584 .unwrap_or_else(|poisoned| poisoned.into_inner());
7585 if !records.records.values().any(|record| {
7586 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7587 == Some(&task_id)
7588 }) {
7589 return Ok(task_id);
7590 }
7591 }
7592 Err(failure(
7593 NodeFailureCode::BackendBusy,
7594 "could not allocate a unique task ID",
7595 ))
7596 }
7597
7598 fn default_record_name(&self, provider: &AgentId) -> String {
7599 let records = self
7600 .session_records
7601 .lock()
7602 .unwrap_or_else(|poisoned| poisoned.into_inner());
7603 let ordinal = records
7604 .records
7605 .values()
7606 .filter(|record| &record.provider == provider)
7607 .count()
7608 .saturating_add(1);
7609 format!("{} #{ordinal}", provider.as_str())
7610 }
7611
7612 fn new_record(
7613 &self,
7614 address: &SessionAddress,
7615 provider: AgentId,
7616 mode: SessionMode,
7617 state: ManagedSessionState,
7618 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7619 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7620 bundle: Option<ResolvedBundleReceipt>,
7621 context: Option<ResolvedContextPackReceipt>,
7622 ) -> Result<ManagedSessionRecord, NodeFailure> {
7623 let canonical_root = self.workspace_root(&address.workspace_id)?;
7624 let now = unix_time_ms();
7625 Ok(ManagedSessionRecord {
7626 record_id: self.allocate_record_id()?,
7627 display_name: self.default_record_name(&provider),
7628 provider,
7629 mode,
7630 state,
7631 workspace_id: address.workspace_id.clone(),
7632 canonical_root: opaque_windows_path(canonical_root),
7633 provider_session,
7634 active_session: Some(address.clone()),
7635 environment_profile,
7636 bundle,
7637 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7638 context,
7639 exported_context: None,
7640 task_binding: None,
7641 created_at_unix_ms: now,
7642 updated_at_unix_ms: now,
7643 last_error: None,
7644 })
7645 }
7646
7647 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7648 let mut records = self
7649 .session_records
7650 .lock()
7651 .unwrap_or_else(|poisoned| poisoned.into_inner());
7652 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7653 return Err(failure(
7654 NodeFailureCode::BackendBusy,
7655 "managed session record capacity is full; forget an inactive record first",
7656 ));
7657 }
7658 if let Some(identity) = record.provider_session.as_ref() {
7659 if records.records.values().any(|candidate| {
7660 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7661 session_registry::same_provider_session(
7662 &candidate.provider,
7663 candidate_identity,
7664 &record.provider,
7665 identity,
7666 )
7667 })
7668 }) {
7669 return Err(failure(
7670 NodeFailureCode::SessionRecordConflict,
7671 "provider session is already represented by another managed record",
7672 ));
7673 }
7674 }
7675 match records.records.entry(record.record_id.clone()) {
7676 std::collections::btree_map::Entry::Vacant(entry) => {
7677 entry.insert(record);
7678 }
7679 std::collections::btree_map::Entry::Occupied(_) => {
7680 return Err(failure(
7681 NodeFailureCode::SessionRecordConflict,
7682 "session record ID collision",
7683 ));
7684 }
7685 }
7686 Ok(())
7687 }
7688
7689 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7690 self.session_records
7691 .lock()
7692 .unwrap_or_else(|poisoned| poisoned.into_inner())
7693 .records
7694 .get(record_id)
7695 .cloned()
7696 .ok_or_else(|| {
7697 failure(
7698 NodeFailureCode::UnknownSessionRecord,
7699 "managed session record does not exist",
7700 )
7701 })
7702 }
7703
7704 fn revalidate_session_record_preview(
7705 &self,
7706 expected: &ManagedSessionRecord,
7707 identity: &ProviderSessionIdentity,
7708 ) -> Result<(), NodeFailure> {
7709 let current = self.record(&expected.record_id)?;
7710 let current_root = self.workspace_root(¤t.workspace_id)?;
7711 if current.provider != expected.provider
7712 || current.workspace_id != expected.workspace_id
7713 || current.canonical_root != expected.canonical_root
7714 || current.provider_session.as_ref() != Some(identity)
7715 || !platform::roots_equal(
7716 ¤t_root,
7717 windows_path_text(¤t.canonical_root),
7718 )
7719 {
7720 return Err(failure(
7721 NodeFailureCode::SessionRecordConflict,
7722 "managed session changed while its preview was loading",
7723 ));
7724 }
7725 Ok(())
7726 }
7727
7728 fn revalidate_session_record_context_export(
7729 &self,
7730 expected: &ManagedSessionRecord,
7731 identity: Option<&ProviderSessionIdentity>,
7732 session: &SessionAddress,
7733 allow_clean_detachment: bool,
7734 ) -> Result<(), NodeFailure> {
7735 let current = self.record(&expected.record_id)?;
7736 let current_root = self.workspace_root(¤t.workspace_id)?;
7737 let snapshot = self.internal_session_snapshot(session)?;
7738 let binding = self
7739 .session_bindings
7740 .lock()
7741 .unwrap_or_else(|poisoned| poisoned.into_inner())
7742 .get(&session.session.instance_id)
7743 .cloned()
7744 .ok_or_else(|| {
7745 failure(
7746 NodeFailureCode::SessionRecordConflict,
7747 "managed session runtime binding disappeared during context export",
7748 )
7749 })?;
7750 if !session_record_context_export_binding_is_exact(
7751 expected,
7752 ¤t,
7753 identity,
7754 session,
7755 &binding,
7756 &snapshot.agent_id,
7757 ¤t_root,
7758 allow_clean_detachment,
7759 ) || !session_record_context_export_source_is_usable(
7760 ¤t,
7761 &snapshot.status,
7762 allow_clean_detachment,
7763 ) {
7764 return Err(failure(
7765 NodeFailureCode::SessionRecordConflict,
7766 "managed session binding changed during context export",
7767 ));
7768 }
7769 Ok(())
7770 }
7771
7772 fn revalidate_loaded_history(
7773 &self,
7774 session: &SessionAddress,
7775 candidate_id: &str,
7776 loaded: &HistorySessionRecord,
7777 ) -> Result<(), NodeFailure> {
7778 let snapshot = self.internal_session_snapshot(session)?;
7779 if snapshot.history.pending.is_some()
7780 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7781 || snapshot.history.loaded.as_ref() != Some(loaded)
7782 {
7783 return Err(failure(
7784 NodeFailureCode::SessionRecordConflict,
7785 "managed session history changed during context export",
7786 ));
7787 }
7788 Ok(())
7789 }
7790
7791 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7792 self.session_records
7793 .lock()
7794 .unwrap_or_else(|poisoned| poisoned.into_inner())
7795 .records
7796 .remove(record_id)
7797 }
7798
7799 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7800 if self.destroy_record_materialization(record_id)? {
7801 self.publish(NodeEvent::SessionRecordRemoved {
7802 record_id: record_id.clone(),
7803 });
7804 return Ok(());
7805 }
7806 let transaction = self
7807 .state_transaction
7808 .lock()
7809 .unwrap_or_else(|poisoned| poisoned.into_inner());
7810 let Some(removed) = self.remove_record_memory(record_id) else {
7811 return Ok(());
7812 };
7813 if let Err(error) = self.persist_state_locked() {
7814 let _ = self.insert_record(removed);
7815 return Err(persistence_failure(error));
7816 }
7817 drop(transaction);
7818 self.publish(NodeEvent::SessionRecordRemoved {
7819 record_id: record_id.clone(),
7820 });
7821 Ok(())
7822 }
7823
7824 fn publish_record(&self, record: ManagedSessionRecord) {
7825 self.publish(NodeEvent::SessionRecordUpserted { record });
7826 }
7827
7828 fn mark_record_error(
7829 &self,
7830 record_id: &SessionRecordId,
7831 message: &str,
7832 ) -> Result<(), NodeFailure> {
7833 let transaction = self
7834 .state_transaction
7835 .lock()
7836 .unwrap_or_else(|poisoned| poisoned.into_inner());
7837 let previous = {
7838 let mut records = self
7839 .session_records
7840 .lock()
7841 .unwrap_or_else(|poisoned| poisoned.into_inner());
7842 let Some(record) = records.records.get_mut(record_id) else {
7843 return Ok(());
7844 };
7845 let previous = record.clone();
7846 record.state = ManagedSessionState::Unavailable;
7847 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7848 record.updated_at_unix_ms = unix_time_ms();
7849 previous
7850 };
7851 if let Err(error) = self.persist_state_locked() {
7852 self.session_records
7853 .lock()
7854 .unwrap_or_else(|poisoned| poisoned.into_inner())
7855 .records
7856 .insert(record_id.clone(), previous);
7857 return Err(persistence_failure(error));
7858 }
7859 let record = self.record(record_id)?;
7860 drop(transaction);
7861 self.publish_record(record);
7862 Ok(())
7863 }
7864
7865 fn rename_session_record(
7866 &self,
7867 record_id: &SessionRecordId,
7868 display_name: String,
7869 ) -> Result<ManagedSessionRecord, NodeFailure> {
7870 validate_display_name(&display_name)
7871 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7872 let _transaction = self
7873 .state_transaction
7874 .lock()
7875 .unwrap_or_else(|poisoned| poisoned.into_inner());
7876 let previous = {
7877 let mut records = self
7878 .session_records
7879 .lock()
7880 .unwrap_or_else(|poisoned| poisoned.into_inner());
7881 let record = records.records.get_mut(record_id).ok_or_else(|| {
7882 failure(
7883 NodeFailureCode::UnknownSessionRecord,
7884 "managed session record does not exist",
7885 )
7886 })?;
7887 let previous = record.clone();
7888 record.display_name = display_name;
7889 record.updated_at_unix_ms = unix_time_ms();
7890 previous
7891 };
7892 if let Err(error) = self.persist_state_locked() {
7893 self.session_records
7894 .lock()
7895 .unwrap_or_else(|poisoned| poisoned.into_inner())
7896 .records
7897 .insert(record_id.clone(), previous);
7898 return Err(persistence_failure(error));
7899 }
7900 let updated = self.record(record_id)?;
7901 self.publish_record(updated.clone());
7902 Ok(updated)
7903 }
7904
7905 fn set_session_task(
7906 &self,
7907 record_id: &SessionRecordId,
7908 expected_revision: u64,
7909 target: SessionTaskTargetV1,
7910 ) -> Result<ManagedSessionRecord, NodeFailure> {
7911 let transaction = self
7912 .state_transaction
7913 .lock()
7914 .unwrap_or_else(|poisoned| poisoned.into_inner());
7915 let current = self.record(record_id)?;
7916 let current_task_id = current
7917 .task_binding
7918 .as_ref()
7919 .and_then(|binding| binding.task_id.as_ref());
7920 let current_revision = current
7921 .task_binding
7922 .as_ref()
7923 .map_or(0, |binding| binding.revision);
7924 if expected_revision != current_revision {
7925 return Err(failure(
7926 NodeFailureCode::SessionRecordConflict,
7927 "managed session task binding revision changed",
7928 ));
7929 }
7930 let exact = match &target {
7931 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
7932 SessionTaskTargetV1::Clear => current_task_id.is_none(),
7933 SessionTaskTargetV1::New => false,
7934 };
7935 if exact {
7936 return Ok(current);
7937 }
7938 let revision = current_revision.checked_add(1).ok_or_else(|| {
7939 failure(
7940 NodeFailureCode::SessionRecordConflict,
7941 "managed session task binding revision is exhausted",
7942 )
7943 })?;
7944 let task_id = match target {
7945 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
7946 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
7947 SessionTaskTargetV1::Clear => None,
7948 };
7949 let changed_at_unix_ms = unix_time_ms()
7950 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
7951 failure(
7952 NodeFailureCode::SessionRecordConflict,
7953 "managed session record timestamp is exhausted",
7954 )
7955 })?);
7956 let updated = {
7957 let mut records = self
7958 .session_records
7959 .lock()
7960 .unwrap_or_else(|poisoned| poisoned.into_inner());
7961 let record = records.records.get_mut(record_id).ok_or_else(|| {
7962 failure(
7963 NodeFailureCode::UnknownSessionRecord,
7964 "managed session record does not exist",
7965 )
7966 })?;
7967 record.task_binding = Some(SessionTaskBindingV1 {
7968 revision,
7969 task_id,
7970 changed_at_unix_ms,
7971 });
7972 record.updated_at_unix_ms = changed_at_unix_ms;
7973 record.clone()
7974 };
7975 if let Err(error) = self.persist_state_locked() {
7976 self.session_records
7977 .lock()
7978 .unwrap_or_else(|poisoned| poisoned.into_inner())
7979 .records
7980 .insert(record_id.clone(), current);
7981 return Err(persistence_failure(error));
7982 }
7983 drop(transaction);
7984 self.publish_record(updated.clone());
7985 Ok(updated)
7986 }
7987
7988 fn index_provider_session(
7989 &self,
7990 workspace_id: WorkspaceId,
7991 provider: AgentId,
7992 identity: ProviderSessionIdentity,
7993 display_name: String,
7994 ) -> Result<ManagedSessionRecord, NodeFailure> {
7995 self.index_provider_session_with_policy(
7996 workspace_id,
7997 provider,
7998 identity,
7999 display_name,
8000 false,
8001 )
8002 }
8003
8004 fn index_provider_session_with_policy(
8005 &self,
8006 workspace_id: WorkspaceId,
8007 provider: AgentId,
8008 identity: ProviderSessionIdentity,
8009 display_name: String,
8010 allow_validated_transcript_path: bool,
8011 ) -> Result<ManagedSessionRecord, NodeFailure> {
8012 validate_display_name(&display_name)
8013 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8014 identity
8015 .validate()
8016 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8017 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8018 return Err(failure(
8019 NodeFailureCode::InvalidRequest,
8020 "provider session reference index v1 does not accept transcript paths",
8021 ));
8022 }
8023 let canonical_root = self.workspace_root(&workspace_id)?;
8024 if !self.enabled_providers.contains(&provider) {
8025 return Err(failure(
8026 NodeFailureCode::InvalidRequest,
8027 "provider is not enabled on this node",
8028 ));
8029 }
8030
8031 let transaction = self
8032 .state_transaction
8033 .lock()
8034 .unwrap_or_else(|poisoned| poisoned.into_inner());
8035 {
8036 let records = self
8037 .session_records
8038 .lock()
8039 .unwrap_or_else(|poisoned| poisoned.into_inner());
8040 if let Some(existing) = records.records.values().find(|candidate| {
8041 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8042 candidate_identity.key == identity.key && candidate_identity.id == identity.id
8043 })
8044 }) {
8045 if existing.provider == provider && existing.workspace_id == workspace_id {
8046 return Ok(existing.clone());
8047 }
8048 return Err(failure(
8049 NodeFailureCode::SessionRecordConflict,
8050 "provider session reference conflicts with another provider or workspace",
8051 ));
8052 }
8053 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8054 return Err(failure(
8055 NodeFailureCode::BackendBusy,
8056 "managed session record capacity is full; forget an inactive record first",
8057 ));
8058 }
8059 }
8060
8061 let now = unix_time_ms();
8062 let record = ManagedSessionRecord {
8063 record_id: self.allocate_record_id()?,
8064 display_name,
8065 provider,
8066 mode: SessionMode::Pty,
8067 state: ManagedSessionState::Dormant,
8068 workspace_id,
8069 canonical_root: opaque_windows_path(canonical_root),
8070 provider_session: Some(identity),
8071 active_session: None,
8072 environment_profile: None,
8073 bundle: None,
8074 context_id: None,
8075 context: None,
8076 exported_context: None,
8077 task_binding: None,
8078 created_at_unix_ms: now,
8079 updated_at_unix_ms: now,
8080 last_error: None,
8081 };
8082 self.insert_record(record.clone())?;
8083 if let Err(error) = self.persist_state_locked() {
8084 self.remove_record_memory(&record.record_id);
8085 return Err(persistence_failure(error));
8086 }
8087 drop(transaction);
8088 self.publish_record(record.clone());
8089 Ok(record)
8090 }
8091
8092 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8093 self.session_bindings
8094 .lock()
8095 .unwrap_or_else(|poisoned| poisoned.into_inner())
8096 .iter()
8097 .find_map(|(instance_id, binding)| {
8098 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8099 workspace_id: binding.workspace_id.clone(),
8100 session: SessionKey {
8101 instance_id: *instance_id,
8102 generation: binding.generation,
8103 },
8104 })
8105 })
8106 }
8107
8108 async fn forget_session_record(
8109 &self,
8110 record_id: &SessionRecordId,
8111 ) -> Result<(), NodeFailure> {
8112 let record = self.record(record_id)?;
8113 if record.active_session.is_some()
8114 || matches!(
8115 record.state,
8116 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8117 )
8118 {
8119 return Err(failure(
8120 NodeFailureCode::SessionRecordBusy,
8121 "stop the managed session before forgetting it",
8122 ));
8123 }
8124 if let Some(address) = self.bound_address_for_record(record_id) {
8125 self.remove_session(&address).await?;
8126 }
8127 self.remove_managed_session_record(record_id, &record.workspace_id).await
8128 }
8129
8130 async fn remove_managed_session_record(
8145 &self,
8146 record_id: &SessionRecordId,
8147 workspace_id: &WorkspaceId,
8148 ) -> Result<(), NodeFailure> {
8149 let managed_lease_id = self.managed_worktrees.lock()
8150 .unwrap_or_else(|poisoned| poisoned.into_inner())
8151 .lease_for_workspace(workspace_id);
8152 if !self.destroy_record_materialization(record_id)? {
8153 let transaction = self
8154 .state_transaction
8155 .lock()
8156 .unwrap_or_else(|poisoned| poisoned.into_inner());
8157 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8158 failure(
8159 NodeFailureCode::UnknownSessionRecord,
8160 "managed session record does not exist",
8161 )
8162 })?;
8163 if let Err(error) = self.persist_state_locked() {
8164 let _ = self.insert_record(removed);
8165 return Err(persistence_failure(error));
8166 }
8167 drop(transaction);
8168 }
8169 self.publish(NodeEvent::SessionRecordRemoved {
8170 record_id: record_id.clone(),
8171 });
8172 if let Some(lease_id) = managed_lease_id {
8173 if let Some(snapshot) = self.managed_worktrees.lock()
8174 .unwrap_or_else(|poisoned| poisoned.into_inner())
8175 .get(&lease_id)
8176 .map(ManagedWorktreeLeaseRecord::snapshot)
8177 {
8178 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8179 }
8180 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8181 }
8182 Ok(())
8183 }
8184
8185 async fn retire_unavailable_session_records(&self) {
8198 let policy = self.session_record_retention;
8199 let age_enabled = policy.age_ms > 0;
8200 let keep_enabled = policy.keep_per_workspace > 0;
8201 if !age_enabled && !keep_enabled {
8202 return;
8203 }
8204 let now = unix_time_ms();
8205 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8206 self.session_records
8207 .lock()
8208 .unwrap_or_else(|poisoned| poisoned.into_inner())
8209 .records
8210 .values()
8211 .filter(|record| record.state == ManagedSessionState::Unavailable)
8212 .map(|record| {
8213 (
8214 record.record_id.clone(),
8215 record.workspace_id.clone(),
8216 record.provider.clone(),
8217 record.updated_at_unix_ms,
8218 )
8219 })
8220 .collect()
8221 };
8222 if candidates.is_empty() {
8223 return;
8224 }
8225 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8226 BTreeMap::new();
8227 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8228 by_workspace
8229 .entry(workspace_id)
8230 .or_default()
8231 .push((record_id, provider, updated_at_unix_ms));
8232 }
8233 let reason = if age_enabled && keep_enabled {
8234 "age+keep-n"
8235 } else if age_enabled {
8236 "age"
8237 } else {
8238 "keep-n"
8239 };
8240 let mut retired: u64 = 0;
8241 for (workspace_id, mut records) in by_workspace {
8242 records.sort_by(|left, right| right.2.cmp(&left.2));
8245 for (index, (record_id, provider, updated_at_unix_ms)) in
8246 records.into_iter().enumerate()
8247 {
8248 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8249 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8250 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8251 let should_retire = if age_enabled && keep_enabled {
8252 age_hit && keep_hit
8253 } else {
8254 age_hit || keep_hit
8255 };
8256 if !should_retire {
8257 continue;
8258 }
8259 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8260 Ok(()) => {
8261 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8262 retired += 1;
8263 tracing::info!(
8264 node_id = %self.node_id,
8265 record_id = %record_id,
8266 workspace_id = %workspace_id,
8267 provider = %provider,
8268 age_ms = record_age_ms,
8269 reason,
8270 "managed session record retired"
8271 );
8272 }
8273 Err(error) => tracing::warn!(
8274 node_id = %self.node_id,
8275 record_id = %record_id,
8276 workspace_id = %workspace_id,
8277 code = ?error.code,
8278 cause = %error.message,
8279 "session record retention sweep failed to retire a record"
8280 ),
8281 }
8282 }
8283 }
8284 if retired > 0 {
8285 let remaining_unavailable = self
8286 .session_records
8287 .lock()
8288 .unwrap_or_else(|poisoned| poisoned.into_inner())
8289 .records
8290 .values()
8291 .filter(|record| record.state == ManagedSessionState::Unavailable)
8292 .count();
8293 tracing::info!(
8294 node_id = %self.node_id,
8295 retired,
8296 remaining_unavailable,
8297 "session record retention sweep completed"
8298 );
8299 }
8300 }
8301
8302 async fn resume_session_record(
8303 &self,
8304 record_id: &SessionRecordId,
8305 terminal_size: gate4agent_types::TerminalSize,
8306 initial_prompt: Option<String>,
8307 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8308 let record = self.record(record_id)?;
8309 if record.active_session.is_some()
8310 || matches!(
8311 record.state,
8312 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8313 )
8314 {
8315 return Err(failure(
8316 NodeFailureCode::SessionRecordBusy,
8317 "managed session already has a live runtime binding",
8318 ));
8319 }
8320 let identity = record.provider_session.clone().ok_or_else(|| {
8321 failure(
8322 NodeFailureCode::SessionRecordNotResumable,
8323 "managed session has no verified provider session identity",
8324 )
8325 })?;
8326 let working_directory = self.workspace_root(&record.workspace_id)?;
8327 if !platform::roots_equal(
8328 &working_directory,
8329 windows_path_text(&record.canonical_root),
8330 ) {
8331 return Err(failure(
8332 NodeFailureCode::SessionRecordConflict,
8333 "managed session workspace root changed; refusing to resume in another directory",
8334 ));
8335 }
8336 {
8337 let records = self
8338 .session_records
8339 .lock()
8340 .unwrap_or_else(|poisoned| poisoned.into_inner());
8341 if records.records.values().any(|candidate| {
8342 candidate.record_id != *record_id
8343 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8344 session_registry::same_provider_session(
8345 &candidate.provider,
8346 candidate_identity,
8347 &record.provider,
8348 &identity,
8349 )
8350 })
8351 && (candidate.active_session.is_some()
8352 || matches!(
8353 candidate.state,
8354 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8355 ))
8356 }) {
8357 return Err(failure(
8358 NodeFailureCode::SessionRecordConflict,
8359 "provider session is already active through another managed record",
8360 ));
8361 }
8362 }
8363 let runtime_requirement = if initial_prompt.is_some() {
8364 ProviderRuntimeRequirement::ResumeWithPrompt
8365 } else {
8366 ProviderRuntimeRequirement::Resume
8367 };
8368 let runtime_policy = self.admit_provider_runtime(
8369 &record.provider,
8370 runtime_requirement,
8371 )
8372 .await?;
8373 let request = ResumeLaunchRequest {
8374 working_directory,
8375 terminal_size,
8376 initial_prompt,
8377 };
8378 request
8379 .validate()
8380 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8381 self.ensure_binding_capacity()?;
8382 let resolved_materialization = match self.resolve_record_materialization(
8383 record_id,
8384 &record.provider,
8385 record.mode,
8386 record.environment_profile.as_ref(),
8387 record.bundle.as_ref(),
8388 record.context.as_ref(),
8389 ) {
8390 Ok(materialization) => materialization,
8391 Err(error) => {
8392 let label = match error.code {
8393 NodeFailureCode::UnknownBundle => "bundle-unavailable",
8394 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8395 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8396 _ => "environment-profile-unavailable",
8397 };
8398 self.mark_record_error(record_id, label)?;
8399 return Err(error);
8400 }
8401 };
8402 let (materialization_id, environment_overlay) = match resolved_materialization {
8403 Some((id, overlay)) => (Some(id), overlay),
8404 None => (None, None),
8405 };
8406 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8407 let mut environment_selection = match SpawnPreparer::select_environment(self,
8408 instance_id,
8409 &record.provider,
8410 record.mode,
8411 record.environment_profile.as_ref(),
8412 ) {
8413 Ok(selection) => selection,
8414 Err(error) => {
8415 self.mark_record_error(record_id, "environment-profile-unavailable")?;
8416 return Err(error);
8417 }
8418 };
8419 let mut instance_overlay = environment_overlay
8420 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8421 .transpose()?
8422 .flatten();
8423 if let Some(stale_address) = self.bound_address_for_record(record_id) {
8424 self.remove_session(&stale_address).await?;
8425 }
8426 let address = SessionAddress {
8427 workspace_id: record.workspace_id.clone(),
8428 session: SessionKey {
8429 instance_id,
8430 generation: SessionGeneration(1),
8431 },
8432 };
8433 let transaction = self
8434 .state_transaction
8435 .lock()
8436 .unwrap_or_else(|poisoned| poisoned.into_inner());
8437 let current = self.record(record_id)?;
8438 if current.active_session.is_some()
8439 || matches!(
8440 current.state,
8441 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8442 )
8443 {
8444 return Err(failure(
8445 NodeFailureCode::SessionRecordBusy,
8446 "managed session became active while resume was being prepared",
8447 ));
8448 }
8449 self.bind_managed_session_with_materialization(
8450 &address,
8451 record_id.clone(),
8452 runtime_policy,
8453 record.environment_profile.clone(),
8454 record.bundle.clone(),
8455 record.context.clone(),
8456 materialization_id,
8457 );
8458 if let Some(selection) = environment_selection.take() {
8459 selection.retain();
8460 }
8461 if let Some(overlay) = instance_overlay.take() {
8462 overlay.retain();
8463 }
8464 drop(transaction);
8465
8466 let transport = match record.mode {
8467 SessionMode::Pty => TransportKind::Pty,
8468 SessionMode::Inline => TransportKind::Pipe,
8469 SessionMode::Acp => TransportKind::Acp,
8470 };
8471 let agent_id = record.provider.clone();
8472 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8473 if let Err(error) = self
8474 .dispatch_bounded(
8475 ControlCommand::Register {
8476 instance_id,
8477 agent_id,
8478 transport,
8479 },
8480 dispatch_timeout,
8481 )
8482 .await
8483 {
8484 if let Some(binding) = self.remove_binding(&address) {
8485 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8486 }
8487 return Err(error);
8488 }
8489 let command = self.prepare_command(ControlCommand::Resume {
8490 instance_id,
8491 target: ResumeTarget::ProviderSession { identity },
8492 runtime_policy,
8493 request,
8494 });
8495 let started_after = self.current_sequence();
8496 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8497 self.rollback_spawn(&address, dispatch_timeout).await?;
8498 return Err(error);
8499 }
8500 if let Err(error) = self.dispatch_envelope(command) {
8501 self.clear_armed_resume(&address);
8502 self.rollback_spawn(&address, dispatch_timeout).await?;
8503 return Err(error);
8504 }
8505 match self
8506 .wait_for_record_resume(&address, started_after)
8507 .await
8508 {
8509 Ok(settled_address) => {
8510 let managed_lease_id = {
8511 self.managed_worktrees.lock()
8512 .unwrap_or_else(|poisoned| poisoned.into_inner())
8513 .lease_for_workspace(&record.workspace_id)
8514 };
8515 if let Some(lease_id) = managed_lease_id {
8516 {
8517 let mut bindings = self.session_bindings.lock()
8518 .unwrap_or_else(|poisoned| poisoned.into_inner());
8519 let binding = bindings.get_mut(&settled_address.session.instance_id)
8520 .ok_or_else(|| failure(
8521 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8522 "resumed managed session lost its exact binding",
8523 ))?;
8524 if binding.generation != settled_address.session.generation {
8525 return Err(failure(
8526 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8527 "resumed managed session generation diverged",
8528 ));
8529 }
8530 binding.managed_worktree_lease_id = Some(lease_id.clone());
8531 }
8532 let snapshot = self.managed_worktrees.lock()
8533 .unwrap_or_else(|poisoned| poisoned.into_inner())
8534 .bind_session(
8535 &lease_id,
8536 ManagedWorktreeSessionHolder {
8537 incarnation_id: self.incarnation_id,
8538 instance_id: settled_address.session.instance_id,
8539 generation: settled_address.session.generation,
8540 },
8541 Some(record_id.clone()),
8542 unix_time_ms(),
8543 ).map_err(|message| failure(
8544 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8545 &message,
8546 ))?;
8547 if let Err(error) = self.persist_state() {
8548 let _ = self.remove_session(&settled_address).await;
8549 return Err(persistence_failure(error));
8550 }
8551 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8552 }
8553 Ok((self.record(record_id)?, settled_address))
8554 }
8555 Err(error) => {
8556 if matches!(
8557 error.code,
8558 NodeFailureCode::SessionRecordNotResumable
8559 | NodeFailureCode::BackendOperationFailed
8560 ) {
8561 if let Some(bound) = self.bound_address_for_record(record_id) {
8562 let _ = self.remove_session(&bound).await;
8563 }
8564 }
8565 Err(error)
8566 }
8567 }
8568 }
8569
8570 async fn wait_for_record_resume(
8571 &self,
8572 initial_address: &SessionAddress,
8573 after_sequence: u64,
8574 ) -> Result<SessionAddress, NodeFailure> {
8575 let deadline = Instant::now()
8576 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8577 let mut scan_after = after_sequence;
8578 loop {
8579 let events = self
8580 .history
8581 .lock()
8582 .unwrap_or_else(|poisoned| poisoned.into_inner())
8583 .events
8584 .iter()
8585 .filter(|event| event.sequence > scan_after)
8586 .cloned()
8587 .collect::<Vec<_>>();
8588 for envelope in events {
8589 scan_after = scan_after.max(envelope.sequence);
8590 let NodeEvent::Control { address, event } = envelope.event else {
8591 continue;
8592 };
8593 if address.session.instance_id != initial_address.session.instance_id {
8594 continue;
8595 }
8596 match event.event {
8597 ControlEventKind::Resumed { .. } => return Ok(address),
8598 ControlEventKind::ResumeDenied { reason } => {
8599 return Err(failure(
8600 NodeFailureCode::SessionRecordNotResumable,
8601 &reason,
8602 ));
8603 }
8604 ControlEventKind::ResumeFailed { message }
8605 | ControlEventKind::CommandRejected { message } => {
8606 return Err(failure(
8607 NodeFailureCode::BackendOperationFailed,
8608 &message,
8609 ));
8610 }
8611 _ => {}
8612 }
8613 }
8614 if Instant::now() >= deadline {
8615 return Err(failure(
8616 NodeFailureCode::BackendBusy,
8617 "managed resume did not settle before the bounded deadline",
8618 ));
8619 }
8620 sleep(Duration::from_millis(5)).await;
8621 }
8622 }
8623
8624 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8625 self.workspaces
8626 .read()
8627 .unwrap_or_else(|poisoned| poisoned.into_inner())
8628 .get(workspace_id)
8629 .cloned()
8630 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8631 }
8632
8633 fn workspace_roots(&self) -> Vec<PathBuf> {
8634 self.workspaces
8635 .read()
8636 .unwrap_or_else(|poisoned| poisoned.into_inner())
8637 .values()
8638 .map(PathBuf::from)
8639 .collect()
8640 }
8641
8642 async fn inspect_workspace(
8643 &self,
8644 workspace_id: WorkspaceId,
8645 ) -> Result<WorkspaceInspection, NodeFailure> {
8646 let _permit = self
8647 .inspection_slots
8648 .clone()
8649 .try_acquire_owned()
8650 .map_err(|_| {
8651 tracing::warn!(
8652 workspace_id = %workspace_id,
8653 "workspace inspection rejected: inspection capacity is busy",
8654 );
8655 failure(
8656 NodeFailureCode::BackendBusy,
8657 "workspace inspection capacity is busy",
8658 )
8659 })?;
8660 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8661 tracing::warn!(
8662 workspace_id = %workspace_id,
8663 code = ?error.code,
8664 "workspace inspection rejected: workspace is not registered",
8665 );
8666 error
8667 })?;
8668 let tree_root = canonical_root.clone();
8669 let started_at = Instant::now();
8670 let time_budget_ms = workspace_inspection_time_budget_ms();
8671 let entry_cap = workspace_inspection_entry_cap();
8672 let deadline = started_at + Duration::from_millis(time_budget_ms);
8673 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8674 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8675 })
8676 .await
8677 .map_err(|error| {
8678 tracing::warn!(
8679 workspace_id = %workspace_id,
8680 error = %error,
8681 "workspace inspection rejected: walk task failed",
8682 );
8683 failure(
8684 NodeFailureCode::BackendOperationFailed,
8685 &format!("workspace inspection task failed: {error}"),
8686 )
8687 })?;
8688 let (mut git, git_time_budget_exceeded) =
8689 inspect_git_workspace(&canonical_root, deadline).await;
8690 if !git.worktrees.is_empty() {
8691 let registered = self
8692 .workspaces
8693 .read()
8694 .unwrap_or_else(|poisoned| poisoned.into_inner())
8695 .iter()
8696 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8697 .collect::<Vec<_>>();
8698 for worktree in &mut git.worktrees {
8699 worktree.workspace_id = registered
8700 .iter()
8701 .find(|(_, root)| {
8702 worktree_paths_equal(root, windows_path_text(&worktree.path))
8703 })
8704 .map(|(workspace_id, _)| workspace_id.clone());
8705 }
8706 }
8707 if git.is_repository {
8708 git.managed_worktree = self
8709 .managed_worktrees
8710 .lock()
8711 .unwrap_or_else(|poisoned| poisoned.into_inner())
8712 .git_scope(&workspace_id, git.branch.as_deref());
8713 }
8714 let truncation = workspace_inspection_truncation(
8715 &walk_budget,
8716 git_time_budget_exceeded,
8717 started_at.elapsed(),
8718 );
8719 if let Some(truncation) = &truncation {
8720 tracing::info!(
8721 workspace_id = %workspace_id,
8722 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8723 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8724 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8725 entries_visited = truncation.entries_visited,
8726 elapsed_ms = truncation.elapsed_ms,
8727 "workspace inspection truncated by its inner budget",
8728 );
8729 }
8730 Ok(WorkspaceInspection {
8731 workspace_id,
8732 entries,
8733 tree_truncated,
8734 git,
8735 truncation,
8736 })
8737 }
8738
8739 async fn browse_host_directories(
8740 &self,
8741 directory: Option<OpaqueHostPath>,
8742 after: Option<OpaqueHostPath>,
8743 ) -> Result<HostDirectoryListing, NodeFailure> {
8744 let permit = self
8745 .inspection_slots
8746 .clone()
8747 .try_acquire_owned()
8748 .map_err(|_| {
8749 failure(
8750 NodeFailureCode::BackendBusy,
8751 "host directory browse capacity is busy",
8752 )
8753 })?;
8754 let task = tokio::task::spawn_blocking(move || {
8755 let _permit = permit;
8756 browse_host_directories(directory, after)
8757 });
8758 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8759 .await
8760 .map_err(|_| {
8761 failure(
8762 NodeFailureCode::HostDirectoryReadTimedOut,
8763 "host directory browse exceeded its bounded deadline",
8764 )
8765 })?
8766 .map_err(|_| {
8767 failure(
8768 NodeFailureCode::HostDirectoryReadFailed,
8769 "host directory browse task failed",
8770 )
8771 })?
8772 .map_err(host_directory_failure)
8773 }
8774
8775 async fn read_workspace_file(
8776 &self,
8777 workspace_id: WorkspaceId,
8778 path: RepositoryPath,
8779 ) -> Result<WorkspaceFileRead, NodeFailure> {
8780 let canonical_root = self.workspace_root(&workspace_id)?;
8781 let permit = self
8782 .inspection_slots
8783 .clone()
8784 .try_acquire_owned()
8785 .map_err(|_| {
8786 failure(
8787 NodeFailureCode::BackendBusy,
8788 "workspace file read capacity is busy",
8789 )
8790 })?;
8791 let path_to_read = path.clone();
8792 let task = tokio::task::spawn_blocking(move || {
8793 let _permit = permit;
8794 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8795 });
8796 let content = timeout(
8797 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8798 task,
8799 )
8800 .await
8801 .map_err(|_| {
8802 failure(
8803 NodeFailureCode::RepositoryFileReadTimedOut,
8804 "repository file read exceeded its bounded deadline",
8805 )
8806 })?
8807 .map_err(|_| {
8808 failure(
8809 NodeFailureCode::RepositoryFileReadFailed,
8810 "repository file read task failed",
8811 )
8812 })?
8813 .map_err(workspace_file_failure)?;
8814 let content = match content {
8815 WorkspaceFileBytes::Utf8(text) => {
8816 let revision = Some(workspace_file_revision(text.as_bytes()));
8817 return Ok(WorkspaceFileRead {
8818 workspace_id,
8819 path,
8820 content: WorkspaceFileContent::Utf8 {
8821 byte_len: u32::try_from(text.len())
8822 .expect("bounded workspace text length must fit u32"),
8823 text,
8824 },
8825 revision,
8826 });
8827 }
8828 WorkspaceFileBytes::NonUtf8 { byte_length } => {
8829 WorkspaceFileContent::NonUtf8 {
8830 byte_len: u32::try_from(byte_length)
8831 .expect("bounded workspace file length must fit u32"),
8832 }
8833 }
8834 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8835 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8836 },
8837 };
8838 Ok(WorkspaceFileRead {
8839 workspace_id,
8840 path,
8841 content,
8842 revision: None,
8843 })
8844 }
8845
8846 async fn write_workspace_file(
8847 &self,
8848 workspace_id: WorkspaceId,
8849 path: RepositoryPath,
8850 expected_revision: WorkspaceFileRevision,
8851 text: String,
8852 ) -> Result<WorkspaceFileRead, NodeFailure> {
8853 let canonical_root = self.workspace_root(&workspace_id)?;
8854 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8855 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8856 })?;
8857 let path_to_write = path.clone();
8858 let expected = expected_revision.as_str().to_owned();
8859 let text_to_write = text.clone();
8860 let task = tokio::task::spawn_blocking(move || {
8861 let _permit = permit;
8862 write_workspace_file_to_disk(
8863 Path::new(&canonical_root),
8864 &path_to_write,
8865 &expected,
8866 &text_to_write,
8867 )
8868 });
8869 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8870 .await
8871 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8872 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8873 .map_err(workspace_file_write_failure)?;
8874 Ok(WorkspaceFileRead {
8875 workspace_id,
8876 path,
8877 content: WorkspaceFileContent::Utf8 {
8878 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8879 text,
8880 },
8881 revision: Some(WorkspaceFileRevision::new(revision)
8882 .expect("workspace writer returns a valid SHA-256 revision")),
8883 })
8884 }
8885
8886 async fn create_workspace_file(
8887 &self,
8888 workspace_id: WorkspaceId,
8889 path: RepositoryPath,
8890 ) -> Result<WorkspaceFileRead, NodeFailure> {
8891 let canonical_root = self.workspace_root(&workspace_id)?;
8892 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8893 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8894 })?;
8895 let path_to_create = path.clone();
8896 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8897 let task_commit_state = Arc::clone(&commit_state);
8898 let task = tokio::task::spawn_blocking(move || {
8899 let _permit = permit;
8900 create_workspace_file_on_disk(
8901 Path::new(&canonical_root),
8902 &path_to_create,
8903 &task_commit_state,
8904 )
8905 });
8906 let revision = settle_workspace_entry_create(
8907 task,
8908 commit_state,
8909 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8910 "repository file create exceeded its bounded deadline",
8911 "repository file create task failed",
8912 )
8913 .await?;
8914 Ok(WorkspaceFileRead {
8915 workspace_id,
8916 path,
8917 content: WorkspaceFileContent::Utf8 {
8918 text: String::new(),
8919 byte_len: 0,
8920 },
8921 revision: Some(WorkspaceFileRevision::new(revision)
8922 .expect("workspace creator returns a valid SHA-256 revision")),
8923 })
8924 }
8925
8926 async fn create_workspace_directory(
8927 &self,
8928 workspace_id: WorkspaceId,
8929 path: RepositoryPath,
8930 ) -> Result<WorkspaceEntry, NodeFailure> {
8931 let canonical_root = self.workspace_root(&workspace_id)?;
8932 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8933 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8934 })?;
8935 let path_to_create = path.clone();
8936 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8937 let task_commit_state = Arc::clone(&commit_state);
8938 let task = tokio::task::spawn_blocking(move || {
8939 let _permit = permit;
8940 create_workspace_directory_on_disk(
8941 Path::new(&canonical_root),
8942 &path_to_create,
8943 &task_commit_state,
8944 )
8945 });
8946 settle_workspace_entry_create(
8947 task,
8948 commit_state,
8949 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8950 "repository directory create exceeded its bounded deadline",
8951 "repository directory create task failed",
8952 )
8953 .await?;
8954 Ok(WorkspaceEntry {
8955 relative_path: path,
8956 kind: WorkspaceEntryKind::Directory,
8957 })
8958 }
8959
8960 async fn read_git_history(
8961 &self,
8962 workspace_id: WorkspaceId,
8963 path: Option<RepositoryPath>,
8964 before: Option<GitObjectId>,
8965 limit: u16,
8966 ) -> Result<GitHistoryPage, NodeFailure> {
8967 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8968 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
8969 })?;
8970 let root = self.workspace_root(&workspace_id)?;
8971 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
8972 }
8973
8974 async fn read_git_diff(
8975 &self,
8976 workspace_id: WorkspaceId,
8977 request: GitDiffRequest,
8978 ) -> Result<GitDiff, NodeFailure> {
8979 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8980 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
8981 })?;
8982 let root = self.workspace_root(&workspace_id)?;
8983 read_git_diff_bounded(&root, request).await
8984 }
8985
8986 fn native_session_route_root(
8987 &self,
8988 route: &NativeSessionCatalogRoute,
8989 ) -> Result<Option<String>, NodeFailure> {
8990 route
8991 .validate()
8992 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8993 match route.scope {
8994 gate4agent_types::NativeSessionCatalogScope::Workspace => route
8995 .workspace_id
8996 .as_ref()
8997 .map(|workspace_id| self.workspace_root(workspace_id))
8998 .transpose(),
8999 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9000 }
9001 }
9002
9003 fn project_native_session_entries(
9004 &self,
9005 route: &NativeSessionCatalogRoute,
9006 entries: Vec<ScopedNativeSessionCatalogEntry>,
9007 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9008 let current_root = route
9009 .workspace_id
9010 .as_ref()
9011 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9012 let records = self
9013 .session_records
9014 .lock()
9015 .unwrap_or_else(|poisoned| poisoned.into_inner());
9016 let mut seen_records = std::collections::HashSet::new();
9017 entries
9018 .into_iter()
9019 .map(|entry| {
9020 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9021 let matches = records
9022 .records
9023 .values()
9024 .filter(|record| {
9025 record.provider == route.provider
9026 && record.workspace_id == *workspace_id
9027 && current_root.as_deref().is_some_and(|root| {
9028 platform::roots_equal(
9029 root,
9030 windows_path_text(&record.canonical_root),
9031 )
9032 })
9033 && record.provider_session.as_ref().is_some_and(|identity| {
9034 session_registry::same_provider_session(
9035 &record.provider,
9036 identity,
9037 &route.provider,
9038 &entry.provider_session,
9039 )
9040 })
9041 })
9042 .map(|record| record.record_id.clone())
9043 .collect::<Vec<_>>();
9044 (matches.len() == 1
9045 && seen_records.insert(matches[0].clone()))
9046 .then(|| matches[0].clone())
9047 });
9048 let metadata = entry.metadata;
9049 crate::protocol::NativeSessionCatalogEntry {
9050 selection_id: metadata.selection_id,
9051 title: metadata.title,
9052 modified_at_unix_ms: metadata.modified_at_unix_ms,
9053 model: metadata.model,
9054 message_count: metadata.message_count,
9055 completed_turn_count: metadata.completed_turn_count,
9056 external_group: entry.external_group,
9057 record_id,
9058 }
9059 })
9060 .collect()
9061 }
9062
9063 async fn catalog_native_sessions(
9064 &self,
9065 route: NativeSessionCatalogRoute,
9066 limit: u16,
9067 ) -> Result<(
9068 Vec<crate::protocol::NativeSessionCatalogEntry>,
9069 crate::protocol::NativeSessionCatalogSummary,
9070 ), NodeFailure> {
9071 let canonical_root = self.native_session_route_root(&route)?;
9072 let registered_workspace_roots = self.workspace_roots();
9073 let provider = route.provider.clone();
9074 let scope = route.scope;
9075 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9076 failure(
9077 NodeFailureCode::InvalidRequest,
9078 "native session catalog is not configured",
9079 )
9080 })?;
9081 let _permit = Arc::clone(&self.inspection_slots)
9082 .try_acquire_owned()
9083 .map_err(|_| {
9084 failure(
9085 NodeFailureCode::BackendBusy,
9086 "native session catalog capacity is busy",
9087 )
9088 })?;
9089 let page = run_native_session_catalog_operation(
9090 catalog,
9091 "native session catalog",
9092 move |catalog| {
9093 catalog.catalog_initial_for_scope(
9094 &provider,
9095 scope,
9096 canonical_root.as_deref().map(Path::new),
9097 ®istered_workspace_roots,
9098 limit,
9099 )
9100 },
9101 )
9102 .await?;
9103 let summary = crate::protocol::NativeSessionCatalogSummary {
9104 catalog_revision: page.revision,
9105 recent_cutoff_unix_ms: page.cutoff_unix_ms,
9106 recent_total_count: page.recent_total_count,
9107 older_total_count: page.older_total_count,
9108 recent_next_after_selection_id: page.next_after_selection_id,
9109 recent_has_more: page.remaining_count > 0,
9110 };
9111 Ok((self.project_native_session_entries(&route, page.entries), summary))
9112 }
9113
9114 async fn page_native_sessions(
9115 &self,
9116 route: NativeSessionCatalogRoute,
9117 window: crate::protocol::NativeSessionCatalogWindow,
9118 catalog_revision: u64,
9119 recent_cutoff_unix_ms: u64,
9120 after_selection_id: Option<String>,
9121 limit: u16,
9122 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9123 let canonical_root = self.native_session_route_root(&route)?;
9124 let registered_workspace_roots = self.workspace_roots();
9125 let provider = route.provider.clone();
9126 let scope = route.scope;
9127 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9128 failure(
9129 NodeFailureCode::InvalidRequest,
9130 "native session catalog is not configured",
9131 )
9132 })?;
9133 let _permit = Arc::clone(&self.inspection_slots)
9134 .try_acquire_owned()
9135 .map_err(|_| {
9136 failure(
9137 NodeFailureCode::BackendBusy,
9138 "native session catalog capacity is busy",
9139 )
9140 })?;
9141 let page = run_native_session_catalog_operation(
9142 catalog,
9143 "native session catalog page",
9144 move |catalog| {
9145 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9146 &provider,
9147 scope,
9148 canonical_root.as_deref().map(Path::new),
9149 ®istered_workspace_roots,
9150 window,
9151 catalog_revision,
9152 recent_cutoff_unix_ms,
9153 after_selection_id.as_deref(),
9154 limit,
9155 ))
9156 },
9157 )
9158 .await?;
9159 let page = page.map_err(|error| match error {
9160 NativeSessionCatalogError::StaleCatalog => failure(
9161 NodeFailureCode::StaleNativeSessionCatalog,
9162 "native session catalog revision or cursor is stale",
9163 ),
9164 _ => failure(
9165 NodeFailureCode::BackendOperationFailed,
9166 "native session catalog page failed",
9167 ),
9168 })?;
9169 let entries = self.project_native_session_entries(&route, page.entries);
9170 Ok(crate::protocol::NativeSessionCatalogPage {
9171 window: page.window,
9172 revision: page.revision,
9173 entries,
9174 next_after_selection_id: page.next_after_selection_id,
9175 remaining_count: page.remaining_count,
9176 has_more: page.remaining_count > 0,
9177 })
9178 }
9179
9180 async fn preview_native_session(
9181 &self,
9182 selection: NativeSessionSelection,
9183 message_limit: u16,
9184 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9185 selection
9186 .validate()
9187 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9188 let canonical_root = self.native_session_route_root(&selection.route)?;
9189 let registered_workspace_roots = self.workspace_roots();
9190 let provider = selection.route.provider.clone();
9191 let scope = selection.route.scope;
9192 let catalog_revision = selection.catalog_revision;
9193 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9194 let selection_id = selection.selection_id.clone();
9195 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9196 failure(
9197 NodeFailureCode::InvalidRequest,
9198 "native session preview is not configured",
9199 )
9200 })?;
9201 let _permit = Arc::clone(&self.inspection_slots)
9202 .try_acquire_owned()
9203 .map_err(|_| {
9204 failure(
9205 NodeFailureCode::BackendBusy,
9206 "native session preview capacity is busy",
9207 )
9208 })?;
9209 let preview = run_native_session_catalog_operation(
9210 catalog,
9211 "native session preview",
9212 move |catalog| {
9213 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9214 &provider,
9215 scope,
9216 canonical_root.as_deref().map(Path::new),
9217 ®istered_workspace_roots,
9218 catalog_revision,
9219 recent_cutoff_unix_ms,
9220 &selection_id,
9221 message_limit,
9222 ))
9223 },
9224 )
9225 .await?;
9226 preview
9227 .map(Into::into)
9228 .map_err(native_session_preview_failure)
9229 }
9230
9231 async fn index_native_session(
9232 &self,
9233 selection: NativeSessionSelection,
9234 display_name: String,
9235 ) -> Result<ManagedSessionRecord, NodeFailure> {
9236 selection
9237 .validate()
9238 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9239 if selection.route.scope
9240 != gate4agent_types::NativeSessionCatalogScope::Workspace
9241 {
9242 return Err(failure(
9243 NodeFailureCode::WorkspaceRegistrationRequired,
9244 "register the external project as a workspace before indexing or resuming it",
9245 ));
9246 }
9247 let workspace_id = selection
9248 .route
9249 .workspace_id
9250 .clone()
9251 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9252 let canonical_root = self
9253 .native_session_route_root(&selection.route)?
9254 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9255 let registered_workspace_roots = self.workspace_roots();
9256 let provider = selection.route.provider.clone();
9257 let catalog_revision = selection.catalog_revision;
9258 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9259 let selection_id = selection.selection_id.clone();
9260 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9261 failure(
9262 NodeFailureCode::InvalidRequest,
9263 "native session index is not configured",
9264 )
9265 })?;
9266 let _permit = Arc::clone(&self.inspection_slots)
9267 .try_acquire_owned()
9268 .map_err(|_| {
9269 failure(
9270 NodeFailureCode::BackendBusy,
9271 "native session index capacity is busy",
9272 )
9273 })?;
9274 let resolved = run_native_session_catalog_operation(
9275 catalog,
9276 "native session index resolution",
9277 move |catalog| {
9278 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9279 &provider,
9280 gate4agent_types::NativeSessionCatalogScope::Workspace,
9281 Some(Path::new(&canonical_root)),
9282 ®istered_workspace_roots,
9283 catalog_revision,
9284 recent_cutoff_unix_ms,
9285 &selection_id,
9286 ))
9287 },
9288 )
9289 .await?
9290 .map_err(native_session_preview_failure)?;
9291 self.index_provider_session_with_policy(
9292 workspace_id,
9293 selection.route.provider,
9294 resolved.identity,
9295 display_name,
9296 true,
9297 )
9298 }
9299
9300 async fn preview_native_session_by_session_id(
9301 &self,
9302 workspace_id: WorkspaceId,
9303 provider: AgentId,
9304 session_id: String,
9305 message_limit: u16,
9306 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9307 let canonical_root = self.workspace_root(&workspace_id)?;
9308 let registered_workspace_roots = self.workspace_roots();
9309 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9310 failure(
9311 NodeFailureCode::InvalidRequest,
9312 "native session preview is not configured",
9313 )
9314 })?;
9315 let _permit = Arc::clone(&self.inspection_slots)
9316 .try_acquire_owned()
9317 .map_err(|_| {
9318 failure(
9319 NodeFailureCode::BackendBusy,
9320 "native session preview capacity is busy",
9321 )
9322 })?;
9323 run_native_session_catalog_operation(
9324 catalog,
9325 "native session preview",
9326 move |catalog| {
9327 catalog.preview_session_id_for_workspace(
9328 &provider,
9329 Path::new(&canonical_root),
9330 ®istered_workspace_roots,
9331 &session_id,
9332 message_limit,
9333 )
9334 },
9335 )
9336 .await
9337 }
9338
9339 async fn register_workspace(
9340 &self,
9341 workspace_id: WorkspaceId,
9342 root: String,
9343 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9344 validate_workspace_request_root(&root)?;
9345 let canonicalize_workspace_id = workspace_id.clone();
9346 let workspace = tokio::task::spawn_blocking(move || {
9347 WorkspaceConfig::new(canonicalize_workspace_id, root)
9348 })
9349 .await
9350 .map_err(|error| {
9351 failure(
9352 NodeFailureCode::BackendOperationFailed,
9353 &format!("workspace canonicalization task failed: {error}"),
9354 )
9355 })?
9356 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9357 let transaction = self
9358 .state_transaction
9359 .lock()
9360 .unwrap_or_else(|poisoned| poisoned.into_inner());
9361 let mut workspaces = self
9362 .workspaces
9363 .write()
9364 .unwrap_or_else(|poisoned| poisoned.into_inner());
9365 if workspaces.contains_key(&workspace_id) {
9366 return Err(failure(
9367 NodeFailureCode::DuplicateWorkspaceId,
9368 "workspace ID is already registered",
9369 ));
9370 }
9371 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9372 platform::roots_equal(existing_root, workspace.canonical_root())
9373 }) {
9374 return Err(failure(
9375 NodeFailureCode::DuplicateWorkspaceRoot,
9376 &format!("workspace root is already registered as '{existing_id}'"),
9377 ));
9378 }
9379 {
9380 let records = self
9381 .session_records
9382 .lock()
9383 .unwrap_or_else(|poisoned| poisoned.into_inner());
9384 if let Some(record) = records.records.values().find(|record| {
9385 record.workspace_id == workspace_id
9386 && !record
9387 .canonical_root
9388 .as_utf8()
9389 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9390 }) {
9391 return Err(failure(
9392 NodeFailureCode::SessionRecordConflict,
9393 &format!(
9394 "workspace ID is retained by session record '{}' at another root",
9395 record.record_id,
9396 ),
9397 ));
9398 }
9399 }
9400 let snapshot = WorkspaceSnapshot {
9401 workspace_id: workspace_id.clone(),
9402 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9403 sessions: Vec::new(),
9404 worktree_service_mode: Some(WorktreeServiceMode::Manual),
9405 managed_worktree_profiles: Some(WorktreeProfileInventory {
9406 profiles: Vec::new(),
9407 }),
9408 };
9409 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9410 drop(workspaces);
9411 let previous_records = {
9412 let mut records = self
9413 .session_records
9414 .lock()
9415 .unwrap_or_else(|poisoned| poisoned.into_inner());
9416 let previous = records.records.clone();
9417 for record in records.records.values_mut().filter(|record| {
9418 record.workspace_id == workspace_id
9419 && record
9420 .canonical_root
9421 .as_utf8()
9422 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9423 }) {
9424 if record.active_session.is_none() {
9425 record.state = detached_record_state(record);
9426 }
9427 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9428 record.last_error = None;
9429 }
9430 record.updated_at_unix_ms = unix_time_ms();
9431 }
9432 previous
9433 };
9434 if let Err(error) = self.persist_state_locked() {
9435 self.workspaces
9436 .write()
9437 .unwrap_or_else(|poisoned| poisoned.into_inner())
9438 .remove(&workspace_id);
9439 self.session_records
9440 .lock()
9441 .unwrap_or_else(|poisoned| poisoned.into_inner())
9442 .records = previous_records;
9443 return Err(persistence_failure(error));
9444 }
9445 let restored = self
9446 .session_records
9447 .lock()
9448 .unwrap_or_else(|poisoned| poisoned.into_inner())
9449 .records
9450 .values()
9451 .filter(|record| record.workspace_id == workspace_id)
9452 .cloned()
9453 .collect::<Vec<_>>();
9454 drop(transaction);
9455 self.publish(NodeEvent::WorkspaceAdded {
9456 workspace: snapshot.clone(),
9457 });
9458 for record in restored {
9459 self.publish_record(record);
9460 }
9461 Ok(snapshot)
9462 }
9463
9464 async fn create_standalone_workspace(
9465 &self,
9466 workspace_id: WorkspaceId,
9467 root: String,
9468 initial_branch: Option<String>,
9469 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9470 validate_workspace_request_root(&root)?;
9471 validate_git_revision("initial branch", initial_branch.as_deref())?;
9472 let preflight_workspace_id = workspace_id.clone();
9473 let preflight_root = root.clone();
9474 let canonical_candidate = tokio::task::spawn_blocking(move || {
9475 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9476 })
9477 .await
9478 .map_err(|_| failure(
9479 NodeFailureCode::BackendOperationFailed,
9480 "standalone workspace root preflight task failed",
9481 ))?
9482 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9483 {
9484 let workspaces = self
9485 .workspaces
9486 .read()
9487 .unwrap_or_else(|poisoned| poisoned.into_inner());
9488 if workspaces.contains_key(&workspace_id) {
9489 return Err(failure(
9490 NodeFailureCode::DuplicateWorkspaceId,
9491 "workspace ID is already registered",
9492 ));
9493 }
9494 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9495 platform::roots_equal(existing_root, &canonical_candidate)
9496 }) {
9497 return Err(failure(
9498 NodeFailureCode::DuplicateWorkspaceRoot,
9499 &format!("workspace root is already registered as '{existing_id}'"),
9500 ));
9501 }
9502 }
9503 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9504 .await
9505 .map_err(standalone_workspace_failure)?;
9506 prepared
9507 .verify_for_registration()
9508 .await
9509 .map_err(standalone_workspace_failure)?;
9510 match self
9511 .register_workspace(workspace_id, prepared.root().to_owned())
9512 .await
9513 {
9514 Ok(workspace) => Ok(workspace),
9515 Err(registration_error) => {
9516 if prepared.compensate_registration_failure().await {
9517 Err(registration_error)
9518 } else {
9519 Err(failure(
9520 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9521 "standalone workspace initialization succeeded but registration failed; recovery is required",
9522 ))
9523 }
9524 }
9525 }
9526 }
9527
9528 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9529 let bindings = self
9530 .session_bindings
9531 .lock()
9532 .unwrap_or_else(|poisoned| poisoned.into_inner());
9533 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9534 return Err(failure(
9535 NodeFailureCode::WorkspaceBusy,
9536 "workspace retains a bound session; remove every session before unregistering it",
9537 ));
9538 }
9539 drop(bindings);
9540 let transaction = self
9541 .state_transaction
9542 .lock()
9543 .unwrap_or_else(|poisoned| poisoned.into_inner());
9544 let mut workspaces = self
9545 .workspaces
9546 .write()
9547 .unwrap_or_else(|poisoned| poisoned.into_inner());
9548 if !workspaces.contains_key(workspace_id) {
9549 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9550 }
9551 if workspaces.len() == 1 {
9552 return Err(failure(
9553 NodeFailureCode::LastWorkspace,
9554 "node must retain at least one workspace",
9555 ));
9556 }
9557 let removed_root = workspaces
9558 .remove(workspace_id)
9559 .expect("workspace presence was validated");
9560 drop(workspaces);
9561 let previous_records = {
9562 let mut records = self
9563 .session_records
9564 .lock()
9565 .unwrap_or_else(|poisoned| poisoned.into_inner());
9566 let previous = records.records.clone();
9567 for record in records
9568 .records
9569 .values_mut()
9570 .filter(|record| &record.workspace_id == workspace_id)
9571 {
9572 record.active_session = None;
9573 record.state = ManagedSessionState::Unavailable;
9574 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9575 record.updated_at_unix_ms = unix_time_ms();
9576 }
9577 previous
9578 };
9579 if let Err(error) = self.persist_state_locked() {
9580 self.workspaces
9581 .write()
9582 .unwrap_or_else(|poisoned| poisoned.into_inner())
9583 .insert(workspace_id.clone(), removed_root);
9584 self.session_records
9585 .lock()
9586 .unwrap_or_else(|poisoned| poisoned.into_inner())
9587 .records = previous_records;
9588 return Err(persistence_failure(error));
9589 }
9590 let unavailable = self
9591 .session_records
9592 .lock()
9593 .unwrap_or_else(|poisoned| poisoned.into_inner())
9594 .records
9595 .values()
9596 .filter(|record| &record.workspace_id == workspace_id)
9597 .cloned()
9598 .collect::<Vec<_>>();
9599 drop(transaction);
9600 self.publish(NodeEvent::WorkspaceRemoved {
9601 workspace_id: workspace_id.clone(),
9602 });
9603 for record in unavailable {
9604 self.publish_record(record);
9605 }
9606 Ok(())
9607 }
9608
9609 async fn create_worktree(
9610 &self,
9611 source_workspace_id: WorkspaceId,
9612 workspace_id: WorkspaceId,
9613 target_root: String,
9614 branch: String,
9615 base: Option<String>,
9616 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9617 self.require_worktree_service(&source_workspace_id)?;
9618 validate_workspace_request_root(&target_root)?;
9619 validate_git_revision("worktree base", base.as_deref())?;
9620 let source_root = self.workspace_root(&source_workspace_id)?;
9621 {
9622 let workspaces = self
9623 .workspaces
9624 .read()
9625 .unwrap_or_else(|poisoned| poisoned.into_inner());
9626 if workspaces.contains_key(&workspace_id) {
9627 return Err(failure(
9628 NodeFailureCode::DuplicateWorkspaceId,
9629 "workspace ID is already registered",
9630 ));
9631 }
9632 if let Some((existing_id, _)) = workspaces
9633 .iter()
9634 .find(|(_, root)| worktree_paths_equal(root, &target_root))
9635 {
9636 return Err(failure(
9637 NodeFailureCode::DuplicateWorkspaceRoot,
9638 &format!("workspace root is already registered as '{existing_id}'"),
9639 ));
9640 }
9641 }
9642 let mut worktree = create_git_worktree(
9643 &source_root,
9644 &target_root,
9645 &branch,
9646 base.as_deref(),
9647 )
9648 .await
9649 .map_err(git_worktree_failure)?;
9650 let workspace = match self
9651 .register_workspace(workspace_id, worktree.path.clone())
9652 .await
9653 {
9654 Ok(workspace) => workspace,
9655 Err(registration_error) => {
9656 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9657 return Err(failure(
9658 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9659 "worktree registration failed and compensation requires recovery",
9660 ));
9661 }
9662 return Err(failure(
9663 registration_error.code,
9664 ®istration_error.message,
9665 ));
9666 }
9667 };
9668 worktree.workspace_id = Some(workspace.workspace_id.clone());
9669 Ok((protocol_worktree(worktree), workspace))
9670 }
9671
9672 async fn remove_worktree(
9673 &self,
9674 source_workspace_id: WorkspaceId,
9675 target_root: String,
9676 ) -> Result<Option<WorkspaceId>, NodeFailure> {
9677 self.require_worktree_service(&source_workspace_id)?;
9678 validate_workspace_request_root(&target_root)?;
9679 let source_root = self.workspace_root(&source_workspace_id)?;
9680 let target_root = normalize_worktree_removal_target(&target_root)
9681 .map_err(git_worktree_failure)?;
9682 let target = list_git_worktrees(&source_root)
9683 .await
9684 .map_err(git_worktree_failure)?
9685 .into_iter()
9686 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9687 .ok_or_else(|| {
9688 failure(
9689 NodeFailureCode::WorktreeProtected,
9690 "refusing to remove a path that is not in Git's worktree listing",
9691 )
9692 })?;
9693 let registered_workspace_id = {
9694 let workspaces = self
9695 .workspaces
9696 .read()
9697 .unwrap_or_else(|poisoned| poisoned.into_inner());
9698 let matched = workspaces
9699 .iter()
9700 .find(|(_, root)| worktree_paths_equal(root, &target.path))
9701 .map(|(workspace_id, _)| workspace_id.clone());
9702 if matched.is_some() && workspaces.len() == 1 {
9703 return Err(failure(
9704 NodeFailureCode::LastWorkspace,
9705 "node must retain at least one workspace",
9706 ));
9707 }
9708 matched
9709 };
9710 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9711 let bindings = self
9712 .session_bindings
9713 .lock()
9714 .unwrap_or_else(|poisoned| poisoned.into_inner());
9715 if bindings
9716 .values()
9717 .any(|binding| &binding.workspace_id == workspace_id)
9718 {
9719 return Err(failure(
9720 NodeFailureCode::WorkspaceBusy,
9721 "worktree workspace retains a bound session; remove every session first",
9722 ));
9723 }
9724 }
9725 remove_git_worktree(&source_root, &target.path)
9726 .await
9727 .map_err(git_worktree_failure)?;
9728 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9729 self.unregister_workspace(workspace_id)
9730 .map_err(|error| failure(error.code, &error.message))?;
9731 }
9732 Ok(registered_workspace_id)
9733 }
9734
9735 async fn cleanup_managed_worktree(
9736 &self,
9737 lease_id: &ManagedWorktreeLeaseId,
9738 explicit: bool,
9739 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9740 let lease = self.managed_worktrees.lock()
9741 .unwrap_or_else(|poisoned| poisoned.into_inner())
9742 .get(lease_id)
9743 .cloned()
9744 .ok_or_else(|| failure(
9745 NodeFailureCode::UnknownManagedWorktreeLease,
9746 "managed worktree lease does not exist",
9747 ))?;
9748 if lease.state == ManagedWorktreeLeaseState::Removed {
9749 return Ok(lease.snapshot());
9750 }
9751 if lease.has_holders() {
9752 return Err(failure(
9753 NodeFailureCode::ManagedWorktreeBusy,
9754 "managed worktree lease still has a session or durable-record holder",
9755 ));
9756 }
9757 if self
9758 .materializations
9759 .lock()
9760 .unwrap_or_else(|poisoned| poisoned.into_inner())
9761 .values()
9762 .any(|record| record.managed_lease_id() == Some(lease_id))
9763 {
9764 return Err(failure(
9765 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9766 "managed worktree cleanup is blocked by session environment ownership",
9767 ));
9768 }
9769 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9770 let snapshot = {
9771 let mut registry = self.managed_worktrees.lock()
9772 .unwrap_or_else(|poisoned| poisoned.into_inner());
9773 let current = registry.get_mut(lease_id)
9774 .expect("managed worktree lease remains present");
9775 current.state = ManagedWorktreeLeaseState::Retained;
9776 current.cleanup_failure = None;
9777 current.updated_at_unix_ms = unix_time_ms();
9778 current.snapshot()
9779 };
9780 if self.persist_state().is_err() {
9781 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9782 }
9783 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9784 return Ok(snapshot);
9785 }
9786 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9787 if profile.revision() != &lease.profile_revision {
9788 self.mark_managed_recovery_required(
9789 lease_id,
9790 ManagedWorktreeCleanupFailure::OwnershipConflict,
9791 );
9792 return Err(failure(
9793 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9794 "managed worktree profile revision changed",
9795 ));
9796 }
9797 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9798 self.mark_managed_recovery_required(
9799 lease_id,
9800 ManagedWorktreeCleanupFailure::OwnershipConflict,
9801 );
9802 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9803 })?;
9804 let source_root = self.workspace_root(&lease.source_workspace_id)?;
9805 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9806 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9807 managed_git_worktree_failure(error)
9808 })?;
9809 let target = listed.iter()
9810 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9811 if let Some(target) = target {
9812 if target.is_main || target.is_bare || !exact_owned_worktree(
9813 &lease,
9814 &target.path,
9815 target.branch.as_deref(),
9816 &target.head,
9817 ) {
9818 self.mark_managed_recovery_required(
9819 lease_id,
9820 ManagedWorktreeCleanupFailure::OwnershipConflict,
9821 );
9822 return Err(failure(
9823 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9824 "Git worktree identity no longer matches the managed lease",
9825 ));
9826 }
9827 if target.prunable {
9828 self.mark_managed_cleanup_blocked(
9829 lease_id,
9830 ManagedWorktreeCleanupFailure::Prunable,
9831 );
9832 return Err(failure(
9833 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9834 "managed worktree is prunable and requires recovery",
9835 ));
9836 }
9837 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9838 let failure_kind = match error.kind {
9839 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9840 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9841 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9842 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9843 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9844 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9845 };
9846 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9847 self.mark_managed_recovery_required(lease_id, failure_kind);
9848 } else {
9849 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9850 }
9851 return Err(managed_git_worktree_failure(error));
9852 }
9853 } else {
9854 match std::fs::symlink_metadata(&lease.target_root) {
9855 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9856 Ok(_) => {
9857 self.mark_managed_recovery_required(
9858 lease_id,
9859 ManagedWorktreeCleanupFailure::OwnershipConflict,
9860 );
9861 return Err(failure(
9862 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9863 "managed target exists but is not owned by Git",
9864 ));
9865 }
9866 Err(_) => {
9867 self.mark_managed_cleanup_blocked(
9868 lease_id,
9869 ManagedWorktreeCleanupFailure::Backend,
9870 );
9871 return Err(failure(
9872 NodeFailureCode::BackendOperationFailed,
9873 "managed target identity could not be inspected",
9874 ));
9875 }
9876 }
9877 }
9878 let registered = self.workspaces.read()
9879 .unwrap_or_else(|poisoned| poisoned.into_inner())
9880 .contains_key(&lease.workspace_id);
9881 if registered {
9882 self.unregister_workspace(&lease.workspace_id)?;
9883 }
9884 let removed = {
9885 self.managed_worktrees.lock()
9886 .unwrap_or_else(|poisoned| poisoned.into_inner())
9887 .tombstone(lease_id, unix_time_ms())
9888 .expect("managed worktree lease remains present during cleanup")
9889 };
9890 self.persist_state().map_err(persistence_failure)?;
9891 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9892 Ok(removed.snapshot())
9893 }
9894
9895 fn mark_managed_cleanup_blocked(
9896 &self,
9897 lease_id: &ManagedWorktreeLeaseId,
9898 failure_kind: ManagedWorktreeCleanupFailure,
9899 ) {
9900 let snapshot = {
9901 let mut registry = self.managed_worktrees.lock()
9902 .unwrap_or_else(|poisoned| poisoned.into_inner());
9903 let Some(lease) = registry.get_mut(lease_id) else { return };
9904 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9905 lease.cleanup_failure = Some(failure_kind);
9906 lease.updated_at_unix_ms = unix_time_ms();
9907 lease.snapshot()
9908 };
9909 let _ = self.persist_state();
9910 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9911 }
9912
9913 fn require_worktree_service(
9914 &self,
9915 source_workspace_id: &WorkspaceId,
9916 ) -> Result<(), NodeFailure> {
9917 if self.managed_worktrees.lock()
9918 .unwrap_or_else(|poisoned| poisoned.into_inner())
9919 .lease_for_workspace(source_workspace_id)
9920 .is_some()
9921 {
9922 return Err(failure(
9923 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9924 "managed worktree targets are internal-only workspaces",
9925 ));
9926 }
9927 match self
9928 .worktree_service_modes
9929 .get(source_workspace_id)
9930 .copied()
9931 .unwrap_or(WorktreeServiceMode::Manual)
9932 {
9933 WorktreeServiceMode::Manual => Ok(()),
9934 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
9935 NodeFailureCode::UnsupportedSpawnCapability,
9936 "Git worktree service mode is unavailable in this node build",
9937 )),
9938 }
9939 }
9940
9941 fn managed_reservation_conflict(
9942 &self,
9943 workspace_id: Option<&WorkspaceId>,
9944 root: Option<&str>,
9945 ) -> bool {
9946 self.managed_worktrees.lock()
9947 .unwrap_or_else(|poisoned| poisoned.into_inner())
9948 .active_records()
9949 .any(|lease| {
9950 workspace_id.is_some_and(|workspace_id| {
9951 workspace_id == &lease.source_workspace_id
9952 || workspace_id == &lease.workspace_id
9953 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
9954 })
9955 }
9956
9957 fn reject_managed_reservation(
9958 &self,
9959 workspace_id: Option<&WorkspaceId>,
9960 root: Option<&str>,
9961 ) -> Result<(), NodeFailure> {
9962 if self.managed_reservation_conflict(workspace_id, root) {
9963 Err(failure(
9964 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9965 "managed worktree ownership may only be mutated through its lease cleanup",
9966 ))
9967 } else {
9968 Ok(())
9969 }
9970 }
9971
9972 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
9973 self.validate_address(address)?;
9974 self.workspace_root(&address.workspace_id)
9975 }
9976
9977 fn require_session_runtime_policy(
10011 &self,
10012 address: &SessionAddress,
10013 requirement: ProviderRuntimeRequirement,
10014 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10015 let runtime_policy = {
10016 let bindings = self
10017 .session_bindings
10018 .lock()
10019 .unwrap_or_else(|poisoned| poisoned.into_inner());
10020 let binding = bindings
10021 .get(&address.session.instance_id)
10022 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10023 if binding.workspace_id != address.workspace_id {
10024 return Err(failure(
10025 NodeFailureCode::SessionWorkspaceMismatch,
10026 "session belongs to another workspace",
10027 ));
10028 }
10029 if binding.generation != address.session.generation {
10030 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10031 }
10032 binding.runtime_policy
10033 };
10034 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10035 let snapshot = self.internal_session_snapshot(address)?;
10036 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10037 && snapshot.provider.lead_activity != ProviderActivity::Idle
10038 {
10039 return Err(failure(
10040 NodeFailureCode::TurnInFlight,
10041 "session already has a turn in flight; wait for it to complete before sending another prompt",
10042 ));
10043 }
10044 }
10045 require_policy(runtime_policy, false, false, requirement)
10046 .map(|()| runtime_policy)
10047 .map_err(|_| failure(
10048 NodeFailureCode::UnsupportedCapability,
10049 "session runtime policy does not admit this semantic operation",
10050 ))
10051 }
10052
10053 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10054 let mut bindings = self
10055 .session_bindings
10056 .lock()
10057 .unwrap_or_else(|poisoned| poisoned.into_inner());
10058 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10059 binding.workspace_id == address.workspace_id
10060 && binding.generation == address.session.generation
10061 }) {
10062 let removed = bindings.remove(&address.session.instance_id);
10063 self.clear_terminal_frame_watermark(address);
10064 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10065 return removed;
10066 }
10067 None
10068 }
10069
10070 fn cleanup_session_owned_materialization(
10071 &self,
10072 address: &SessionAddress,
10073 binding: &SessionBinding,
10074 ) -> Result<(), NodeFailure> {
10075 let Some(id) = binding.materialization_id.as_ref() else {
10076 return Ok(());
10077 };
10078 let owner = self
10079 .materializations
10080 .lock()
10081 .unwrap_or_else(|poisoned| poisoned.into_inner())
10082 .get(id)
10083 .map(|record| record.owner().clone());
10084 match owner {
10085 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10086 Some(MaterializationOwner::Session {
10087 incarnation_id,
10088 instance_id,
10089 generation,
10090 }) if incarnation_id == self.incarnation_id
10091 && instance_id == address.session.instance_id
10092 && generation == address.session.generation =>
10093 {
10094 self.cleanup_materialization(id)
10095 }
10096 Some(MaterializationOwner::Session { .. }) => Err(failure(
10097 NodeFailureCode::BackendOperationFailed,
10098 "session environment ownership requires recovery",
10099 )),
10100 }
10101 }
10102
10103 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10104 let mut watermarks = self
10105 .terminal_frame_watermarks
10106 .lock()
10107 .unwrap_or_else(|poisoned| poisoned.into_inner());
10108 if watermarks
10109 .get(&address.session.instance_id)
10110 .is_some_and(|(current, _)| current == address)
10111 {
10112 watermarks.remove(&address.session.instance_id);
10113 }
10114 }
10115
10116 #[cfg(test)]
10117 fn address_for(
10118 &self,
10119 instance_id: AgentInstanceId,
10120 generation: SessionGeneration,
10121 ) -> Option<SessionAddress> {
10122 let binding = self.session_bindings
10123 .lock()
10124 .unwrap_or_else(|poisoned| poisoned.into_inner())
10125 .get(&instance_id)
10126 .cloned()?;
10127 (binding.generation == generation).then_some(SessionAddress {
10128 workspace_id: binding.workspace_id,
10129 session: SessionKey { instance_id, generation },
10130 })
10131 }
10132
10133 fn arm_resume(
10134 &self,
10135 address: &SessionAddress,
10136 command_id: CommandId,
10137 runtime_policy: ProviderRuntimePolicy,
10138 ) -> Result<(), NodeFailure> {
10139 let mut bindings = self
10140 .session_bindings
10141 .lock()
10142 .unwrap_or_else(|poisoned| poisoned.into_inner());
10143 let binding = bindings
10144 .get_mut(&address.session.instance_id)
10145 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10146 if binding.workspace_id != address.workspace_id {
10147 return Err(failure(
10148 NodeFailureCode::SessionWorkspaceMismatch,
10149 "session belongs to another workspace",
10150 ));
10151 }
10152 if binding.generation != address.session.generation {
10153 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10154 }
10155 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10156 Ok(())
10157 }
10158
10159 fn clear_armed_resume(&self, address: &SessionAddress) {
10160 let mut bindings = self
10161 .session_bindings
10162 .lock()
10163 .unwrap_or_else(|poisoned| poisoned.into_inner());
10164 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10165 if binding.workspace_id == address.workspace_id
10166 && binding.generation == address.session.generation
10167 {
10168 binding.pending_resume = None;
10169 }
10170 }
10171 }
10172
10173 fn publish_control(&self, event: ControlEvent) {
10174 let revokes_harness_mcp = matches!(
10175 &event.event,
10176 ControlEventKind::Exited { .. }
10177 | ControlEventKind::Failed { .. }
10178 | ControlEventKind::Removed
10179 );
10180 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10181 let mut bindings = self
10182 .session_bindings
10183 .lock()
10184 .unwrap_or_else(|poisoned| poisoned.into_inner());
10185 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10186 return;
10187 };
10188 let previous_address = SessionAddress {
10189 workspace_id: binding.workspace_id.clone(),
10190 session: SessionKey {
10191 instance_id: event.instance_id,
10192 generation: binding.generation,
10193 },
10194 };
10195 let mut resume_command_rejected = false;
10196 if binding.generation == event.generation {
10197 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10198 let rejected_resume = matches!(
10199 &event.event,
10200 ControlEventKind::CommandRejected { .. }
10201 ) && event.command_id == Some(pending_command_id);
10202 let resume_failed = matches!(
10203 &event.event,
10204 ControlEventKind::ResumeDenied { .. }
10205 | ControlEventKind::ResumeFailed { .. }
10206 );
10207 if pending_generation == binding.generation
10208 && (rejected_resume || resume_failed)
10209 {
10210 resume_command_rejected = rejected_resume;
10211 binding.pending_resume = None;
10212 }
10213 }
10214 } else {
10215 let expected = binding.generation.0.checked_add(1);
10216 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10217 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10218 generation == binding.generation
10219 })
10220 || expected != Some(event.generation.0)
10221 || !authorized
10222 {
10223 return;
10224 }
10225 let (_, _, runtime_policy) = binding
10226 .pending_resume
10227 .expect("authorized resume retains its pending runtime policy");
10228 binding.generation = event.generation;
10229 binding.runtime_policy = runtime_policy;
10230 binding.pending_resume = None;
10231 }
10232 (
10233 SessionAddress {
10234 workspace_id: binding.workspace_id.clone(),
10235 session: SessionKey {
10236 instance_id: event.instance_id,
10237 generation: event.generation,
10238 },
10239 },
10240 (previous_address.session.generation != event.generation)
10241 .then_some(previous_address),
10242 binding.record_id.clone(),
10243 binding.runtime_policy.provider_session_identity,
10244 resume_command_rejected,
10245 )
10246 };
10247 if let Some(previous_address) = previous_address.as_ref() {
10248 self.clear_terminal_frame_watermark(previous_address);
10249 }
10250 if let Some(record_id) = record_id {
10251 self.reconcile_managed_record(
10252 &record_id,
10253 &address,
10254 &event,
10255 identity_admitted,
10256 resume_command_rejected,
10257 );
10258 }
10259 match &event.event {
10267 ControlEventKind::Running { process_id } => {
10268 tracing::info!(
10269 node_id = %self.node_id,
10270 workspace_id = %address.workspace_id,
10271 session = ?address.session,
10272 pid = ?process_id,
10273 "session process is running",
10274 );
10275 }
10276 ControlEventKind::Exited { exit_code, forced } => {
10277 tracing::info!(
10278 node_id = %self.node_id,
10279 workspace_id = %address.workspace_id,
10280 session = ?address.session,
10281 exit_code = ?exit_code,
10282 forced = forced,
10283 "session process exited",
10284 );
10285 }
10286 ControlEventKind::Failed { message } => {
10287 tracing::warn!(
10288 node_id = %self.node_id,
10289 workspace_id = %address.workspace_id,
10290 session = ?address.session,
10291 cause = %message,
10292 "session failed",
10293 );
10294 }
10295 ControlEventKind::CommandRejected { message } => {
10296 tracing::warn!(
10297 node_id = %self.node_id,
10298 workspace_id = %address.workspace_id,
10299 session = ?address.session,
10300 cause = %message,
10301 "session command rejected",
10302 );
10303 }
10304 ControlEventKind::Removed => {
10305 tracing::info!(
10306 node_id = %self.node_id,
10307 workspace_id = %address.workspace_id,
10308 session = ?address.session,
10309 "session removed",
10310 );
10311 }
10312 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10313 tracing::info!(
10314 node_id = %self.node_id,
10315 workspace_id = %address.workspace_id,
10316 session = ?address.session,
10317 provider_session = ?provider_session,
10318 "session resume authorized",
10319 );
10320 }
10321 ControlEventKind::Resumed { session: provider_session, process_id } => {
10322 tracing::info!(
10323 node_id = %self.node_id,
10324 workspace_id = %address.workspace_id,
10325 session = ?address.session,
10326 provider_session = ?provider_session,
10327 pid = ?process_id,
10328 "session resumed",
10329 );
10330 }
10331 ControlEventKind::ResumeDenied { reason } => {
10332 tracing::warn!(
10333 node_id = %self.node_id,
10334 workspace_id = %address.workspace_id,
10335 session = ?address.session,
10336 cause = %reason,
10337 "session resume denied",
10338 );
10339 }
10340 ControlEventKind::ResumeFailed { message } => {
10341 tracing::warn!(
10342 node_id = %self.node_id,
10343 workspace_id = %address.workspace_id,
10344 session = ?address.session,
10345 cause = %message,
10346 "session resume failed",
10347 );
10348 }
10349 _ => {}
10350 }
10351 let stream_chunk = agent_stream_chunk(&event);
10352 self.publish(NodeEvent::Control {
10353 address: address.clone(),
10354 event,
10355 });
10356 if let Some(chunk) = stream_chunk {
10357 self.publish_agent_stream_chunk(address.clone(), chunk);
10358 }
10359 if revokes_harness_mcp {
10360 SessionLifecycleHook::on_session_ended(self, &address);
10361 }
10362 }
10363
10364 fn reconcile_managed_record(
10365 &self,
10366 record_id: &SessionRecordId,
10367 address: &SessionAddress,
10368 event: &ControlEvent,
10369 identity_admitted: bool,
10370 resume_command_rejected: bool,
10371 ) {
10372 let _transaction = self
10373 .state_transaction
10374 .lock()
10375 .unwrap_or_else(|poisoned| poisoned.into_inner());
10376 let observed_identity = match &event.event {
10377 ControlEventKind::ProviderEvent {
10378 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10379 ..
10380 } if identity_admitted => Some(identity.clone()),
10381 _ => None,
10382 };
10383 let identity_was_observed = observed_identity.is_some();
10384 let replacement_id = observed_identity
10385 .as_ref()
10386 .and_then(|_| self.allocate_record_id().ok());
10387 let mut upserts = Vec::new();
10388 let mut removals = Vec::new();
10389 let mut rebind = None;
10390 let original_records;
10391 let original_materializations = self
10392 .materializations
10393 .lock()
10394 .unwrap_or_else(|poisoned| poisoned.into_inner())
10395 .clone();
10396 {
10397 let mut records = self
10398 .session_records
10399 .lock()
10400 .unwrap_or_else(|poisoned| poisoned.into_inner());
10401 original_records = records.records.clone();
10402 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10403 return;
10404 };
10405 if let Some(identity) = observed_identity {
10406 let existing_id = records
10407 .records
10408 .values()
10409 .find(|record| {
10410 record.record_id != *record_id
10411 && record.provider_session.as_ref().is_some_and(|record_identity| {
10412 session_registry::same_provider_session(
10413 &record.provider,
10414 record_identity,
10415 ¤t_snapshot.provider,
10416 &identity,
10417 )
10418 })
10419 })
10420 .map(|record| record.record_id.clone());
10421 if let Some(existing_id) = existing_id {
10422 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10423 record.mode == current_snapshot.mode
10424 && record.workspace_id == current_snapshot.workspace_id
10425 && record.environment_profile == current_snapshot.environment_profile
10426 && record.bundle == current_snapshot.bundle
10427 && record.context_id == current_snapshot.context_id
10428 && record.context == current_snapshot.context
10429 && record
10430 .canonical_root
10431 .as_utf8()
10432 .zip(current_snapshot.canonical_root.as_utf8())
10433 .is_some_and(|(left, right)| platform::roots_equal(left, right))
10434 });
10435 if !scope_matches {
10436 if let Some(current) = records.records.get_mut(record_id) {
10437 current.state = ManagedSessionState::Unavailable;
10438 current.last_error =
10439 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10440 current.updated_at_unix_ms = unix_time_ms();
10441 upserts.push(current.clone());
10442 }
10443 } else {
10444 let existing_is_active = records
10445 .records
10446 .get(&existing_id)
10447 .is_some_and(|record| record.active_session.is_some());
10448 if existing_is_active {
10449 if let Some(current) = records.records.get_mut(record_id) {
10450 current.state = ManagedSessionState::Unavailable;
10451 current.last_error =
10452 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10453 current.updated_at_unix_ms = unix_time_ms();
10454 upserts.push(current.clone());
10455 }
10456 } else {
10457 if current_snapshot.provider_session.is_some() {
10458 if let Some(current) = records.records.get_mut(record_id) {
10459 current.active_session = None;
10460 current.state = ManagedSessionState::Dormant;
10461 current.updated_at_unix_ms = unix_time_ms();
10462 upserts.push(current.clone());
10463 }
10464 } else {
10465 records.records.remove(record_id);
10466 removals.push(record_id.clone());
10467 }
10468 if let Some(existing) = records.records.get_mut(&existing_id) {
10469 existing.provider_session = Some(identity.clone());
10470 existing.active_session = Some(address.clone());
10471 existing.state = ManagedSessionState::Live;
10472 existing.updated_at_unix_ms = unix_time_ms();
10473 existing.last_error = None;
10474 upserts.push(existing.clone());
10475 }
10476 rebind = Some(existing_id);
10477 }
10478 }
10479 } else if current_snapshot
10480 .provider_session
10481 .as_ref()
10482 .is_some_and(|current| {
10483 !session_registry::same_provider_session(
10484 ¤t_snapshot.provider,
10485 current,
10486 ¤t_snapshot.provider,
10487 &identity,
10488 )
10489 })
10490 {
10491 if let Some(current) = records.records.get_mut(record_id) {
10492 current.active_session = None;
10493 if current.bundle.is_some() || current.context.is_some() {
10494 let had_bundle = current.bundle.is_some();
10495 current.provider_session = None;
10496 current.bundle = None;
10497 current.context_id = None;
10498 current.context = None;
10499 current.state = ManagedSessionState::Unavailable;
10500 current.last_error = Some(if had_bundle {
10501 "bundle-unavailable".to_owned()
10502 } else {
10503 "context-unavailable".to_owned()
10504 });
10505 } else {
10506 current.state = ManagedSessionState::Dormant;
10507 }
10508 current.updated_at_unix_ms = unix_time_ms();
10509 upserts.push(current.clone());
10510 }
10511 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10512 if let Some(current) = records.records.get_mut(record_id) {
10513 current.state = ManagedSessionState::Unavailable;
10514 current.last_error =
10515 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10516 current.updated_at_unix_ms = unix_time_ms();
10517 upserts.push(current.clone());
10518 }
10519 } else if let Some(new_record_id) = replacement_id {
10520 let now = unix_time_ms();
10521 let replacement = ManagedSessionRecord {
10522 record_id: new_record_id.clone(),
10523 display_name: format!(
10524 "{} #{}",
10525 current_snapshot.provider.as_str(),
10526 records
10527 .records
10528 .values()
10529 .filter(|record| {
10530 record.provider == current_snapshot.provider
10531 })
10532 .count()
10533 .saturating_add(1),
10534 ),
10535 provider: current_snapshot.provider.clone(),
10536 mode: current_snapshot.mode,
10537 state: ManagedSessionState::Live,
10538 workspace_id: current_snapshot.workspace_id,
10539 canonical_root: current_snapshot.canonical_root,
10540 provider_session: Some(identity),
10541 active_session: Some(address.clone()),
10542 environment_profile: current_snapshot.environment_profile,
10543 bundle: current_snapshot.bundle,
10544 context_id: current_snapshot.context_id,
10545 context: current_snapshot.context,
10546 exported_context: None,
10552 task_binding: current_snapshot.task_binding,
10553 created_at_unix_ms: now,
10554 updated_at_unix_ms: now,
10555 last_error: None,
10556 };
10557 records
10558 .records
10559 .insert(new_record_id.clone(), replacement.clone());
10560 upserts.push(replacement);
10561 rebind = Some(new_record_id);
10562 } else if let Some(current) = records.records.get_mut(record_id) {
10563 current.state = ManagedSessionState::Unavailable;
10564 current.last_error =
10565 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10566 upserts.push(current.clone());
10567 }
10568 } else if let Some(current) = records.records.get_mut(record_id) {
10569 current.provider_session = Some(identity);
10570 current.active_session = Some(address.clone());
10571 current.state = ManagedSessionState::Live;
10572 current.updated_at_unix_ms = unix_time_ms();
10573 current.last_error = None;
10574 upserts.push(current.clone());
10575 }
10576 } else if let Some(current) = records.records.get_mut(record_id) {
10577 if resume_command_rejected {
10578 current.active_session = None;
10579 current.state = detached_record_state(current);
10580 current.updated_at_unix_ms = unix_time_ms();
10581 current.last_error =
10582 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10583 upserts.push(current.clone());
10584 } else {
10585 match &event.event {
10586 ControlEventKind::Running { .. }
10587 | ControlEventKind::ResumeAuthorized { .. }
10588 | ControlEventKind::Resumed { .. } => {
10589 current.active_session = Some(address.clone());
10590 current.state = active_record_state(
10591 identity_admitted,
10592 current.provider_session.is_some(),
10593 );
10594 current.updated_at_unix_ms = unix_time_ms();
10595 current.last_error = None;
10596 upserts.push(current.clone());
10597 }
10598 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10599 current.active_session = None;
10600 current.state = detached_record_state(current);
10601 current.updated_at_unix_ms = unix_time_ms();
10602 upserts.push(current.clone());
10603 }
10604 ControlEventKind::Failed { message }
10605 | ControlEventKind::ResumeFailed { message } => {
10606 current.active_session = None;
10607 current.state = detached_record_state(current);
10608 current.updated_at_unix_ms = unix_time_ms();
10609 current.last_error = Some(
10610 session_registry::sanitized_record_error_summary(message),
10611 );
10612 upserts.push(current.clone());
10613 }
10614 ControlEventKind::ResumeDenied { reason } => {
10615 current.active_session = None;
10616 current.state = detached_record_state(current);
10617 current.updated_at_unix_ms = unix_time_ms();
10618 current.last_error = Some(
10619 session_registry::sanitized_record_error_summary(reason),
10620 );
10621 upserts.push(current.clone());
10622 }
10623 _ => {}
10624 }
10625 }
10626 }
10627 }
10628 let materialization_owner_record = identity_was_observed.then(|| {
10629 rebind.clone().unwrap_or_else(|| record_id.clone())
10630 }).and_then(|candidate| {
10631 self.session_records
10632 .lock()
10633 .unwrap_or_else(|poisoned| poisoned.into_inner())
10634 .records
10635 .get(&candidate)
10636 .filter(|record| {
10637 record.provider_session.is_some()
10638 && record.active_session.as_ref() == Some(address)
10639 })
10640 .map(|_| candidate)
10641 });
10642 if let Some(owner_record_id) = materialization_owner_record {
10643 let materialization_id = self
10644 .session_bindings
10645 .lock()
10646 .unwrap_or_else(|poisoned| poisoned.into_inner())
10647 .get(&address.session.instance_id)
10648 .and_then(|binding| binding.materialization_id.clone());
10649 if let Some(materialization_id) = materialization_id {
10650 let mut transfer_failed = false;
10651 {
10652 let mut materializations = self
10653 .materializations
10654 .lock()
10655 .unwrap_or_else(|poisoned| poisoned.into_inner());
10656 if let Some(ownership) = materializations.get_mut(&materialization_id) {
10657 match ownership.owner() {
10658 MaterializationOwner::Session {
10659 incarnation_id,
10660 instance_id,
10661 generation,
10662 } if *incarnation_id == self.incarnation_id
10663 && *instance_id == address.session.instance_id
10664 && *generation == address.session.generation =>
10665 {
10666 transfer_failed = ownership
10667 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10668 .is_err();
10669 }
10670 MaterializationOwner::Record { record_id }
10671 if record_id == &owner_record_id => {}
10672 MaterializationOwner::Record { record_id } => {
10673 let expected = record_id.clone();
10674 transfer_failed = ownership
10675 .transfer_record_owner(
10676 &expected,
10677 owner_record_id.clone(),
10678 unix_time_ms(),
10679 )
10680 .is_err();
10681 }
10682 _ => transfer_failed = true,
10683 }
10684 if transfer_failed {
10685 let _ = ownership.mark_recovery_required(unix_time_ms());
10686 }
10687 } else {
10688 transfer_failed = true;
10689 }
10690 }
10691 if transfer_failed {
10692 if let Some(record) = self
10693 .session_records
10694 .lock()
10695 .unwrap_or_else(|poisoned| poisoned.into_inner())
10696 .records
10697 .get_mut(&owner_record_id)
10698 {
10699 record.state = ManagedSessionState::Unavailable;
10700 record.last_error = Some("environment-profile-unavailable".to_owned());
10701 record.updated_at_unix_ms = unix_time_ms();
10702 upserts.push(record.clone());
10703 }
10704 }
10705 }
10706 }
10707 if let Some(new_record_id) = rebind.clone() {
10708 if let Some(binding) = self
10709 .session_bindings
10710 .lock()
10711 .unwrap_or_else(|poisoned| poisoned.into_inner())
10712 .get_mut(&address.session.instance_id)
10713 {
10714 binding.record_id = Some(new_record_id);
10715 }
10716 }
10717 if upserts.is_empty() && removals.is_empty() {
10718 return;
10719 }
10720 if self.persist_state_locked().is_err() {
10721 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10722 let mut records = self
10723 .session_records
10724 .lock()
10725 .unwrap_or_else(|poisoned| poisoned.into_inner());
10726 records.records = original_records;
10727 *self
10728 .materializations
10729 .lock()
10730 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10731 if let Some(current) = records.records.get_mut(record_id) {
10732 current.state = ManagedSessionState::Unavailable;
10733 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10734 current.updated_at_unix_ms = unix_time_ms();
10735 upserts = vec![current.clone()];
10736 } else {
10737 upserts.clear();
10738 }
10739 removals.clear();
10740 drop(records);
10741 if let Some(binding) = self
10742 .session_bindings
10743 .lock()
10744 .unwrap_or_else(|poisoned| poisoned.into_inner())
10745 .get_mut(&address.session.instance_id)
10746 {
10747 binding.record_id = Some(record_id.clone());
10748 }
10749 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10750 }
10751 for record in upserts {
10752 self.publish_record(record);
10753 }
10754 for record_id in removals {
10755 self.publish(NodeEvent::SessionRecordRemoved { record_id });
10756 }
10757 }
10758
10759 fn snapshot(&self) -> NodeSnapshot {
10760 let control = self.handle.snapshot();
10761 let bindings = self
10762 .session_bindings
10763 .lock()
10764 .unwrap_or_else(|poisoned| poisoned.into_inner())
10765 .clone();
10766 let workspace_roots = self
10767 .workspaces
10768 .read()
10769 .unwrap_or_else(|poisoned| poisoned.into_inner())
10770 .clone();
10771 let environment_profiles = self
10772 .environment_profiles
10773 .read()
10774 .unwrap_or_else(|poisoned| poisoned.into_inner());
10775 let mut workspaces = workspace_roots
10776 .iter()
10777 .map(|(workspace_id, canonical_root)| {
10778 (
10779 workspace_id.clone(),
10780 WorkspaceSnapshot {
10781 workspace_id: workspace_id.clone(),
10782 canonical_root: opaque_windows_path(canonical_root.clone()),
10783 sessions: Vec::new(),
10784 worktree_service_mode: Some(
10785 self.worktree_service_modes
10786 .get(workspace_id)
10787 .copied()
10788 .unwrap_or(WorktreeServiceMode::Manual),
10789 ),
10790 managed_worktree_profiles: Some(WorktreeProfileInventory {
10791 profiles: self
10792 .managed_worktree_profiles
10793 .get(workspace_id)
10794 .into_iter()
10795 .flat_map(BTreeMap::values)
10796 .map(|profile| ManagedWorktreeProfileSummary {
10797 id: profile.profile_id().clone(),
10798 revision: profile.revision().clone(),
10799 retention: profile.retention(),
10800 })
10801 .collect(),
10802 }),
10803 },
10804 )
10805 })
10806 .collect::<BTreeMap<_, _>>();
10807 let mut agent_progress = Vec::new();
10808 for session in &control.sessions {
10809 let Some(binding) = bindings.get(&session.instance_id) else {
10810 continue;
10811 };
10812 if binding.generation != session.generation {
10813 continue;
10814 }
10815 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10816 let address = SessionAddress {
10817 workspace_id: binding.workspace_id.clone(),
10818 session: SessionKey {
10819 instance_id: session.instance_id,
10820 generation: session.generation,
10821 },
10822 };
10823 if let Some(progress) =
10824 agent_progress_from_provider_snapshot(address, &session.provider)
10825 {
10826 agent_progress.push(progress);
10827 }
10828 workspace.sessions.push(session.clone());
10829 }
10830 }
10831 NodeSnapshot {
10832 node_id: self.node_id.clone(),
10833 enabled_providers: self.enabled_providers.clone(),
10834 provider_runtime_statuses: {
10835 let updates = self
10836 .provider_runtime_status_updates
10837 .read()
10838 .unwrap_or_else(|poisoned| poisoned.into_inner());
10839 ProviderRuntimeStatuses::new(
10840 self.provider_runtime_statuses
10841 .iter()
10842 .filter(|status| !updates.contains_key(status.provider()))
10843 .cloned()
10844 .chain(updates.values().cloned()),
10845 )
10846 .expect("refreshed provider runtime inventory remains bounded and unique")
10847 },
10848 workspaces: workspaces.into_values().collect(),
10849 session_records: self
10850 .session_records
10851 .lock()
10852 .unwrap_or_else(|poisoned| poisoned.into_inner())
10853 .records
10854 .values()
10855 .cloned()
10856 .collect(),
10857 managed_worktrees: self.managed_worktrees
10858 .lock()
10859 .unwrap_or_else(|poisoned| poisoned.into_inner())
10860 .snapshots(),
10861 launch_inventory: Some(LaunchInventory {
10862 spawn_profiles: Some(
10863 self.spawn_profiles
10864 .iter()
10865 .filter_map(|profile| {
10866 resolved_spawn_profile_summary(profile, &environment_profiles)
10867 })
10868 .collect(),
10869 ),
10870 bundles: Some(
10871 self.bundle_catalog
10872 .read()
10873 .unwrap_or_else(|poisoned| poisoned.into_inner())
10874 .iter()
10875 .map(NodeBundle::receipt)
10876 .collect(),
10877 ),
10878 network_allowlists: {
10881 let catalog = self
10882 .network_allowlist_catalog
10883 .read()
10884 .unwrap_or_else(|poisoned| poisoned.into_inner());
10885 if catalog.is_empty() {
10886 None
10887 } else {
10888 Some(catalog.ids().cloned().collect())
10889 }
10890 },
10891 }),
10892 agent_progress,
10893 }
10894 }
10895
10896 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
10897 assert!(
10898 !matches!(
10899 &event,
10900 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
10901 ),
10902 "terminal frame and agent stream events must use their own dedicated live channel",
10903 );
10904 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10905 let sequence = history
10906 .last_sequence
10907 .checked_add(1)
10908 .expect("node event sequence exhausted");
10909 if history.events.len() == NODE_EVENT_HISTORY_MAX {
10910 if let Some(evicted) = history.events.pop_front() {
10911 history.replay_floor_sequence = evicted
10912 .sequence
10913 .checked_add(1)
10914 .expect("node replay floor sequence exhausted");
10915 history.removed_record_providers.remove(&evicted.sequence);
10916 }
10917 }
10918 match &event {
10919 NodeEvent::HarnessMcpReadCall { .. } => {
10920 panic!("transient harness MCP calls must not enter durable event history")
10921 }
10922 NodeEvent::SessionRecordUpserted { record } => {
10923 history
10924 .record_providers
10925 .insert(record.record_id.clone(), record.provider.clone());
10926 }
10927 NodeEvent::SessionRecordRemoved { record_id } => {
10928 if let Some(provider) = history.record_providers.remove(record_id) {
10929 history.removed_record_providers.insert(sequence, provider);
10930 }
10931 }
10932 NodeEvent::ManagedWorktreeUpserted { .. }
10933 | NodeEvent::ManagedWorktreeRemoved { .. }
10934 | NodeEvent::Control { .. }
10935 | NodeEvent::ControllerChanged { .. }
10936 | NodeEvent::SessionRecordHistorySummarized { .. }
10937 | NodeEvent::WorkspaceAdded { .. }
10938 | NodeEvent::WorkspaceRemoved { .. }
10939 | NodeEvent::ResyncRequired { .. }
10940 | NodeEvent::AgentStream { .. }
10941 | NodeEvent::TerminalFrame { .. } => {}
10942 }
10943 let envelope = NodeEventEnvelope { sequence, event };
10944 history.events.push_back(envelope.clone());
10945 history.last_sequence = sequence;
10946 let _ = self.event_tx.send(envelope.clone());
10947 drop(history);
10948 envelope
10949 }
10950
10951 fn publish_transient(&self, event: NodeEvent) -> bool {
10962 assert!(event.requires_harness_mcp_proxy_capability());
10963 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
10964 let envelope = NodeEventEnvelope { sequence: 0, event };
10965 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
10966 Ok(_) => true,
10967 Err(broadcast::error::SendError(undelivered)) => {
10968 if let Some(NodeEventEnvelope {
10969 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
10970 ..
10971 }) = undelivered.first() {
10972 tracing::warn!(
10978 call_id = call_id.as_str(),
10979 content_type = ?request.content_type,
10980 cause = "no-subscribers",
10981 "harness MCP proxy event could not be published",
10982 );
10983 }
10984 false
10985 }
10986 }
10987 }
10988
10989 fn publish_terminal_frames(&self) {
10990 let snapshot = self.handle.snapshot();
10991 let bindings = self
10992 .session_bindings
10993 .lock()
10994 .unwrap_or_else(|poisoned| poisoned.into_inner());
10995 let candidates = snapshot
10996 .sessions
10997 .iter()
10998 .filter_map(|session| {
10999 let binding = bindings.get(&session.instance_id)?;
11000 if binding.generation != session.generation {
11001 return None;
11002 }
11003 let frame = session.terminal_frame.clone()?;
11004 Some((
11005 SessionAddress {
11006 workspace_id: binding.workspace_id.clone(),
11007 session: SessionKey {
11008 instance_id: session.instance_id,
11009 generation: session.generation,
11010 },
11011 },
11012 frame,
11013 ))
11014 })
11015 .collect();
11016 drop(bindings);
11017 self.publish_terminal_frame_candidates(candidates);
11018 }
11019
11020 fn publish_terminal_frame_candidates(
11021 &self,
11022 candidates: Vec<(SessionAddress, TerminalFrame)>,
11023 ) {
11024 let mut watermarks = self
11025 .terminal_frame_watermarks
11026 .lock()
11027 .unwrap_or_else(|poisoned| poisoned.into_inner());
11028 let mut events = Vec::with_capacity(candidates.len());
11029 for (address, frame) in candidates {
11030 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11031 let advanced = watermarks
11032 .get(&address.session.instance_id)
11033 .map_or(true, |(current_address, sequence)| {
11034 current_address != &address || frame.sequence > *sequence
11035 });
11036 if !advanced {
11037 continue;
11038 }
11039 if is_first_frame {
11040 if self.controller_state().is_none() {
11054 tracing::warn!(
11055 node_id = %self.node_id,
11056 workspace_id = %address.workspace_id,
11057 session = ?address.session,
11058 terminal_size = ?frame.size,
11059 "session producing output with no controller attached",
11060 );
11061 } else {
11062 tracing::info!(
11063 node_id = %self.node_id,
11064 workspace_id = %address.workspace_id,
11065 session = ?address.session,
11066 terminal_size = ?frame.size,
11067 "session produced its first output",
11068 );
11069 }
11070 }
11071 watermarks.insert(
11072 address.session.instance_id,
11073 (address.clone(), frame.sequence),
11074 );
11075 if let Some((at, reported)) = self
11076 .pty_input_marks
11077 .lock()
11078 .unwrap_or_else(|poisoned| poisoned.into_inner())
11079 .get_mut(&address.session.instance_id)
11080 {
11081 if !*reported && at.elapsed() < Duration::from_secs(5) {
11083 *reported = true;
11084 tracing::debug!(target: "pty_lat", instance_id = address.session.instance_id.0, node_us = at.elapsed().as_micros() as u64, "pty input to frame published");
11085 }
11086 }
11087 events.push(NodeEvent::TerminalFrame { address, frame });
11088 }
11089 drop(watermarks);
11090 if events.is_empty() {
11091 return;
11092 }
11093 let mut history = self
11094 .history
11095 .lock()
11096 .unwrap_or_else(|poisoned| poisoned.into_inner());
11097 let mut envelopes = Vec::with_capacity(events.len());
11098 for event in events {
11099 let sequence = history
11100 .last_sequence
11101 .checked_add(1)
11102 .expect("node event sequence exhausted");
11103 history.last_sequence = sequence;
11104 envelopes.push(NodeEventEnvelope { sequence, event });
11105 }
11106 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11107 drop(history);
11108 }
11109
11110 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11119 let mut history = self
11120 .history
11121 .lock()
11122 .unwrap_or_else(|poisoned| poisoned.into_inner());
11123 let sequence = history
11124 .last_sequence
11125 .checked_add(1)
11126 .expect("node event sequence exhausted");
11127 history.last_sequence = sequence;
11128 drop(history);
11129 tracing::debug!(
11137 source_sequence = chunk.source_sequence,
11138 kind = agent_stream_chunk_kind_label(&chunk.kind),
11139 envelope_sequence = sequence,
11140 "publishing agent stream chunk",
11141 );
11142 let envelope = NodeEventEnvelope {
11143 sequence,
11144 event: NodeEvent::AgentStream { address, chunk },
11145 };
11146 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11147 }
11148
11149 fn current_sequence(&self) -> u64 {
11150 self.history
11151 .lock()
11152 .unwrap_or_else(|poisoned| poisoned.into_inner())
11153 .last_sequence
11154 }
11155
11156 fn controller_state(&self) -> Option<ControllerState> {
11157 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11158 let lease = controller.as_ref().copied()?;
11159 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11160 if remaining.is_zero() {
11161 *controller = None;
11162 return None;
11163 }
11164 Some(ControllerState {
11165 connection_id: lease.connection_id,
11166 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11167 })
11168 }
11169
11170 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11171 if role != ClientRole::Operator {
11172 tracing::warn!(
11173 node_id = %self.node_id,
11174 connection_id,
11175 "controller lease rejected: observer connections cannot acquire control",
11176 );
11177 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11178 }
11179 let lease_ms = if lease_ms == 0 {
11180 DEFAULT_CONTROLLER_LEASE_MS
11181 } else {
11182 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11183 };
11184 let now = Instant::now();
11185 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11186 if let Some(active) = controller.as_ref() {
11187 if active.connection_id != connection_id && active.expires_at > now {
11188 tracing::warn!(
11189 node_id = %self.node_id,
11190 connection_id,
11191 holder_connection_id = active.connection_id,
11192 "controller lease rejected: another operator holds it",
11193 );
11194 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11195 }
11196 }
11197 let is_new_attach = match controller.as_ref() {
11204 Some(active) => active.connection_id != connection_id,
11205 None => true,
11206 };
11207 *controller = Some(ControllerLease {
11208 connection_id,
11209 expires_at: now + Duration::from_millis(lease_ms),
11210 });
11211 drop(controller);
11212 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11213 if is_new_attach {
11214 tracing::info!(
11215 node_id = %self.node_id,
11216 connection_id,
11217 lease_ms,
11218 "controller lease acquired",
11219 );
11220 } else {
11221 tracing::debug!(
11222 node_id = %self.node_id,
11223 connection_id,
11224 lease_ms,
11225 "controller lease renewed",
11226 );
11227 }
11228 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11229 Ok(state)
11230 }
11231
11232 fn release_controller(&self, connection_id: u64) -> bool {
11233 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11234 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11235 if released {
11236 *controller = None;
11237 }
11238 drop(controller);
11239 if released {
11240 tracing::info!(
11241 node_id = %self.node_id,
11242 connection_id,
11243 "controller lease released",
11244 );
11245 self.publish(NodeEvent::ControllerChanged { controller: None });
11246 }
11247 released
11248 }
11249
11250 fn mark_spawn_provisional(&self, address: SessionAddress) {
11256 let id = address.session.instance_id;
11257 self.provisional_spawns
11258 .lock()
11259 .unwrap_or_else(|poisoned| poisoned.into_inner())
11260 .insert(id, (address, Instant::now()));
11261 tracing::info!(
11262 node_id = %self.node_id,
11263 instance_id = id.0,
11264 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11265 "draft spawn; waiting for HQ view",
11266 );
11267 }
11268
11269 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11270 let removed = self
11271 .provisional_spawns
11272 .lock()
11273 .unwrap_or_else(|poisoned| poisoned.into_inner())
11274 .remove(&instance_id);
11275 if removed.is_some() {
11276 tracing::info!(
11277 node_id = %self.node_id,
11278 instance_id = instance_id.0,
11279 "draft spawn claimed by HQ view",
11280 );
11281 }
11282 }
11283
11284 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11285 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11286 let mut map = self
11287 .provisional_spawns
11288 .lock()
11289 .unwrap_or_else(|poisoned| poisoned.into_inner());
11290 let due: Vec<AgentInstanceId> = map
11291 .iter()
11292 .filter(|(_, (_, at))| at.elapsed() >= grace)
11293 .map(|(id, _)| *id)
11294 .collect();
11295 due.into_iter()
11296 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11297 .collect()
11298 }
11299
11300 async fn reap_provisional_spawn(&self, address: SessionAddress) {
11301 let instance_id = address.session.instance_id;
11302 tracing::warn!(
11303 node_id = %self.node_id,
11304 instance_id = instance_id.0,
11305 workspace = %address.workspace_id,
11306 "draft spawn never viewed; stopping PTY",
11307 );
11308 let _ = self.dispatch(ControlCommand::Stop {
11309 instance_id,
11310 force: true,
11311 });
11312 let _ = self.remove_session(&address).await;
11313 }
11314
11315 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11316 if role != ClientRole::Operator {
11317 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11318 }
11319 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11320 let now = Instant::now();
11321 match controller.as_ref().copied() {
11322 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11323 Some(active) if active.expires_at <= now => {
11324 *controller = None;
11325 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11326 }
11327 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11328 }
11329 }
11330
11331 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11332 if !self
11333 .workspaces
11334 .read()
11335 .unwrap_or_else(|poisoned| poisoned.into_inner())
11336 .contains_key(&address.workspace_id)
11337 {
11338 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11339 }
11340 let binding = self
11341 .session_bindings
11342 .lock()
11343 .unwrap_or_else(|poisoned| poisoned.into_inner())
11344 .get(&address.session.instance_id)
11345 .cloned()
11346 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11347 if binding.workspace_id != address.workspace_id {
11348 return Err(failure(
11349 NodeFailureCode::SessionWorkspaceMismatch,
11350 "session belongs to another workspace",
11351 ));
11352 }
11353 if binding.generation != address.session.generation {
11354 return Err(failure(
11355 NodeFailureCode::StaleGeneration,
11356 &format!(
11357 "stale session generation: expected {}, received {}",
11358 binding.generation.0,
11359 address.session.generation.0,
11360 ),
11361 ));
11362 }
11363 let snapshot = self.handle.snapshot();
11364 let Some(current) = snapshot
11365 .sessions
11366 .iter()
11367 .find(|item| item.instance_id == address.session.instance_id)
11368 else {
11369 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11370 };
11371 if current.generation != address.session.generation {
11372 return Err(failure(
11373 NodeFailureCode::StaleGeneration,
11374 &format!(
11375 "stale session generation: expected {}, received {}",
11376 current.generation.0,
11377 address.session.generation.0,
11378 ),
11379 ));
11380 }
11381 Ok(current.agent_id.clone())
11382 }
11383
11384 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11385 self.dispatch_envelope(self.prepare_command(command))
11386 }
11387
11388 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11389 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11390 CommandEnvelope {
11391 id: CommandId(id),
11392 command,
11393 }
11394 }
11395
11396 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11397 let command_id = envelope.id;
11398 self.handle
11399 .dispatch(envelope)
11400 .map_err(|error| match error {
11401 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11402 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11403 })?;
11404 Ok(command_id)
11405 }
11406
11407 fn record_rejected_command(
11416 &self,
11417 command_id: CommandId,
11418 message: String,
11419 unsupported_transport: bool,
11420 ) {
11421 let now = Instant::now();
11422 let mut reasons = self.rejected_command_reasons.lock()
11423 .unwrap_or_else(|poisoned| poisoned.into_inner());
11424 reasons.retain(|_, reason| reason.expires_at > now);
11425 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11426 if let Some(&oldest) = reasons.keys().next() {
11427 reasons.remove(&oldest);
11428 }
11429 }
11430 reasons.insert(
11431 command_id,
11432 RejectedCommandReason {
11433 message,
11434 unsupported_transport,
11435 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11436 },
11437 );
11438 }
11439
11440 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11447 let now = Instant::now();
11448 let mut reasons = self.rejected_command_reasons.lock()
11449 .unwrap_or_else(|poisoned| poisoned.into_inner());
11450 match reasons.remove(&command_id) {
11451 Some(reason) if reason.expires_at > now => Some(reason),
11452 _ => None,
11453 }
11454 }
11455
11456 async fn dispatch_bounded(
11457 &self,
11458 command: ControlCommand,
11459 timeout_duration: Duration,
11460 ) -> Result<CommandId, NodeFailure> {
11461 let deadline = Instant::now() + timeout_duration;
11462 loop {
11463 match self.dispatch(command.clone()) {
11464 Ok(command_id) => return Ok(command_id),
11465 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11466 sleep(Duration::from_millis(2)).await;
11467 }
11468 Err(error) => return Err(error),
11469 }
11470 }
11471 }
11472
11473 fn internal_session_snapshot(
11474 &self,
11475 address: &SessionAddress,
11476 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11477 self.validate_address(address)?;
11478 self.handle
11479 .snapshot()
11480 .sessions
11481 .clone()
11482 .into_iter()
11483 .find(|session| {
11484 session.instance_id == address.session.instance_id
11485 && session.generation == address.session.generation
11486 })
11487 .ok_or_else(|| {
11488 failure(
11489 NodeFailureCode::UnknownSession,
11490 "session snapshot is unavailable",
11491 )
11492 })
11493 }
11494
11495 fn require_acp_session(
11503 &self,
11504 address: &SessionAddress,
11505 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11506 let snapshot = self.internal_session_snapshot(address)?;
11507 if snapshot.transport != TransportKind::Acp {
11508 return Err(failure(
11509 NodeFailureCode::UnsupportedTransport,
11510 "this verb requires a session spawned over the ACP transport",
11511 ));
11512 }
11513 Ok(snapshot)
11514 }
11515
11516 async fn dispatch_history_bounded(
11517 &self,
11518 address: &SessionAddress,
11519 command: ControlCommand,
11520 operation: HistoryOperation,
11521 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11522 self.validate_address(address)?;
11523 let started_after = self.current_sequence();
11524 let command_id = self
11525 .dispatch_bounded(
11526 command,
11527 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11528 )
11529 .await?;
11530 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11531 let mut scan_after = started_after;
11532 let mut accepted_after = None;
11533 loop {
11534 let events = self
11535 .history
11536 .lock()
11537 .unwrap_or_else(|poisoned| poisoned.into_inner())
11538 .events
11539 .iter()
11540 .filter(|envelope| envelope.sequence > scan_after)
11541 .cloned()
11542 .collect::<Vec<_>>();
11543 for envelope in events {
11544 scan_after = scan_after.max(envelope.sequence);
11545 let NodeEvent::Control {
11546 address: event_address,
11547 event,
11548 } = envelope.event
11549 else {
11550 continue;
11551 };
11552 if event_address != *address {
11553 continue;
11554 }
11555 if event.command_id == Some(command_id) {
11556 match &event.event {
11557 ControlEventKind::CommandRejected { .. } => {
11558 return Err(failure(
11559 NodeFailureCode::InvalidRequest,
11560 "history request was rejected",
11561 ));
11562 }
11563 ControlEventKind::HistoryRequested {
11564 operation: requested,
11565 ..
11566 } if requested == &operation => {
11567 accepted_after = Some(envelope.sequence);
11568 continue;
11569 }
11570 _ => {}
11571 }
11572 }
11573 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11574 match (&operation, &event.event) {
11575 (
11576 HistoryOperation::Discover { .. },
11577 ControlEventKind::HistoryDiscovered { .. },
11578 )
11579 | (
11580 HistoryOperation::Load { .. },
11581 ControlEventKind::HistoryLoaded { .. },
11582 ) => return self.internal_session_snapshot(address),
11583 (_, ControlEventKind::HistoryFailed { .. }) => {
11584 return Err(failure(
11585 NodeFailureCode::BackendOperationFailed,
11586 "history operation failed",
11587 ));
11588 }
11589 _ => {}
11590 }
11591 }
11592 }
11593 if Instant::now() >= deadline {
11594 return Err(failure(
11595 NodeFailureCode::BackendBusy,
11596 if accepted_after.is_some() {
11597 "history operation did not settle before the bounded deadline"
11598 } else {
11599 "history operation was not accepted before the bounded deadline"
11600 },
11601 ));
11602 }
11603 sleep(Duration::from_millis(2)).await;
11604 }
11605 }
11606
11607 async fn discover_history(
11608 &self,
11609 address: &SessionAddress,
11610 limit: u16,
11611 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11612 let query = HistoryQuery {
11613 working_directory: Some(self.workspace_root(&address.workspace_id)?),
11614 limit,
11615 };
11616 query
11617 .validate()
11618 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11619 let snapshot = self
11620 .dispatch_history_bounded(
11621 address,
11622 ControlCommand::DiscoverHistory {
11623 instance_id: address.session.instance_id,
11624 query: query.clone(),
11625 },
11626 HistoryOperation::Discover { query },
11627 )
11628 .await?;
11629 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11630 return Err(failure(
11631 NodeFailureCode::BackendOperationFailed,
11632 "history discovery settled into an invalid state",
11633 ));
11634 }
11635 Ok(snapshot.history.candidates)
11636 }
11637
11638 async fn load_history(
11639 &self,
11640 address: &SessionAddress,
11641 candidate_id: String,
11642 ) -> Result<HistorySessionRecord, NodeFailure> {
11643 validate_candidate_id(&candidate_id)
11644 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11645 let snapshot = self
11646 .dispatch_history_bounded(
11647 address,
11648 ControlCommand::LoadHistory {
11649 instance_id: address.session.instance_id,
11650 candidate_id: candidate_id.clone(),
11651 },
11652 HistoryOperation::Load {
11653 candidate_id: candidate_id.clone(),
11654 },
11655 )
11656 .await?;
11657 if snapshot.history.pending.is_some()
11658 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11659 {
11660 return Err(failure(
11661 NodeFailureCode::BackendOperationFailed,
11662 "history load settled into an invalid state",
11663 ));
11664 }
11665 snapshot.history.loaded.ok_or_else(|| {
11666 failure(
11667 NodeFailureCode::BackendOperationFailed,
11668 "history load produced no bounded session",
11669 )
11670 })
11671 }
11672
11673 async fn export_context_pack_for_session_record(
11680 &self,
11681 record_id: &SessionRecordId,
11682 session: &SessionAddress,
11683 allow_clean_detachment: bool,
11684 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11685 let outcome = self
11691 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11692 .await;
11693 if let Err(failure) = &outcome {
11694 tracing::warn!(
11695 record = %record_id,
11696 code = ?failure.code,
11697 reason = %failure.message,
11698 "session-record context-pack export refused",
11699 );
11700 }
11701 outcome
11702 }
11703
11704 async fn export_context_pack_for_session_record_inner(
11705 &self,
11706 record_id: &SessionRecordId,
11707 session: &SessionAddress,
11708 allow_clean_detachment: bool,
11709 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11710 let step = |name: &'static str| move |failure: NodeFailure| {
11715 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11716 failure
11717 };
11718 let record = self.record(record_id).map_err(step("record-lookup"))?;
11719 let identity = match record.provider_session.clone() {
11735 Some(identity) => {
11736 if identity.key != ProviderSessionKey::SessionId {
11737 return Err(step("provider-identity-key")(failure(
11738 NodeFailureCode::BackendOperationFailed,
11739 "managed session provider identity cannot bind history exactly",
11740 )));
11741 }
11742 Some(identity)
11743 }
11744 None if record.state == ManagedSessionState::IdentityPending => {
11745 return Err(step("provider-session-identity")(failure(
11746 NodeFailureCode::BackendOperationFailed,
11747 "managed session has not yet observed its provider session identity",
11748 )));
11749 }
11750 None => None,
11751 };
11752 self.revalidate_session_record_context_export(
11753 &record,
11754 identity.as_ref(),
11755 session,
11756 allow_clean_detachment,
11757 ).map_err(step("revalidate-before-discovery"))?;
11758 let candidates = self
11759 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11760 .await
11761 .map_err(step("discover-history"))?;
11762 let candidate_id = select_history_candidate(
11767 &candidates,
11768 identity.as_ref().map(|identity| identity.id.as_str()),
11769 ).map_err(step("unique-candidate"))?;
11770 let loaded = self.load_history(session, candidate_id.clone()).await
11771 .map_err(step("load-history"))?;
11772 if let Some(identity) = identity.as_ref() {
11773 if loaded.session_id != identity.id {
11774 return Err(failure(
11775 NodeFailureCode::SessionRecordConflict,
11776 "loaded history identity does not match the managed session record",
11777 ));
11778 }
11779 }
11780 self.revalidate_session_record_context_export(
11781 &record,
11782 identity.as_ref(),
11783 session,
11784 allow_clean_detachment,
11785 )?;
11786 self.revalidate_loaded_history(session, &candidate_id, &loaded)
11787 .map_err(step("revalidate-loaded-history"))?;
11788 let pack = self.materialize_context_pack(session).await
11789 .map_err(step("materialize-context-pack"))?;
11790 self.commit_context_pack_for_session_record(
11791 &record,
11792 identity.as_ref(),
11793 session,
11794 &candidate_id,
11795 &loaded,
11796 pack,
11797 allow_clean_detachment,
11798 )
11799 }
11800
11801 async fn export_context_pack(
11802 &self,
11803 address: &SessionAddress,
11804 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11805 let pack = self.materialize_context_pack(address).await?;
11806 self.commit_context_pack(pack)
11807 }
11808
11809 async fn materialize_context_pack(
11810 &self,
11811 address: &SessionAddress,
11812 ) -> Result<NodeContextPack, NodeFailure> {
11813 let snapshot = self.internal_session_snapshot(address)?;
11814 if !context_pack_source_status_is_usable(&snapshot.status) {
11815 return Err(failure(
11816 NodeFailureCode::InvalidRequest,
11817 "context export requires a running or successfully exited source session",
11818 ));
11819 }
11820 if snapshot.history.pending.is_some()
11821 || snapshot.history.loaded_candidate_id.is_none()
11822 {
11823 return Err(failure(
11824 NodeFailureCode::InvalidRequest,
11825 "context export requires settled loaded history",
11826 ));
11827 }
11828 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11829 failure(
11830 NodeFailureCode::InvalidRequest,
11831 "context export requires settled loaded history",
11832 )
11833 })?;
11834 let source_provider = snapshot.agent_id.clone();
11835 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11836 let repository = self
11837 .context_pack_repository(&address.workspace_id)
11838 .await?;
11839 let current = self.internal_session_snapshot(address)?;
11840 if !context_pack_source_status_is_usable(¤t.status)
11841 || current.agent_id != source_provider
11842 || current.history.pending.is_some()
11843 || current.history.loaded_candidate_id != loaded_candidate_id
11844 || current.history.loaded.as_ref() != Some(&loaded)
11845 {
11846 return Err(failure(
11847 NodeFailureCode::ContextPackMaterializationFailed,
11848 "context source changed during bounded repository capture",
11849 ));
11850 }
11851 NodeContextPack::export_with_repository(
11852 ContextPackLineageReceipt {
11853 source_node_id: self.node_id.clone(),
11854 source_session: address.clone(),
11855 source_provider,
11856 },
11857 &loaded,
11858 Some(repository),
11859 )
11860 .map_err(|_| {
11861 failure(
11862 NodeFailureCode::ContextPackMaterializationFailed,
11863 "bounded context pack export failed",
11864 )
11865 })
11866 }
11867
11868 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
11874 let mut store = self
11875 .context_pack_store
11876 .lock()
11877 .unwrap_or_else(|poisoned| poisoned.into_inner());
11878 let Some(store) = store.as_mut() else {
11879 return Ok(());
11880 };
11881 store.commit(pack).map_err(|_| {
11882 failure(
11883 NodeFailureCode::ContextPackMaterializationFailed,
11884 "durable context pack commit failed",
11885 )
11886 })
11887 }
11888
11889 fn commit_context_pack(
11890 &self,
11891 pack: NodeContextPack,
11892 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11893 self.commit_context_pack_durably(&pack)?;
11894 self.context_catalog
11895 .write()
11896 .unwrap_or_else(|poisoned| poisoned.into_inner())
11897 .insert(pack)
11898 .map_err(|_| {
11899 failure(
11900 NodeFailureCode::ContextPackMaterializationFailed,
11901 "context pack catalog is full",
11902 )
11903 })
11904 }
11905
11906 fn commit_context_pack_for_session_record(
11907 &self,
11908 expected: &ManagedSessionRecord,
11909 identity: Option<&ProviderSessionIdentity>,
11910 session: &SessionAddress,
11911 candidate_id: &str,
11912 loaded: &HistorySessionRecord,
11913 pack: NodeContextPack,
11914 allow_clean_detachment: bool,
11915 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11916 let current_root = self.workspace_root(&expected.workspace_id)?;
11917 let _transaction = self
11918 .state_transaction
11919 .lock()
11920 .unwrap_or_else(|poisoned| poisoned.into_inner());
11921 let records = self
11922 .session_records
11923 .lock()
11924 .unwrap_or_else(|poisoned| poisoned.into_inner());
11925 let current = records.records.get(&expected.record_id).ok_or_else(|| {
11926 failure(
11927 NodeFailureCode::SessionRecordConflict,
11928 "managed session record disappeared during context export",
11929 )
11930 })?;
11931 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
11932 return Err(failure(
11933 NodeFailureCode::SessionRecordConflict,
11934 "managed session record changed during context export",
11935 ));
11936 }
11937 let bindings = self
11938 .session_bindings
11939 .lock()
11940 .unwrap_or_else(|poisoned| poisoned.into_inner());
11941 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
11942 failure(
11943 NodeFailureCode::SessionRecordConflict,
11944 "managed session runtime binding disappeared during context export",
11945 )
11946 })?;
11947 let runtime = self
11948 .handle
11949 .snapshot()
11950 .sessions
11951 .iter()
11952 .find(|snapshot| {
11953 snapshot.instance_id == session.session.instance_id
11954 && snapshot.generation == session.session.generation
11955 })
11956 .cloned()
11957 .ok_or_else(|| {
11958 failure(
11959 NodeFailureCode::SessionRecordConflict,
11960 "managed session runtime disappeared during context export",
11961 )
11962 })?;
11963 let receipt = pack.receipt();
11964 if !session_record_context_export_binding_is_exact(
11965 expected,
11966 current,
11967 identity,
11968 session,
11969 binding,
11970 &runtime.agent_id,
11971 ¤t_root,
11972 allow_clean_detachment,
11973 ) || !session_record_context_export_source_is_usable(
11974 current,
11975 &runtime.status,
11976 allow_clean_detachment,
11977 ) || runtime.history.pending.is_some()
11978 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
11979 || runtime.history.loaded.as_ref() != Some(loaded)
11980 || receipt.lineage.source_node_id != self.node_id
11981 || receipt.lineage.source_session != *session
11982 || receipt.lineage.source_provider != current.provider
11983 || receipt.source_message_count != loaded.message_count
11984 {
11985 return Err(failure(
11986 NodeFailureCode::SessionRecordConflict,
11987 "managed session source changed before context catalog commit",
11988 ));
11989 }
11990 self.commit_context_pack_durably(&pack)?;
11991 self.context_catalog
11992 .write()
11993 .unwrap_or_else(|poisoned| poisoned.into_inner())
11994 .insert(pack)
11995 .map_err(|_| {
11996 failure(
11997 NodeFailureCode::ContextPackMaterializationFailed,
11998 "context pack catalog is full",
11999 )
12000 })
12001 }
12002
12003 async fn context_pack_repository(
12004 &self,
12005 workspace_id: &WorkspaceId,
12006 ) -> Result<ContextPackRepository, NodeFailure> {
12007 let first = self
12008 .context_pack_repository_observation(workspace_id)
12009 .await?;
12010 let second = self
12011 .context_pack_repository_observation(workspace_id)
12012 .await?;
12013 stable_context_pack_repository(first, second)
12014 }
12015
12016 async fn context_pack_repository_observation(
12017 &self,
12018 workspace_id: &WorkspaceId,
12019 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12020 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12021 let head = if git.is_repository {
12022 let root = self.workspace_root(workspace_id)?;
12023 match resolve_base_commit_with_timeout(
12024 &root,
12025 "HEAD",
12026 GIT_COMMAND_TIMEOUT_MS,
12027 )
12028 .await
12029 {
12030 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12031 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12032 ContextPackRepositoryHead::Unborn
12033 }
12034 Err(_) => {
12035 return Err(failure(
12036 NodeFailureCode::ContextPackMaterializationFailed,
12037 "context pack HEAD observation failed",
12038 ));
12039 }
12040 }
12041 } else {
12042 ContextPackRepositoryHead::NotRepository
12043 };
12044 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12045 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12046 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12047 failure(
12048 NodeFailureCode::InvalidRepositoryPath,
12049 "context pack selected file path is invalid",
12050 )
12051 })?;
12052 match self.read_workspace_file(workspace_id.clone(), path).await {
12053 Ok(WorkspaceFileRead {
12054 content: WorkspaceFileContent::Utf8 { text, byte_len },
12055 ..
12056 }) => files.push(ContextPackRepositoryFileSource::utf8(
12057 selected_path,
12058 text,
12059 byte_len,
12060 )),
12061 Ok(WorkspaceFileRead {
12062 content: WorkspaceFileContent::NonUtf8 { byte_len },
12063 ..
12064 }) => files.push(ContextPackRepositoryFileSource::skipped(
12065 selected_path,
12066 ContextPackSelectedFileSkipReason::NonUtf8,
12067 Some(byte_len),
12068 )),
12069 Ok(WorkspaceFileRead {
12070 content: WorkspaceFileContent::TooLarge { .. },
12071 ..
12072 }) => files.push(ContextPackRepositoryFileSource::skipped(
12073 selected_path,
12074 ContextPackSelectedFileSkipReason::TooLarge,
12075 None,
12076 )),
12077 Err(error) => {
12078 let reason = if matches!(
12079 error.code,
12080 NodeFailureCode::InvalidRepositoryPath
12081 | NodeFailureCode::RepositoryFileNotRegular
12082 | NodeFailureCode::RepositoryPathUnsafe
12083 ) {
12084 ContextPackSelectedFileSkipReason::Unsafe
12085 } else {
12086 ContextPackSelectedFileSkipReason::Unavailable
12087 };
12088 files.push(ContextPackRepositoryFileSource::skipped(
12089 selected_path,
12090 reason,
12091 None,
12092 ));
12093 }
12094 }
12095 }
12096 Ok(ContextPackRepositoryObservation { head, git, files })
12097 }
12098
12099 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12100 let referenced_by_binding = self
12101 .session_bindings
12102 .lock()
12103 .unwrap_or_else(|poisoned| poisoned.into_inner())
12104 .values()
12105 .any(|binding| {
12106 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12107 });
12108 let referenced_by_record = self
12109 .session_records
12110 .lock()
12111 .unwrap_or_else(|poisoned| poisoned.into_inner())
12112 .records
12113 .values()
12114 .any(|record| record.context_id.as_ref() == Some(context_id));
12115 let referenced_by_materialization = self
12116 .materializations
12117 .lock()
12118 .unwrap_or_else(|poisoned| poisoned.into_inner())
12119 .values()
12120 .any(|ownership| {
12121 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12122 });
12123 if referenced_by_binding
12124 || referenced_by_record
12125 || referenced_by_materialization
12126 {
12127 return Err(failure(
12128 NodeFailureCode::ContextPackBusy,
12129 "context pack is still referenced",
12130 ));
12131 }
12132 let mut catalog = self.context_catalog
12133 .write()
12134 .unwrap_or_else(|poisoned| poisoned.into_inner());
12135 if catalog.get(context_id).is_none() {
12136 return Err(failure(
12137 NodeFailureCode::UnknownContextPack,
12138 "context pack is unavailable",
12139 ));
12140 }
12141 self.spawn_idempotency
12142 .lock()
12143 .unwrap_or_else(|poisoned| poisoned.into_inner())
12144 .entries
12145 .retain(|_, entry| !entry.value.references_context(context_id));
12146 let removed = catalog.remove(context_id);
12147 debug_assert!(removed.is_some(), "validated context pack remains present");
12148 Ok(())
12149 }
12150
12151 async fn dispatch_input_bounded(
12152 &self,
12153 address: &SessionAddress,
12154 action: InputAction,
12155 ) -> Result<(), NodeFailure> {
12156 let started_after = self.current_sequence();
12157 self.pty_input_marks
12158 .lock()
12159 .unwrap_or_else(|poisoned| poisoned.into_inner())
12160 .insert(address.session.instance_id, (Instant::now(), false));
12161 self.pty_input_wake.notify_one();
12162 let command_id = self
12163 .dispatch_bounded(
12164 ControlCommand::SendInput {
12165 instance_id: address.session.instance_id,
12166 action,
12167 },
12168 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12169 )
12170 .await?;
12171 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12175 let mut scan_after = started_after;
12176 let mut accepted_after = None;
12177 loop {
12178 let events = self
12179 .history
12180 .lock()
12181 .unwrap_or_else(|poisoned| poisoned.into_inner())
12182 .events
12183 .iter()
12184 .filter(|envelope| envelope.sequence > scan_after)
12185 .cloned()
12186 .collect::<Vec<_>>();
12187 for envelope in events {
12188 scan_after = scan_after.max(envelope.sequence);
12189 let NodeEvent::Control {
12190 address: event_address,
12191 event,
12192 } = envelope.event
12193 else {
12194 continue;
12195 };
12196 if event_address != *address {
12197 continue;
12198 }
12199 if event.command_id == Some(command_id) {
12200 match &event.event {
12201 ControlEventKind::CommandRejected { message } => {
12202 return Err(failure(NodeFailureCode::InvalidRequest, message));
12203 }
12204 ControlEventKind::InputRequested { .. } => {
12205 accepted_after = Some(envelope.sequence);
12206 continue;
12207 }
12208 _ => {}
12209 }
12210 }
12211 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12212 match &event.event {
12213 ControlEventKind::InputCompleted { .. } => return Ok(()),
12214 ControlEventKind::InputFailed { message, .. } => {
12215 return Err(failure(
12216 NodeFailureCode::BackendOperationFailed,
12217 message,
12218 ));
12219 }
12220 _ => {}
12221 }
12222 }
12223 }
12224 if Instant::now() >= deadline {
12225 return Err(failure(
12226 NodeFailureCode::BackendBusy,
12227 if accepted_after.is_some() {
12228 "input was accepted but did not settle before the bounded deadline"
12229 } else {
12230 "input was not accepted before the bounded deadline"
12231 },
12232 ));
12233 }
12234 sleep(Duration::from_millis(2)).await;
12235 }
12236 }
12237
12238 async fn dispatch_resize_bounded(
12239 &self,
12240 address: &SessionAddress,
12241 size: gate4agent_types::TerminalSize,
12242 ) -> Result<(), NodeFailure> {
12243 let started_after = self.current_sequence();
12244 let command_id = self
12245 .dispatch_bounded(
12246 ControlCommand::Resize {
12247 instance_id: address.session.instance_id,
12248 size,
12249 },
12250 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12251 )
12252 .await?;
12253 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12254 let mut scan_after = started_after;
12255 let mut accepted_after = None;
12256 loop {
12257 let events = self
12258 .history
12259 .lock()
12260 .unwrap_or_else(|poisoned| poisoned.into_inner())
12261 .events
12262 .iter()
12263 .filter(|envelope| envelope.sequence > scan_after)
12264 .cloned()
12265 .collect::<Vec<_>>();
12266 for envelope in events {
12267 scan_after = scan_after.max(envelope.sequence);
12268 let NodeEvent::Control {
12269 address: event_address,
12270 event,
12271 } = envelope.event
12272 else {
12273 continue;
12274 };
12275 if event_address != *address {
12276 continue;
12277 }
12278 if event.command_id == Some(command_id) {
12279 match &event.event {
12280 ControlEventKind::CommandRejected { message } => {
12281 return Err(failure(NodeFailureCode::InvalidRequest, message));
12282 }
12283 ControlEventKind::ResizeRequested {
12284 size: requested_size,
12285 ..
12286 } if *requested_size == size => {
12287 accepted_after = Some(envelope.sequence);
12288 continue;
12289 }
12290 _ => {}
12291 }
12292 }
12293 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12294 match &event.event {
12295 ControlEventKind::Resized { size: settled_size }
12296 if *settled_size == size =>
12297 {
12298 return Ok(());
12299 }
12300 ControlEventKind::ResizeFailed { message } => {
12301 return Err(failure(
12302 NodeFailureCode::BackendOperationFailed,
12303 message,
12304 ));
12305 }
12306 _ => {}
12307 }
12308 }
12309 }
12310 if Instant::now() >= deadline {
12311 return Err(failure(
12312 NodeFailureCode::BackendBusy,
12313 if accepted_after.is_some() {
12314 "resize was accepted but did not settle before the bounded deadline"
12315 } else {
12316 "resize was not accepted before the bounded deadline"
12317 },
12318 ));
12319 }
12320 sleep(Duration::from_millis(2)).await;
12321 }
12322 }
12323
12324 async fn spawn_session(
12325 &self,
12326 workspace_id: WorkspaceId,
12327 provider: AgentId,
12328 mode: SessionMode,
12329 terminal_size: gate4agent_types::TerminalSize,
12330 initial_prompt: Option<String>,
12331 ) -> Result<SessionAddress, NodeFailure> {
12332 if self.managed_worktrees.lock()
12333 .unwrap_or_else(|poisoned| poisoned.into_inner())
12334 .lease_for_workspace(&workspace_id)
12335 .is_some()
12336 {
12337 return Err(failure(
12338 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12339 "managed worktree targets may only be spawned through their lease request",
12340 ));
12341 }
12342 self
12343 .spawn_session_with_deadline(
12344 workspace_id,
12345 provider,
12346 mode,
12347 terminal_size,
12348 initial_prompt,
12349 None,
12350 None,
12351 None,
12352 None,
12353 None,
12354 SpawnRecordPolicy::ProviderIdentityOnly,
12355 None,
12356 &[],
12357 None,
12358 ApprovalLevel::default(),
12364 None,
12365 None,
12366 )
12367 .await
12368 .map(|(session, _runtime_policy)| session)
12369 }
12370
12371 async fn spawn_session_with_deadline(
12372 &self,
12373 workspace_id: WorkspaceId,
12374 provider: AgentId,
12375 mode: SessionMode,
12376 terminal_size: gate4agent_types::TerminalSize,
12377 initial_prompt: Option<String>,
12378 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12379 bundle: Option<ResolvedBundleReceipt>,
12380 context: Option<ResolvedContextPackReceipt>,
12381 managed_authority: Option<ManagedWorktreeLeaseId>,
12382 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12383 record_policy: SpawnRecordPolicy,
12384 deadline: Option<Instant>,
12385 required_capabilities: &[ProviderRuntimeCapability],
12386 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12387 approval_level: ApprovalLevel,
12388 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12389 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12395 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12396 let reserved_lease = self.managed_worktrees.lock()
12397 .unwrap_or_else(|poisoned| poisoned.into_inner())
12398 .lease_for_workspace(&workspace_id);
12399 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12400 (Some(expected), Some(provided)) if expected == provided => {}
12401 (None, None) => {}
12402 _ => {
12403 return Err(failure(
12404 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12405 "spawn lacks exact managed worktree lease authority",
12406 ));
12407 }
12408 }
12409 self.ensure_workspace_environment_profile_compatible(
12413 &workspace_id,
12414 environment_profile.as_ref(),
12415 )?;
12416 let runtime_requirement = match (mode, initial_prompt.is_some()) {
12417 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12418 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12419 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12420 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12421 };
12422 let transport = match mode {
12428 SessionMode::Pty => TransportKind::Pty,
12429 SessionMode::Inline => TransportKind::Pipe,
12430 SessionMode::Acp => TransportKind::Acp,
12431 };
12432 let pipe_transport = self.provider_supports_pipe_transport(&provider);
12433 let acp_transport = self.provider_supports_acp_transport(&provider);
12434 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12435 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12436 NodeFailureCode::UnsupportedSpawnCapability,
12437 "pre-admitted provider runtime no longer satisfies spawn requirements",
12438 ))?;
12439 runtime_policy
12440 } else if let Some(deadline) = deadline {
12441 let remaining = spawn_deadline_remaining(deadline)?;
12442 timeout(
12443 remaining,
12444 self.admit_provider_runtime(&provider, runtime_requirement),
12445 )
12446 .await
12447 .map_err(|_| {
12448 failure(
12449 NodeFailureCode::SpawnDeadlineExceeded,
12450 "provider admission exceeded the spawn deadline",
12451 )
12452 })??
12453 } else {
12454 self.admit_provider_runtime(&provider, runtime_requirement).await?
12455 };
12456 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12457 let runtime_policy =
12469 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12470 if required_capabilities
12471 .iter()
12472 .any(|capability| !runtime_policy.admits(*capability))
12473 {
12474 return Err(failure(
12475 NodeFailureCode::UnsupportedSpawnCapability,
12476 "provider runtime does not admit a required spawn capability",
12477 ));
12478 }
12479 if let Some(deadline) = deadline {
12480 spawn_deadline_remaining(deadline)?;
12481 }
12482 self.ensure_binding_capacity()?;
12483 let working_directory = self.workspace_root(&workspace_id)?;
12484 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12485 let session = SessionKey {
12486 instance_id,
12487 generation: SessionGeneration(1),
12488 };
12489 let address = SessionAddress {
12490 workspace_id: workspace_id.clone(),
12491 session,
12492 };
12493 tracing::info!(
12501 node_id = %self.node_id,
12502 workspace_id = %workspace_id,
12503 provider = %provider,
12504 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12505 session = ?address.session,
12506 "session spawn request accepted",
12507 );
12508 let mut prepared_spawn = SpawnPreparer::prepare(
12509 self,
12510 SpawnPrepareRequest {
12511 instance_id,
12512 address: &address,
12513 provider: &provider,
12514 mode,
12515 environment_profile: environment_profile.as_ref(),
12516 bundle: bundle.as_ref(),
12517 context: context.as_ref(),
12518 managed_authority: managed_authority.as_ref(),
12519 harness_mcp,
12520 approval_level,
12521 network_allowlist: network_allowlist.as_ref(),
12522 browser_profile_id,
12523 },
12524 )?;
12525 let materialization_id = prepared_spawn.materialization_id.take();
12526 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12527 let record_id = self.bind_spawn_session_with_materialization(
12528 &address,
12529 provider.clone(),
12530 mode,
12531 runtime_policy,
12532 record_policy,
12533 environment_profile,
12534 bundle,
12535 context,
12536 materialization_id,
12537 )?;
12538 prepared_spawn.retain_session_guards();
12539 let agent_id = provider;
12543 let register_command_id = match self.dispatch_bounded(
12544 ControlCommand::Register {
12545 instance_id,
12546 agent_id,
12547 transport,
12548 },
12549 dispatch_timeout,
12550 )
12551 .await {
12552 Ok(command_id) => command_id,
12553 Err(error) => {
12554 if let Some(binding) = self.remove_binding(&address) {
12555 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12556 }
12557 if let Some(record_id) = record_id.as_ref() {
12558 self.discard_record(record_id)?;
12559 }
12560 return Err(spawn_dispatch_error(error, deadline));
12561 }
12562 };
12563 let start_timeout = match spawn_dispatch_timeout(deadline) {
12564 Ok(timeout) => timeout,
12565 Err(error) => {
12566 let recovery = self
12567 .rollback_spawn(
12568 &address,
12569 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12570 )
12571 .await;
12572 return match recovery {
12573 Ok(()) => {
12574 if let Some(record_id) = record_id.as_ref() {
12575 self.discard_record(record_id)?;
12576 }
12577 Err(error)
12578 }
12579 Err(recovery_error) => {
12580 if let Some(record_id) = record_id.as_ref() {
12581 self.mark_record_error(
12582 record_id,
12583 "spawn deadline elapsed and registration recovery failed",
12584 )?;
12585 }
12586 Err(recovery_error)
12587 }
12588 };
12589 }
12590 };
12591 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12592 if let Err(recovery_error) = self
12593 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12594 .await
12595 {
12596 return Err(recovery_error);
12597 }
12598 if let Some(record_id) = record_id.as_ref() {
12599 self.discard_record(record_id)?;
12600 }
12601 return Err(start_error);
12602 }
12603 let start_result = self
12604 .dispatch_bounded(
12605 ControlCommand::Start {
12606 instance_id,
12607 runtime_policy,
12608 request: StartRequest {
12609 working_directory,
12610 terminal_size,
12611 initial_prompt,
12612 session_options: None,
12613 approval_level,
12614 },
12615 },
12616 start_timeout,
12617 )
12618 .await;
12619 let start_command_id = match start_result {
12620 Ok(command_id) => command_id,
12621 Err(start_error) => {
12622 let start_error = spawn_dispatch_error(start_error, deadline);
12623 let recovery = self
12624 .rollback_spawn(
12625 &address,
12626 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12627 )
12628 .await;
12629 return match recovery {
12630 Ok(()) => {
12631 if let Some(record_id) = record_id.as_ref() {
12632 self.discard_record(record_id)?;
12633 }
12634 Err(start_error)
12635 }
12636 Err(recovery_error) => {
12637 let diagnostic = format!(
12638 "start failed and registration recovery failed: {}",
12639 recovery_error.message,
12640 );
12641 if let Some(record_id) = record_id.as_ref() {
12642 self.mark_record_error(record_id, &diagnostic)?;
12643 }
12644 Err(failure(recovery_error.code, &diagnostic))
12645 }
12646 };
12647 }
12648 };
12649
12650 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12651 loop {
12652 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12661 .or_else(|| self.take_rejected_command_reason(start_command_id))
12662 {
12663 self.rollback_spawn(
12664 &address,
12665 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12666 ).await.map_err(|error| {
12667 failure(
12668 error.code,
12669 &format!(
12670 "spawn was rejected ({}) and registration recovery failed: {}",
12671 reason.message, error.message
12672 ),
12673 )
12674 })?;
12675 if let Some(record_id) = record_id.as_ref() {
12676 self.discard_record(record_id)?;
12677 }
12678 let code = if reason.unsupported_transport {
12679 NodeFailureCode::UnsupportedTransport
12680 } else {
12681 NodeFailureCode::BackendOperationFailed
12682 };
12683 return Err(failure(code, &reason.message));
12684 }
12685 if let Some(current) = self
12686 .handle
12687 .snapshot()
12688 .sessions
12689 .iter()
12690 .find(|current| current.instance_id == instance_id)
12691 {
12692 if current.generation != session.generation {
12693 let divergence = failure(
12694 NodeFailureCode::StaleGeneration,
12695 "spawn generation diverged",
12696 );
12697 return match self
12698 .rollback_spawn(
12699 &address,
12700 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12701 )
12702 .await
12703 {
12704 Ok(()) => {
12705 if let Some(record_id) = record_id.as_ref() {
12706 self.discard_record(record_id)?;
12707 }
12708 Err(divergence)
12709 }
12710 Err(recovery_error) => {
12711 if let Some(record_id) = record_id.as_ref() {
12712 self.mark_record_error(
12713 record_id,
12714 "spawn generation diverged and recovery failed",
12715 )?;
12716 }
12717 Err(recovery_error)
12718 }
12719 };
12720 }
12721 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12722 prepared_spawn.retain_launch_guards();
12723 return Ok((address, runtime_policy));
12724 }
12725 }
12726 if Instant::now() >= commit_deadline {
12727 self.rollback_spawn(
12728 &address,
12729 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12730 ).await.map_err(|error| {
12731 failure(
12732 error.code,
12733 &format!(
12734 "start did not commit and registration recovery failed: {}",
12735 error.message
12736 ),
12737 )
12738 })?;
12739 if let Some(record_id) = record_id.as_ref() {
12740 self.discard_record(record_id)?;
12741 }
12742 return Err(failure(
12743 if deadline.is_some() {
12744 NodeFailureCode::SpawnDeadlineExceeded
12745 } else {
12746 NodeFailureCode::BackendBusy
12747 },
12748 "start did not commit before the bounded deadline; registration was removed",
12749 ));
12750 }
12751 sleep(Duration::from_millis(2)).await;
12752 }
12753 }
12754
12755 fn effective_spawn_runtime_policy(
12756 &self,
12757 runtime_policy: ProviderRuntimePolicy,
12758 ) -> ProviderRuntimePolicy {
12759 #[cfg(feature = "fixture")]
12760 let runtime_policy = if self.fixture_semantic_hook_policy {
12761 ProviderRuntimePolicy::new(true, true, true, true, false, true)
12762 .expect("monitoring Hook fixture policy is internally valid")
12763 } else {
12764 runtime_policy
12765 };
12766 runtime_policy
12767 }
12768
12769 fn verify_harness_mcp_before_start(
12770 &self,
12771 prepared: Option<&PreparedHarnessMcpSpawn>,
12772 ) -> Result<(), NodeFailure> {
12773 prepared
12774 .map(PreparedHarnessMcpSpawn::verify_helper_program)
12775 .transpose()
12776 .map(|_| ())
12777 .map_err(harness_mcp_failure)
12778 }
12779
12780 async fn rollback_spawn(
12781 &self,
12782 address: &SessionAddress,
12783 commit_timeout: Duration,
12784 ) -> Result<(), NodeFailure> {
12785 self.dispatch_bounded(
12786 ControlCommand::Remove {
12787 instance_id: address.session.instance_id,
12788 },
12789 commit_timeout,
12790 )
12791 .await?;
12792 self.wait_until_removed(address, commit_timeout).await?;
12793 if let Some(binding) = self.remove_binding(address) {
12794 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
12795 }
12796 Ok(())
12797 }
12798
12799 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12800 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12801 self.dispatch_bounded(
12802 ControlCommand::Remove {
12803 instance_id: address.session.instance_id,
12804 },
12805 commit_timeout,
12806 )
12807 .await?;
12808 self.wait_until_removed(address, commit_timeout).await?;
12809 let removed = self.remove_binding(address);
12810 if let Some(binding) = removed.as_ref() {
12811 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
12812 }
12813 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12814 let snapshot = self.managed_worktrees.lock()
12815 .unwrap_or_else(|poisoned| poisoned.into_inner())
12816 .release_session(
12817 &lease_id,
12818 self.incarnation_id,
12819 address.session.instance_id,
12820 address.session.generation,
12821 unix_time_ms(),
12822 );
12823 if self.persist_state().is_err() {
12824 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12825 }
12826 if let Some(snapshot) = snapshot {
12827 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12828 }
12829 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12830 }
12831 Ok(())
12832 }
12833
12834 async fn wait_until_removed(
12880 &self,
12881 address: &SessionAddress,
12882 commit_timeout: Duration,
12883 ) -> Result<(), NodeFailure> {
12884 let deadline = Instant::now() + commit_timeout;
12885 let mut next_dispatch = Instant::now();
12886 loop {
12887 let snapshot = self.handle.snapshot();
12888 let status = snapshot
12889 .sessions
12890 .iter()
12891 .find(|session| session.instance_id == address.session.instance_id)
12892 .map(|session| session.status.clone());
12893 let Some(status) = status else {
12894 return Ok(());
12895 };
12896 let now = Instant::now();
12897 if now >= deadline {
12898 return Err(failure(
12899 NodeFailureCode::BackendBusy,
12900 "remove did not commit before the bounded deadline; session binding was retained",
12901 ));
12902 }
12903 if status.allows_remove() && now >= next_dispatch {
12904 let _ = self.dispatch(ControlCommand::Remove {
12905 instance_id: address.session.instance_id,
12906 });
12907 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
12908 }
12909 sleep(Duration::from_millis(2)).await;
12910 }
12911 }
12912
12913 fn resync(&self, after_sequence: u64) -> NodeResponse {
12914 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12915 let event_sequence = history.last_sequence;
12916 let oldest_available_sequence = history.replay_floor_sequence;
12917 let events = history
12918 .events
12919 .iter()
12920 .filter(|event| event.sequence > after_sequence)
12921 .cloned()
12922 .collect();
12923 drop(history);
12924 NodeResponse::Resync {
12925 event_sequence,
12926 oldest_available_sequence,
12927 snapshot: self.snapshot(),
12928 events,
12929 }
12930 }
12931}
12932
12933fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
12938 match kind {
12939 AgentStreamChunkKindV1::Text { .. } => "text",
12940 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
12941 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
12942 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
12943 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
12944 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
12945 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
12946 }
12947}
12948
12949async fn accept_connections(
12963 endpoint: &str,
12964 call_home: Option<std::net::SocketAddr>,
12965 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
12966 shared: Arc<NodeShared>,
12967) -> Result<(), NodeServerError> {
12968 let mut connections = JoinSet::new();
12969 let result = match call_home {
12970 None => accept_connections_inner(endpoint, shared, &mut connections).await,
12971 Some(relay) => {
12972 let listener =
12973 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
12974 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
12975 tokio::pin!(listener);
12976 tokio::pin!(dialler);
12977 tokio::select! {
12978 result = &mut listener => result,
12979 result = &mut dialler => result,
12980 }
12981 }
12982 };
12983 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
12984 while !connections.is_empty() {
12985 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
12986 if remaining.is_zero()
12987 || timeout(remaining, connections.join_next()).await.is_err()
12988 {
12989 break;
12990 }
12991 }
12992 connections.abort_all();
12993 while connections.join_next().await.is_some() {}
12994 result
12995}
12996
12997const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13001
13002async fn call_home_loop(
13016 relay: std::net::SocketAddr,
13017 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13018 shared: Arc<NodeShared>,
13019) -> Result<(), NodeServerError> {
13020 let mut failures = 0_usize;
13021 loop {
13022 if shared.shutdown.load(Ordering::Acquire) {
13023 return Ok(());
13024 }
13025 let permit = tokio::select! {
13026 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13027 permit.map_err(|_| NodeServerError::PreauthClosed)?
13028 }
13029 _ = shared.shutdown_notify.notified() => return Ok(()),
13030 };
13031 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13032 Ok(()) => {
13033 failures = 0;
13037 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed");
13038 }
13039 Err(error) => {
13040 failures = failures.saturating_add(1);
13041 tracing::warn!(
13042 node_id = %shared.node_id,
13043 %relay,
13044 cause = %error,
13045 attempt = failures,
13046 "call-home connection failed",
13047 );
13048 let backoff = CALL_HOME_BACKOFFS_MS
13049 [failures.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)];
13050 tokio::select! {
13051 _ = sleep(Duration::from_millis(backoff)) => {}
13052 _ = shared.shutdown_notify.notified() => return Ok(()),
13053 }
13054 }
13055 }
13056 }
13057}
13058
13059async fn dial_relay_and_serve(
13061 relay: std::net::SocketAddr,
13062 identity: Option<&hatchery_enroll::NodeIdentity>,
13063 shared: Arc<NodeShared>,
13064 preauth_permit: OwnedSemaphorePermit,
13065) -> Result<(), NodeServerError> {
13066 let mut stream = tokio::net::TcpStream::connect(relay)
13067 .await
13068 .map_err(NodeServerError::CallHomeTransport)?;
13069 let _ = stream.set_nodelay(true);
13073 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13074 .await
13075 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13076 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13077 let mut session_token = None;
13078 if let Some(identity) = identity {
13079 let mut magic = [0_u8; 4];
13083 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13084 .await
13085 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13086 .map_err(NodeServerError::CallHomeTransport)?;
13087 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13088 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13089 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13090 .await
13091 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13092 .map_err(NodeServerError::CallHomeTransport)?;
13093 let (answer, token) = identity
13094 .answer_challenge(shared.node_id.as_str(), &frame)
13095 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13096 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13097 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13098 session_token = Some(token);
13099 }
13100 }
13101 serve_connection(stream, shared, session_token, preauth_permit).await
13102}
13103
13104async fn accept_connections_inner(
13105 endpoint: &str,
13106 shared: Arc<NodeShared>,
13107 connections: &mut JoinSet<Result<(), NodeServerError>>,
13108) -> Result<(), NodeServerError> {
13109 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13110 loop {
13111 while connections.try_join_next().is_some() {}
13112 if shared.shutdown.load(Ordering::Acquire) {
13113 return Ok(());
13114 }
13115 let preauth_permit = tokio::select! {
13116 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13117 permit.map_err(|_| NodeServerError::PreauthClosed)?
13118 }
13119 _ = shared.shutdown_notify.notified() => return Ok(()),
13120 };
13121 let server = tokio::select! {
13122 result = listener.accept() => result?,
13123 _ = shared.shutdown_notify.notified() => return Ok(()),
13124 };
13125 let connection_shared = Arc::clone(&shared);
13126 connections.spawn(async move {
13127 serve_connection(server, connection_shared, None, preauth_permit).await
13128 });
13129 if shared.shutdown.load(Ordering::Acquire) {
13130 return Ok(());
13131 }
13132 }
13133}
13134
13135async fn serve_connection<S>(
13143 mut pipe: S,
13144 shared: Arc<NodeShared>,
13145 session_token: Option<String>,
13146 preauth_permit: OwnedSemaphorePermit,
13147) -> Result<(), NodeServerError>
13148where
13149 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13150{
13151 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13153 let hello_frame = timeout(
13154 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13155 read_json_frame_limited_body_timeout(
13156 &mut pipe,
13157 MAX_NODE_HELLO_FRAME_BYTES,
13158 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13159 ),
13160 )
13161 .await
13162 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13163 let ClientFrame::Hello(hello) = hello_frame else {
13164 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13165 };
13166 if hello.build_stamp != BUILD_STAMP {
13167 return Err(NodeServerError::Handshake(format!(
13168 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13169 hello.build_stamp,
13170 )));
13171 }
13172 let compatibility = match hello.compatibility.as_ref() {
13173 Some(offer) => {
13174 let mut selected = node_compatibility_support(&shared)?
13175 .negotiate(offer)
13176 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13177 project_negotiated_provider_ids(&mut selected);
13178 Some(selected)
13179 }
13180 None => None,
13181 };
13182 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13183 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13184 (Some(offer), Some(selected)) => negotiated_auth_proof(
13185 auth_token,
13186 AuthDirection::Server,
13187 hello.role,
13188 &hello.client_nonce,
13189 &server_nonce,
13190 offer,
13191 selected,
13192 ),
13193 (None, None) => auth_proof(
13194 auth_token,
13195 AuthDirection::Server,
13196 hello.role,
13197 &hello.client_nonce,
13198 &server_nonce,
13199 ),
13200 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13201 }
13202 .map_err(NodeServerError::Authentication)?;
13203 write_json_frame_limited(
13204 &mut pipe,
13205 &ServerFrame::Challenge(ServerChallenge {
13206 build_stamp: BUILD_STAMP.to_owned(),
13207 server_nonce,
13208 server_proof,
13209 compatibility: compatibility.clone(),
13210 }),
13211 MAX_NODE_HELLO_FRAME_BYTES,
13212 )
13213 .await?;
13214 let authentication = timeout(
13215 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13216 read_json_frame_limited_body_timeout(
13217 &mut pipe,
13218 MAX_NODE_HELLO_FRAME_BYTES,
13219 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13220 ),
13221 )
13222 .await
13223 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13224 let ClientFrame::Authenticate(authentication) = authentication else {
13225 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13226 };
13227 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13228 (Some(offer), Some(selected)) => negotiated_auth_proof(
13229 auth_token,
13230 AuthDirection::Client,
13231 hello.role,
13232 &hello.client_nonce,
13233 &server_nonce,
13234 offer,
13235 selected,
13236 ),
13237 (None, None) => auth_proof(
13238 auth_token,
13239 AuthDirection::Client,
13240 hello.role,
13241 &hello.client_nonce,
13242 &server_nonce,
13243 ),
13244 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13245 }
13246 .map_err(NodeServerError::Authentication)?;
13247 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13248 return Err(NodeServerError::Handshake("access denied".to_owned()));
13249 }
13250 let selected_capabilities = compatibility
13251 .as_ref()
13252 .map(|selected| selected.capabilities.clone())
13253 .unwrap_or_default();
13254 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13255 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13256 });
13257 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13258 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13259 });
13260 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13261 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13262 });
13263 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13264 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13265 });
13266 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13267 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13268 });
13269 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13270 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13271 }) && selected_capabilities.iter().any(|capability| {
13272 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13273 });
13274 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13275 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13276 });
13277 let include_session_bundles = selected_capabilities.iter().any(|capability| {
13278 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13279 });
13280 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13281 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13282 });
13283 let include_agent_progress = selected_capabilities.iter().any(|capability| {
13284 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13285 });
13286 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13287 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13288 });
13289 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13290 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13291 });
13292 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13293 .try_acquire_owned()
13294 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13295 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13296 let _release_guard = ControllerReleaseGuard {
13297 shared: Arc::clone(&shared),
13298 connection_id,
13299 };
13300 drop(preauth_permit);
13301 let _authenticated_permit = authenticated_permit;
13302 let mut event_rx = shared.event_tx.subscribe();
13303 let mut terminal_event_rx = terminal_event_subscription(
13304 &shared,
13305 include_terminal_frame_events,
13306 );
13307 let mut agent_stream_event_rx = agent_stream_event_subscription(
13308 &shared,
13309 include_agent_stream_events,
13310 );
13311 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13312 &shared,
13313 include_harness_mcp_proxy,
13314 );
13315 write_json_frame(
13316 &mut pipe,
13317 &ServerFrame::Hello(NodeHello {
13318 build_stamp: BUILD_STAMP.to_owned(),
13319 incarnation_id: shared.incarnation_id,
13320 connection_id,
13321 role: hello.role,
13322 event_sequence: shared.current_sequence(),
13323 controller: shared.controller_state(),
13324 snapshot: snapshot_for_wire(
13325 &shared,
13326 include_provider_runtime_status,
13327 include_open_provider_ids,
13328 include_spawn_profiles,
13329 include_managed_worktrees,
13330 include_child_environment_profiles,
13331 include_session_bundles,
13332 include_history_context_packs,
13333 include_agent_progress,
13334 include_session_task_correlation,
13335 ),
13336 compatibility,
13337 }),
13338 )
13339 .await?;
13340 let (mut reader, mut writer) = tokio::io::split(pipe);
13341 let (frame_tx, mut frame_rx) = mpsc::channel(16);
13342 let reader_task = tokio::spawn(async move {
13343 loop {
13344 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13345 &mut reader,
13346 MAX_NODE_CLIENT_FRAME_BYTES,
13347 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13348 )
13349 .await;
13350 let terminal = frame.is_err();
13351 if frame_tx.send(frame).await.is_err() || terminal {
13352 break;
13353 }
13354 }
13355 });
13356 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13357 let mut pending_events = Vec::new();
13358 let mut resync_required = false;
13359 let mut discard_events_through = 0_u64;
13360 loop {
13361 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13366 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13367 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13368 for _ in 0..durable_drain_budget {
13369 match event_rx.try_recv() {
13370 Ok(event) => queue_connection_event(
13371 &mut pending_events,
13372 event,
13373 discard_events_through,
13374 ),
13375 Err(broadcast::error::TryRecvError::Empty) => break,
13376 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13377 resync_required = true;
13378 break;
13379 }
13380 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13381 }
13382 }
13383 if let Some(receiver) = terminal_event_rx.as_mut() {
13384 match receiver.try_recv() {
13385 Ok(events) => queue_connection_event_batch(
13386 &mut pending_events,
13387 &events,
13388 discard_events_through,
13389 ),
13390 Err(broadcast::error::TryRecvError::Empty) => {}
13391 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13392 resync_required = true;
13393 }
13394 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13395 }
13396 }
13397 if let Some(receiver) = agent_stream_event_rx.as_mut() {
13398 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13408 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13409 for _ in 0..agent_stream_drain_budget {
13410 match receiver.try_recv() {
13411 Ok(events) => queue_connection_event_batch(
13412 &mut pending_events,
13413 &events,
13414 discard_events_through,
13415 ),
13416 Err(broadcast::error::TryRecvError::Empty) => break,
13417 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13418 tracing::warn!(
13419 connection_id,
13420 dropped,
13421 "agent stream event subscription lagged; forcing a resync",
13422 );
13423 resync_required = true;
13424 break;
13425 }
13426 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13427 }
13428 }
13429 }
13430 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13431 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13437 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13438 for _ in 0..harness_mcp_proxy_drain_budget {
13439 match receiver.try_recv() {
13440 Ok(events) => queue_connection_event_batch(
13441 &mut pending_events,
13442 &events,
13443 discard_events_through,
13444 ),
13445 Err(broadcast::error::TryRecvError::Empty) => break,
13446 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13447 tracing::warn!(
13448 connection_id,
13449 dropped,
13450 "harness MCP proxy event subscription lagged; forcing a resync",
13451 );
13452 resync_required = true;
13453 break;
13454 }
13455 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13456 }
13457 }
13458 }
13459 if resync_required {
13460 let event = resync_required_event(&shared);
13461 apply_connection_resync_watermark(
13462 &mut pending_events,
13463 &mut discard_events_through,
13464 event.sequence,
13465 );
13466 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13467 resync_required = false;
13468 continue;
13469 }
13470 if !pending_events.is_empty() {
13471 pending_events.sort_unstable_by_key(|event| event.sequence);
13472 let burst_len = connection_event_burst_len(pending_events.len());
13473 shared
13474 .connection_events_sent
13475 .fetch_add(burst_len as u64, Ordering::Relaxed);
13476 for event in pending_events.drain(..burst_len) {
13477 let event = if include_managed_worktrees {
13478 Some(event)
13479 } else {
13480 project_event_without_managed_worktrees(event)
13481 };
13482 let event = event.and_then(|event| if include_open_provider_ids {
13483 Some(event)
13484 } else {
13485 project_event_legacy_provider_ids(&shared, event)
13486 });
13487 let event = event.map(|event| if include_child_environment_profiles {
13488 event
13489 } else {
13490 project_event_without_child_environment_profile(event)
13491 });
13492 let event = event.map(|event| if include_session_bundles {
13493 event
13494 } else {
13495 project_event_without_session_bundle(event)
13496 });
13497 let event = event.map(|event| {
13498 project_event_history_for_wire(event, include_history_context_packs)
13499 });
13500 let event = event.map(|event| if include_history_context_packs {
13501 event
13502 } else {
13503 project_event_without_context_pack(event)
13504 });
13505 let event = event.map(|event| if include_session_task_correlation {
13506 event
13507 } else {
13508 project_event_without_session_task_binding(event)
13509 });
13510 let exact_harness_controller = hello.role == ClientRole::Operator
13511 && shared.controller_state().is_some_and(|controller| {
13512 controller.connection_id == connection_id
13513 });
13514 let event = event.and_then(|event| {
13515 if include_harness_mcp_proxy && exact_harness_controller {
13516 Some(event)
13517 } else {
13518 project_event_without_harness_mcp_proxy(event)
13519 }
13520 });
13521 if let Some(event) = event {
13522 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13523 }
13524 }
13525 }
13526 tokio::select! {
13527 biased;
13528 frame = frame_rx.recv() => {
13529 let frame = match frame {
13530 Some(frame) => frame,
13531 None => break,
13532 };
13533 let frame = match frame {
13534 Ok(frame) => frame,
13535 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13536 Err(error) => return Err(error.into()),
13537 };
13538 let ClientFrame::Request(request) = frame else {
13539 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13540 };
13541 #[cfg(feature = "fixture")]
13542 let probe_spawn_managed_worktree_v2 = matches!(
13543 &request.request,
13544 NodeRequest::SpawnManagedWorktreeV2 { .. }
13545 );
13546 let requires_open_provider_ids =
13547 request_requires_open_provider_ids(&shared, &request.request);
13548 let requires_child_environment_profile =
13549 request_requires_child_environment_profile(&shared, &request.request);
13550 let requires_session_bundle =
13551 request_requires_session_bundle(&shared, &request.request);
13552 let requires_history_context_pack =
13553 request_requires_history_context_pack(&shared, &request.request);
13554 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13555 ResponseEnvelope {
13556 request_id: request.request_id,
13557 result: Err(failure(
13558 NodeFailureCode::UnsupportedCapability,
13559 "open provider IDs were not negotiated",
13560 )),
13561 }
13562 } else if requires_child_environment_profile
13563 && !include_child_environment_profiles
13564 {
13565 ResponseEnvelope {
13566 request_id: request.request_id,
13567 result: Err(failure(
13568 NodeFailureCode::UnsupportedCapability,
13569 "child environment profiles were not negotiated",
13570 )),
13571 }
13572 } else if requires_session_bundle && !include_session_bundles {
13573 ResponseEnvelope {
13574 request_id: request.request_id,
13575 result: Err(failure(
13576 NodeFailureCode::UnsupportedCapability,
13577 "session bundles were not negotiated",
13578 )),
13579 }
13580 } else if requires_history_context_pack && !include_history_context_packs {
13581 ResponseEnvelope {
13582 request_id: request.request_id,
13583 result: Err(failure(
13584 NodeFailureCode::UnsupportedCapability,
13585 "history context packs were not negotiated",
13586 )),
13587 }
13588 } else if request_uses_unnegotiated_capability(
13589 &request.request,
13590 &selected_capabilities,
13591 )
13592 {
13593 ResponseEnvelope {
13594 request_id: request.request_id,
13595 result: Err(failure(
13596 NodeFailureCode::UnsupportedCapability,
13597 "request capability was not negotiated",
13598 )),
13599 }
13600 } else {
13601 process_request(&shared, connection_id, hello.role, request).await
13602 };
13603 #[cfg(feature = "fixture")]
13604 if probe_spawn_managed_worktree_v2 {
13605 shared
13606 .fixture_spawn_managed_worktree_v2_failure_probe
13607 .record(reply.result.as_ref().err().map(|failure| failure.code));
13608 }
13609 if !include_provider_runtime_status {
13610 clear_response_provider_runtime_status(&mut reply);
13611 }
13612 if !include_managed_worktrees {
13613 project_response_without_managed_worktrees(&mut reply);
13614 }
13615 if !include_open_provider_ids {
13616 project_response_legacy_provider_ids(&shared, &mut reply);
13617 }
13618 if !include_child_environment_profiles {
13619 project_response_without_child_environment_profile(&mut reply);
13620 }
13621 if !include_session_bundles {
13622 project_response_without_session_bundle(&mut reply);
13623 }
13624 project_response_history_for_wire(
13625 &mut reply,
13626 include_history_context_packs,
13627 );
13628 if !include_history_context_packs {
13629 project_response_without_context_pack(&mut reply);
13630 }
13631 if !include_agent_progress {
13632 project_response_without_agent_progress(&mut reply);
13633 }
13634 if !include_session_task_correlation {
13635 project_response_without_session_task_binding(&mut reply);
13636 }
13637 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13638 }
13639 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13640 event = event_rx.recv() => {
13641 match event {
13642 Ok(event) => queue_connection_event(
13643 &mut pending_events,
13644 event,
13645 discard_events_through,
13646 ),
13647 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13648 Err(broadcast::error::RecvError::Closed) => break,
13649 }
13650 }
13651 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13652 match events {
13653 Ok(events) => queue_connection_event_batch(
13654 &mut pending_events,
13655 &events,
13656 discard_events_through,
13657 ),
13658 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13659 Err(broadcast::error::RecvError::Closed) => break,
13660 }
13661 }
13662 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13663 match events {
13664 Ok(events) => queue_connection_event_batch(
13665 &mut pending_events,
13666 &events,
13667 discard_events_through,
13668 ),
13669 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13670 Err(broadcast::error::RecvError::Closed) => break,
13671 }
13672 }
13673 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13674 match events {
13675 Ok(events) => queue_connection_event_batch(
13676 &mut pending_events,
13677 &events,
13678 discard_events_through,
13679 ),
13680 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13681 Err(broadcast::error::RecvError::Closed) => break,
13682 }
13683 }
13684 _ = shared.shutdown_notify.notified() => break,
13685 }
13686 }
13687 Ok(())
13688}
13689
13690async fn receive_terminal_event_batch(
13691 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13692) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13693 match receiver {
13694 Some(receiver) => receiver.recv().await,
13695 None => std::future::pending().await,
13696 }
13697}
13698
13699fn terminal_event_subscription(
13700 shared: &NodeShared,
13701 enabled: bool,
13702) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13703 enabled.then(|| shared.terminal_event_tx.subscribe())
13704}
13705
13706fn agent_stream_event_subscription(
13710 shared: &NodeShared,
13711 enabled: bool,
13712) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13713 enabled.then(|| shared.agent_stream_event_tx.subscribe())
13714}
13715
13716fn harness_mcp_proxy_event_subscription(
13721 shared: &NodeShared,
13722 enabled: bool,
13723) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13724 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13725}
13726
13727fn queue_connection_event(
13728 pending: &mut Vec<NodeEventEnvelope>,
13729 event: NodeEventEnvelope,
13730 discard_through: u64,
13731) {
13732 if event.sequence <= discard_through
13733 && !event.event.requires_harness_mcp_proxy_capability()
13734 {
13735 return;
13736 }
13737 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13738 if let Some(index) = pending.iter().position(|current| {
13739 matches!(
13740 ¤t.event,
13741 NodeEvent::TerminalFrame {
13742 address: current_address,
13743 ..
13744 } if current_address == address
13745 )
13746 }) {
13747 pending.remove(index);
13748 }
13749 }
13750 pending.push(event);
13751}
13752
13753fn project_event_without_harness_mcp_proxy(
13754 envelope: NodeEventEnvelope,
13755) -> Option<NodeEventEnvelope> {
13756 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13757}
13758
13759fn queue_connection_event_batch(
13760 pending: &mut Vec<NodeEventEnvelope>,
13761 events: &[NodeEventEnvelope],
13762 discard_through: u64,
13763) {
13764 for event in events {
13765 queue_connection_event(pending, event.clone(), discard_through);
13766 }
13767}
13768
13769fn apply_connection_resync_watermark(
13770 pending: &mut Vec<NodeEventEnvelope>,
13771 discard_through: &mut u64,
13772 marker_sequence: u64,
13773) {
13774 *discard_through = (*discard_through).max(marker_sequence);
13775 pending.retain(|event| event.sequence > *discard_through);
13776}
13777
13778fn connection_event_burst_len(pending_len: usize) -> usize {
13779 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13780}
13781
13782fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13783 let history = shared
13784 .history
13785 .lock()
13786 .unwrap_or_else(|poisoned| poisoned.into_inner());
13787 let sequence = history.last_sequence;
13788 let oldest_available_sequence = history.replay_floor_sequence;
13789 NodeEventEnvelope {
13790 sequence,
13791 event: NodeEvent::ResyncRequired {
13792 oldest_available_sequence,
13793 },
13794 }
13795}
13796
13797fn node_compatibility_support(
13798 shared: &NodeShared,
13799) -> Result<NodeCompatibilitySupport, NodeServerError> {
13800 let mut support = node_compatibility_support_for_manifest(
13801 &shared.provider_contracts,
13802 &shared.provider_adapter_contracts,
13803 )?;
13804 if shared
13805 .delivery_store
13806 .lock()
13807 .unwrap_or_else(|poisoned| poisoned.into_inner())
13808 .is_some()
13809 {
13810 support.capabilities.push(
13811 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13812 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13813 );
13814 }
13815 if shared.harness_mcp_registry.is_some() {
13816 support.capabilities.push(
13817 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
13818 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13819 );
13820 }
13821 Ok(support)
13822}
13823
13824fn node_compatibility_support_for_manifest(
13825 provider_contracts: &[ProviderContractSupport],
13826 provider_adapter_contracts: &[ProviderAdapterContractSupport],
13827) -> Result<NodeCompatibilitySupport, NodeServerError> {
13828 Ok(NodeCompatibilitySupport {
13829 build_stamp: BUILD_STAMP.to_owned(),
13830 capabilities: baseline_capabilities()?,
13831 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
13832 path_semantics: platform::path_semantics(),
13833 local_transport: platform::local_transport(),
13834 state_schema: StateSchemaSupport {
13835 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
13836 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13837 },
13838 provider_contracts: provider_contracts.to_vec(),
13839 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
13840 })
13841}
13842
13843fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
13844 let capabilities = [
13845 NODE_COMPATIBILITY_METADATA_CAPABILITY,
13846 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
13847 NODE_REPOSITORY_PATH_CAPABILITY,
13848 NODE_WORKSPACE_FILE_READ_CAPABILITY,
13849 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
13850 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
13851 NODE_GIT_READ_CAPABILITY,
13852 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
13853 NODE_PROVIDER_ID_OPEN_CAPABILITY,
13854 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
13855 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
13856 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
13857 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
13858 NODE_ACP_CONTROL_CAPABILITY,
13859 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
13860 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
13861 NODE_WORKTREE_SELECTION_CAPABILITY,
13862 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
13863 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
13864 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
13865 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
13866 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
13867 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
13868 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
13869 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
13870 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
13871 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
13872 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
13873 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
13874 ]
13875 .into_iter()
13876 .map(|capability| {
13877 CapabilityId::new(capability)
13878 .map_err(|error| NodeServerError::Handshake(error.to_string()))
13879 })
13880 .collect::<Result<Vec<_>, _>>()?;
13881 #[cfg(windows)]
13882 let capabilities = {
13883 let mut capabilities = capabilities;
13884 capabilities.push(
13885 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
13886 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13887 );
13888 capabilities
13889 };
13890 Ok(capabilities)
13891}
13892
13893fn request_uses_unnegotiated_capability(
13894 request: &NodeRequest,
13895 selected_capabilities: &[CapabilityId],
13896) -> bool {
13897 let selected = |required: &str| {
13898 selected_capabilities
13899 .iter()
13900 .any(|capability| capability.as_str() == required)
13901 };
13902 request
13903 .required_capability()
13904 .is_some_and(|required| !selected(required))
13905 || (request.requires_worktree_selection_capability()
13906 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
13907 || (request.requires_spawn_spec_defaults_overrides_capability()
13908 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
13909 || (request.requires_spawn_profile_revision_capability()
13910 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
13911 || (request.requires_child_environment_profile_capability()
13912 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
13913 || (request.requires_session_bundle_materialization_capability()
13914 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
13915 || (request.requires_history_context_pack_capability()
13916 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
13917}
13918
13919fn snapshot_for_wire(
13920 shared: &NodeShared,
13921 include_provider_runtime_status: bool,
13922 include_open_provider_ids: bool,
13923 include_spawn_profiles: bool,
13924 include_managed_worktrees: bool,
13925 include_child_environment_profiles: bool,
13926 include_session_bundles: bool,
13927 include_history_context_packs: bool,
13928 include_agent_progress: bool,
13929 include_session_task_correlation: bool,
13930) -> NodeSnapshot {
13931 let mut snapshot = shared.snapshot();
13932 if !include_provider_runtime_status {
13933 snapshot.provider_runtime_statuses.clear();
13934 }
13935 if !include_open_provider_ids {
13936 project_snapshot_legacy_provider_ids(&mut snapshot);
13937 }
13938 if !include_managed_worktrees {
13939 snapshot.managed_worktrees.clear();
13940 for workspace in &mut snapshot.workspaces {
13941 workspace.managed_worktree_profiles = None;
13942 }
13943 }
13944 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
13945 if !include_spawn_profiles {
13946 inventory.spawn_profiles = None;
13947 }
13948 if !include_session_bundles {
13949 inventory.bundles = None;
13950 }
13951 if inventory.spawn_profiles.is_none()
13952 && inventory.bundles.is_none()
13953 && inventory.network_allowlists.is_none()
13954 {
13955 snapshot.launch_inventory = None;
13956 }
13957 }
13958 if !include_child_environment_profiles {
13959 clear_snapshot_child_environment_profiles(&mut snapshot);
13960 }
13961 if !include_session_bundles {
13962 clear_snapshot_session_bundles(&mut snapshot);
13963 }
13964 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
13965 if !include_history_context_packs {
13966 clear_snapshot_context_packs(&mut snapshot);
13967 }
13968 if !include_agent_progress {
13969 snapshot.agent_progress.clear();
13970 }
13971 if !include_session_task_correlation {
13972 clear_snapshot_session_task_bindings(&mut snapshot);
13973 }
13974 snapshot
13975}
13976
13977fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
13978 for record in &mut snapshot.session_records {
13979 record.task_binding = None;
13980 }
13981}
13982
13983fn project_event_without_session_task_binding(
13984 mut envelope: NodeEventEnvelope,
13985) -> NodeEventEnvelope {
13986 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
13987 record.task_binding = None;
13988 }
13989 envelope
13990}
13991
13992fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
13993 let Ok(response) = reply.result.as_mut() else { return };
13994 match response {
13995 NodeResponse::Snapshot { snapshot, .. } => {
13996 clear_snapshot_session_task_bindings(snapshot);
13997 }
13998 NodeResponse::Resync { snapshot, events, .. } => {
13999 clear_snapshot_session_task_bindings(snapshot);
14000 for event in events {
14001 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14002 record.task_binding = None;
14003 }
14004 }
14005 }
14006 NodeResponse::SessionRecordUpdated { record }
14007 | NodeResponse::ProviderSessionIndexed { record }
14008 | NodeResponse::NativeSessionIndexed { record, .. }
14009 | NodeResponse::SessionRecordResumed { record, .. } => {
14010 record.task_binding = None;
14011 }
14012 _ => {}
14013 }
14014}
14015
14016fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14017 let Ok(response) = reply.result.as_mut() else { return };
14018 match response {
14019 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14020 snapshot.agent_progress.clear();
14021 }
14022 _ => {}
14023 }
14024}
14025
14026fn project_event_without_managed_worktrees(
14027 mut envelope: NodeEventEnvelope,
14028) -> Option<NodeEventEnvelope> {
14029 if matches!(
14030 envelope.event,
14031 NodeEvent::ManagedWorktreeUpserted { .. }
14032 | NodeEvent::ManagedWorktreeRemoved { .. }
14033 ) {
14034 return None;
14035 }
14036 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14037 workspace.managed_worktree_profiles = None;
14038 }
14039 Some(envelope)
14040}
14041
14042fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14043 if matches!(
14044 reply.result,
14045 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14046 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14047 ) {
14048 reply.result = Err(failure(
14049 NodeFailureCode::UnsupportedCapability,
14050 "managed worktree capability was not negotiated",
14051 ));
14052 return;
14053 }
14054 let Ok(response) = reply.result.as_mut() else { return };
14055 match response {
14056 NodeResponse::Snapshot { snapshot, .. } => {
14057 snapshot.managed_worktrees.clear();
14058 for workspace in &mut snapshot.workspaces {
14059 workspace.managed_worktree_profiles = None;
14060 }
14061 }
14062 NodeResponse::Resync { snapshot, events, .. } => {
14063 snapshot.managed_worktrees.clear();
14064 for workspace in &mut snapshot.workspaces {
14065 workspace.managed_worktree_profiles = None;
14066 }
14067 *events = std::mem::take(events)
14068 .into_iter()
14069 .filter_map(project_event_without_managed_worktrees)
14070 .collect();
14071 }
14072 NodeResponse::WorkspaceRegistered { workspace }
14073 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14074 | NodeResponse::WorktreeCreated { workspace, .. } => {
14075 workspace.managed_worktree_profiles = None;
14076 }
14077 NodeResponse::WorkspaceInspected { inspection } => {
14078 inspection.git.managed_worktree = None;
14079 }
14080 _ => {}
14081 }
14082}
14083
14084fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14085 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14086 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14087 });
14088 if includes_open_provider_ids {
14089 return;
14090 }
14091 compatibility
14092 .provider_contracts
14093 .retain(|contract| provider_id_is_legacy(&contract.provider));
14094 compatibility
14095 .provider_adapter_contracts
14096 .retain(|contract| provider_id_is_legacy(&contract.provider));
14097}
14098
14099fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14100 if let Some(spec) = match request {
14101 NodeRequest::SpawnSpec { spec }
14102 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14103 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14104 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14105 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14106 _ => None,
14107 } {
14108 return shared
14109 .resolve_spawn_spec(spec)
14110 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14111 }
14112 request_requires_open_provider_ids_with(
14113 request,
14114 |session| shared.validate_address(session).ok(),
14115 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14116 )
14117}
14118
14119fn request_requires_child_environment_profile(
14120 shared: &NodeShared,
14121 request: &NodeRequest,
14122) -> bool {
14123 if let Some(spec) = match request {
14124 NodeRequest::SpawnSpec { spec }
14125 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14126 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14127 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14128 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14129 _ => None,
14130 } {
14131 return shared
14132 .resolve_spawn_spec(spec)
14133 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14134 }
14135 match request {
14136 NodeRequest::Resume { session, .. }
14137 | NodeRequest::Prompt { session, .. }
14138 | NodeRequest::Paste { session, .. }
14139 | NodeRequest::Input { session, .. }
14140 | NodeRequest::TerminalBytes { session, .. }
14141 | NodeRequest::TerminalControl { session, .. }
14142 | NodeRequest::Resize { session, .. }
14143 | NodeRequest::Interrupt { session }
14144 | NodeRequest::Stop { session, .. }
14145 | NodeRequest::Remove { session }
14146 | NodeRequest::ResolveInteraction { session, .. }
14147 | NodeRequest::SetSessionMode { session, .. }
14148 | NodeRequest::SetSessionConfigOption { session, .. }
14149 | NodeRequest::SetSessionModel { session, .. }
14150 | NodeRequest::DiscoverHistory { session, .. }
14151 | NodeRequest::LoadHistory { session, .. }
14152 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14153 | NodeRequest::ExportContextPack { session } => shared
14154 .session_bindings
14155 .lock()
14156 .unwrap_or_else(|poisoned| poisoned.into_inner())
14157 .get(&session.session.instance_id)
14158 .filter(|binding| {
14159 binding.workspace_id == session.workspace_id
14160 && binding.generation == session.session.generation
14161 })
14162 .map_or(true, |binding| binding.environment_profile.is_some()),
14163 NodeRequest::RenameSessionRecord { record_id, .. }
14164 | NodeRequest::SetSessionTask { record_id, .. }
14165 | NodeRequest::ResumeSessionRecord { record_id, .. }
14166 | NodeRequest::ForgetSessionRecord { record_id } => shared
14167 .record(record_id)
14168 .map_or(true, |record| record.environment_profile.is_some()),
14169 NodeRequest::ArmHarnessMcpReservation { .. }
14170 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14171 | NodeRequest::ActivateHarnessMcpReservation { .. }
14172 | NodeRequest::AbortHarnessMcpReservation { .. }
14173 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14174 | NodeRequest::RejectHarnessMcpCall { .. }
14175 | NodeRequest::Snapshot
14176 | NodeRequest::Resync { .. }
14177 | NodeRequest::BeginDeliveryStage { .. }
14178 | NodeRequest::PutDeliveryBlobChunk { .. }
14179 | NodeRequest::CommitDeliveryStage { .. }
14180 | NodeRequest::AbortDeliveryStage { .. }
14181 | NodeRequest::BrowseHostDirectories { .. }
14182 | NodeRequest::InspectWorkspace { .. }
14183 | NodeRequest::ReadWorkspaceFile { .. }
14184 | NodeRequest::WriteWorkspaceFile { .. }
14185 | NodeRequest::CreateWorkspaceFile { .. }
14186 | NodeRequest::CreateWorkspaceDirectory { .. }
14187 | NodeRequest::ReadGitHistory { .. }
14188 | NodeRequest::ReadGitDiff { .. }
14189 | NodeRequest::AcquireController { .. }
14190 | NodeRequest::ReleaseController
14191 | NodeRequest::Spawn { .. }
14192 | NodeRequest::SpawnSpec { .. }
14193 | NodeRequest::RegisterWorkspace { .. }
14194 | NodeRequest::CreateStandaloneWorkspace { .. }
14195 | NodeRequest::UnregisterWorkspace { .. }
14196 | NodeRequest::CreateWorktree { .. }
14197 | NodeRequest::RemoveWorktree { .. }
14198 | NodeRequest::SpawnManagedWorktree { .. }
14199 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14200 | NodeRequest::CleanupManagedWorktree { .. }
14201 | NodeRequest::IndexProviderSession { .. }
14202 | NodeRequest::IndexNativeSession { .. }
14203 | NodeRequest::CatalogNativeSessions { .. }
14204 | NodeRequest::PageNativeSessions { .. }
14205 | NodeRequest::PreviewNativeSession { .. }
14206 | NodeRequest::PreviewSessionRecord { .. }
14207 | NodeRequest::ForgetContextPack { .. }
14208 | NodeRequest::ResolveDurableContextPack { .. }
14209 | NodeRequest::ReadContextPack { .. }
14210 | NodeRequest::Shutdown => false,
14211 }
14212}
14213
14214fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14215 if let Some(spec) = match request {
14216 NodeRequest::SpawnSpec { spec }
14217 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14218 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14219 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14220 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14221 _ => None,
14222 } {
14223 return shared
14224 .resolve_spawn_spec(spec)
14225 .is_ok_and(|resolved| resolved.bundle_id.is_some());
14226 }
14227 match request {
14228 NodeRequest::Resume { session, .. }
14229 | NodeRequest::Prompt { session, .. }
14230 | NodeRequest::Paste { session, .. }
14231 | NodeRequest::Input { session, .. }
14232 | NodeRequest::TerminalBytes { session, .. }
14233 | NodeRequest::TerminalControl { session, .. }
14234 | NodeRequest::Resize { session, .. }
14235 | NodeRequest::Interrupt { session }
14236 | NodeRequest::Stop { session, .. }
14237 | NodeRequest::Remove { session }
14238 | NodeRequest::ResolveInteraction { session, .. }
14239 | NodeRequest::SetSessionMode { session, .. }
14240 | NodeRequest::SetSessionConfigOption { session, .. }
14241 | NodeRequest::SetSessionModel { session, .. }
14242 | NodeRequest::DiscoverHistory { session, .. }
14243 | NodeRequest::LoadHistory { session, .. }
14244 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14245 | NodeRequest::ExportContextPack { session } => shared
14246 .session_bindings
14247 .lock()
14248 .unwrap_or_else(|poisoned| poisoned.into_inner())
14249 .get(&session.session.instance_id)
14250 .filter(|binding| {
14251 binding.workspace_id == session.workspace_id
14252 && binding.generation == session.session.generation
14253 })
14254 .map_or(true, |binding| binding.bundle.is_some()),
14255 NodeRequest::RenameSessionRecord { record_id, .. }
14256 | NodeRequest::SetSessionTask { record_id, .. }
14257 | NodeRequest::ResumeSessionRecord { record_id, .. }
14258 | NodeRequest::ForgetSessionRecord { record_id } => shared
14259 .record(record_id)
14260 .map_or(true, |record| record.bundle.is_some()),
14261 NodeRequest::ArmHarnessMcpReservation { .. }
14262 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14263 | NodeRequest::ActivateHarnessMcpReservation { .. }
14264 | NodeRequest::AbortHarnessMcpReservation { .. }
14265 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14266 | NodeRequest::RejectHarnessMcpCall { .. }
14267 | NodeRequest::Snapshot
14268 | NodeRequest::Resync { .. }
14269 | NodeRequest::BeginDeliveryStage { .. }
14270 | NodeRequest::PutDeliveryBlobChunk { .. }
14271 | NodeRequest::CommitDeliveryStage { .. }
14272 | NodeRequest::AbortDeliveryStage { .. }
14273 | NodeRequest::BrowseHostDirectories { .. }
14274 | NodeRequest::InspectWorkspace { .. }
14275 | NodeRequest::ReadWorkspaceFile { .. }
14276 | NodeRequest::WriteWorkspaceFile { .. }
14277 | NodeRequest::CreateWorkspaceFile { .. }
14278 | NodeRequest::CreateWorkspaceDirectory { .. }
14279 | NodeRequest::ReadGitHistory { .. }
14280 | NodeRequest::ReadGitDiff { .. }
14281 | NodeRequest::AcquireController { .. }
14282 | NodeRequest::ReleaseController
14283 | NodeRequest::Spawn { .. }
14284 | NodeRequest::SpawnSpec { .. }
14285 | NodeRequest::RegisterWorkspace { .. }
14286 | NodeRequest::CreateStandaloneWorkspace { .. }
14287 | NodeRequest::UnregisterWorkspace { .. }
14288 | NodeRequest::CreateWorktree { .. }
14289 | NodeRequest::RemoveWorktree { .. }
14290 | NodeRequest::SpawnManagedWorktree { .. }
14291 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14292 | NodeRequest::CleanupManagedWorktree { .. }
14293 | NodeRequest::IndexProviderSession { .. }
14294 | NodeRequest::IndexNativeSession { .. }
14295 | NodeRequest::CatalogNativeSessions { .. }
14296 | NodeRequest::PageNativeSessions { .. }
14297 | NodeRequest::PreviewNativeSession { .. }
14298 | NodeRequest::PreviewSessionRecord { .. }
14299 | NodeRequest::ForgetContextPack { .. }
14300 | NodeRequest::ResolveDurableContextPack { .. }
14301 | NodeRequest::ReadContextPack { .. }
14302 | NodeRequest::Shutdown => false,
14303 }
14304}
14305
14306fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14307 if request.requires_history_context_pack_capability() {
14308 return true;
14309 }
14310 if let Some(spec) = match request {
14311 NodeRequest::SpawnSpec { spec }
14312 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14313 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14314 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14315 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14316 _ => None,
14317 } {
14318 return shared
14319 .resolve_spawn_spec(spec)
14320 .map_or(true, |resolved| resolved.context_id.is_some());
14321 }
14322 match request {
14323 NodeRequest::Resume { session, .. }
14324 | NodeRequest::Prompt { session, .. }
14325 | NodeRequest::Paste { session, .. }
14326 | NodeRequest::Input { session, .. }
14327 | NodeRequest::TerminalBytes { session, .. }
14328 | NodeRequest::TerminalControl { session, .. }
14329 | NodeRequest::Resize { session, .. }
14330 | NodeRequest::Interrupt { session }
14331 | NodeRequest::Stop { session, .. }
14332 | NodeRequest::Remove { session }
14333 | NodeRequest::ResolveInteraction { session, .. }
14334 | NodeRequest::SetSessionMode { session, .. }
14335 | NodeRequest::SetSessionConfigOption { session, .. }
14336 | NodeRequest::SetSessionModel { session, .. } => shared
14337 .session_bindings
14338 .lock()
14339 .unwrap_or_else(|poisoned| poisoned.into_inner())
14340 .get(&session.session.instance_id)
14341 .filter(|binding| {
14342 binding.workspace_id == session.workspace_id
14343 && binding.generation == session.session.generation
14344 })
14345 .map_or(true, |binding| binding.context.is_some()),
14346 NodeRequest::RenameSessionRecord { record_id, .. }
14347 | NodeRequest::SetSessionTask { record_id, .. }
14348 | NodeRequest::ResumeSessionRecord { record_id, .. }
14349 | NodeRequest::ForgetSessionRecord { record_id } => shared
14350 .record(record_id)
14351 .map_or(true, |record| record.context.is_some()),
14352 NodeRequest::ArmHarnessMcpReservation { .. }
14353 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14354 | NodeRequest::ActivateHarnessMcpReservation { .. }
14355 | NodeRequest::AbortHarnessMcpReservation { .. }
14356 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14357 | NodeRequest::RejectHarnessMcpCall { .. }
14358 | NodeRequest::Snapshot
14359 | NodeRequest::Resync { .. }
14360 | NodeRequest::BeginDeliveryStage { .. }
14361 | NodeRequest::PutDeliveryBlobChunk { .. }
14362 | NodeRequest::CommitDeliveryStage { .. }
14363 | NodeRequest::AbortDeliveryStage { .. }
14364 | NodeRequest::BrowseHostDirectories { .. }
14365 | NodeRequest::InspectWorkspace { .. }
14366 | NodeRequest::ReadWorkspaceFile { .. }
14367 | NodeRequest::WriteWorkspaceFile { .. }
14368 | NodeRequest::CreateWorkspaceFile { .. }
14369 | NodeRequest::CreateWorkspaceDirectory { .. }
14370 | NodeRequest::ReadGitHistory { .. }
14371 | NodeRequest::ReadGitDiff { .. }
14372 | NodeRequest::AcquireController { .. }
14373 | NodeRequest::ReleaseController
14374 | NodeRequest::Spawn { .. }
14375 | NodeRequest::SpawnSpec { .. }
14376 | NodeRequest::RegisterWorkspace { .. }
14377 | NodeRequest::CreateStandaloneWorkspace { .. }
14378 | NodeRequest::UnregisterWorkspace { .. }
14379 | NodeRequest::CreateWorktree { .. }
14380 | NodeRequest::RemoveWorktree { .. }
14381 | NodeRequest::SpawnManagedWorktree { .. }
14382 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14383 | NodeRequest::CleanupManagedWorktree { .. }
14384 | NodeRequest::IndexProviderSession { .. }
14385 | NodeRequest::IndexNativeSession { .. }
14386 | NodeRequest::CatalogNativeSessions { .. }
14387 | NodeRequest::PageNativeSessions { .. }
14388 | NodeRequest::PreviewNativeSession { .. }
14389 | NodeRequest::PreviewSessionRecord { .. }
14390 | NodeRequest::DiscoverHistory { .. }
14391 | NodeRequest::LoadHistory { .. }
14392 | NodeRequest::ExportContextPackForSessionRecord { .. }
14393 | NodeRequest::ExportContextPack { .. }
14394 | NodeRequest::ForgetContextPack { .. }
14395 | NodeRequest::ResolveDurableContextPack { .. }
14396 | NodeRequest::ReadContextPack { .. }
14397 | NodeRequest::Shutdown => false,
14398 }
14399}
14400
14401fn request_requires_open_provider_ids_with(
14402 request: &NodeRequest,
14403 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14404 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14405) -> bool {
14406 match request {
14407 NodeRequest::Spawn { provider, .. } => !provider_id_is_legacy(provider),
14408 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14409 NodeRequest::CatalogNativeSessions { route, .. }
14410 | NodeRequest::PageNativeSessions { route, .. } => {
14411 !provider_id_is_legacy(&route.provider)
14412 }
14413 NodeRequest::PreviewNativeSession { selection, .. }
14414 | NodeRequest::IndexNativeSession { selection, .. } => {
14415 !provider_id_is_legacy(&selection.route.provider)
14416 }
14417 NodeRequest::SpawnSpec { .. }
14418 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14419 NodeRequest::SpawnManagedWorktree { .. }
14420 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14421 NodeRequest::Resume { session, .. }
14422 | NodeRequest::Prompt { session, .. }
14423 | NodeRequest::Paste { session, .. }
14424 | NodeRequest::Input { session, .. }
14425 | NodeRequest::TerminalBytes { session, .. }
14426 | NodeRequest::TerminalControl { session, .. }
14427 | NodeRequest::Resize { session, .. }
14428 | NodeRequest::Interrupt { session }
14429 | NodeRequest::Stop { session, .. }
14430 | NodeRequest::Remove { session }
14431 | NodeRequest::ResolveInteraction { session, .. }
14432 | NodeRequest::SetSessionMode { session, .. }
14433 | NodeRequest::SetSessionConfigOption { session, .. }
14434 | NodeRequest::SetSessionModel { session, .. }
14435 | NodeRequest::DiscoverHistory { session, .. }
14436 | NodeRequest::LoadHistory { session, .. }
14437 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14438 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14439 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14440 NodeRequest::RenameSessionRecord { record_id, .. }
14441 | NodeRequest::SetSessionTask { record_id, .. }
14442 | NodeRequest::ResumeSessionRecord { record_id, .. }
14443 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14444 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14445 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14446 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14447 NodeRequest::ArmHarnessMcpReservation { .. }
14448 | NodeRequest::ActivateHarnessMcpReservation { .. }
14449 | NodeRequest::AbortHarnessMcpReservation { .. }
14450 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14451 | NodeRequest::RejectHarnessMcpCall { .. }
14452 | NodeRequest::Snapshot
14453 | NodeRequest::Resync { .. }
14454 | NodeRequest::BeginDeliveryStage { .. }
14455 | NodeRequest::PutDeliveryBlobChunk { .. }
14456 | NodeRequest::CommitDeliveryStage { .. }
14457 | NodeRequest::AbortDeliveryStage { .. }
14458 | NodeRequest::BrowseHostDirectories { .. }
14459 | NodeRequest::InspectWorkspace { .. }
14460 | NodeRequest::ReadWorkspaceFile { .. }
14461 | NodeRequest::WriteWorkspaceFile { .. }
14462 | NodeRequest::CreateWorkspaceFile { .. }
14463 | NodeRequest::CreateWorkspaceDirectory { .. }
14464 | NodeRequest::ReadGitHistory { .. }
14465 | NodeRequest::ReadGitDiff { .. }
14466 | NodeRequest::AcquireController { .. }
14467 | NodeRequest::ReleaseController
14468 | NodeRequest::RegisterWorkspace { .. }
14469 | NodeRequest::CreateStandaloneWorkspace { .. }
14470 | NodeRequest::UnregisterWorkspace { .. }
14471 | NodeRequest::CreateWorktree { .. }
14472 | NodeRequest::RemoveWorktree { .. }
14473 | NodeRequest::CleanupManagedWorktree { .. }
14474 | NodeRequest::Shutdown => false,
14475 NodeRequest::ForgetContextPack { .. } => true,
14476 NodeRequest::ResolveDurableContextPack { .. }
14482 | NodeRequest::ReadContextPack { .. } => true,
14483 }
14484}
14485
14486fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14487 snapshot.enabled_providers.retain(provider_id_is_legacy);
14488 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14489 snapshot
14490 .provider_runtime_statuses
14491 .as_slice()
14492 .iter()
14493 .filter(|status| provider_id_is_legacy(status.provider()))
14494 .cloned(),
14495 )
14496 .expect("a filtered provider runtime projection remains valid");
14497 snapshot
14498 .session_records
14499 .retain(|record| provider_id_is_legacy(&record.provider));
14500 for workspace in &mut snapshot.workspaces {
14501 workspace
14502 .sessions
14503 .retain(|session| provider_id_is_legacy(&session.agent_id));
14504 }
14505}
14506
14507fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14508 for record in &mut snapshot.session_records {
14509 record.environment_profile = None;
14510 }
14511 if let Some(profiles) = snapshot
14512 .launch_inventory
14513 .as_mut()
14514 .and_then(|inventory| inventory.spawn_profiles.as_mut())
14515 {
14516 for profile in profiles {
14517 profile.environment_profile = None;
14518 }
14519 }
14520}
14521
14522fn project_event_without_child_environment_profile(
14523 mut envelope: NodeEventEnvelope,
14524) -> NodeEventEnvelope {
14525 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14526 record.environment_profile = None;
14527 }
14528 envelope
14529}
14530
14531fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14532 let contains_environment_profile = match reply.result.as_ref() {
14533 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14534 receipt.environment_profile.is_some()
14535 }
14536 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14537 receipt.spawn.environment_profile.is_some()
14538 }
14539 _ => false,
14540 };
14541 if contains_environment_profile {
14542 reply.result = Err(failure(
14543 NodeFailureCode::UnsupportedCapability,
14544 "child environment profiles were not negotiated",
14545 ));
14546 return;
14547 }
14548 let Ok(response) = reply.result.as_mut() else {
14549 return;
14550 };
14551 match response {
14552 NodeResponse::Snapshot { snapshot, .. } => {
14553 clear_snapshot_child_environment_profiles(snapshot);
14554 }
14555 NodeResponse::Resync { snapshot, events, .. } => {
14556 clear_snapshot_child_environment_profiles(snapshot);
14557 for event in events {
14558 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14559 record.environment_profile = None;
14560 }
14561 }
14562 }
14563 NodeResponse::SessionRecordUpdated { record }
14564 | NodeResponse::ProviderSessionIndexed { record }
14565 | NodeResponse::NativeSessionIndexed { record, .. }
14566 | NodeResponse::SessionRecordResumed { record, .. } => {
14567 record.environment_profile = None;
14568 }
14569 NodeResponse::Armed { .. }
14570 | NodeResponse::Spawned { .. }
14571 | NodeResponse::Activated { .. }
14572 | NodeResponse::Aborted { .. }
14573 | NodeResponse::ReplyChunkAccepted { .. }
14574 | NodeResponse::CallRejected { .. }
14575 | NodeResponse::DeliveryStageBegun { .. }
14576 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14577 | NodeResponse::DeliveryCommitted { .. }
14578 | NodeResponse::DeliveryStageAborted { .. }
14579 | NodeResponse::WorkspaceInspected { .. }
14580 | NodeResponse::HostDirectoriesBrowsed { .. }
14581 | NodeResponse::WorkspaceFileRead { .. }
14582 | NodeResponse::WorkspaceFileWritten { .. }
14583 | NodeResponse::WorkspaceFileCreated { .. }
14584 | NodeResponse::WorkspaceDirectoryCreated { .. }
14585 | NodeResponse::GitHistoryRead { .. }
14586 | NodeResponse::GitDiffRead { .. }
14587 | NodeResponse::Controller { .. }
14588 | NodeResponse::SpawnAccepted { .. }
14589 | NodeResponse::SpawnSpecAccepted { .. }
14590 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14591 | NodeResponse::ManagedWorktreeCleanup { .. }
14592 | NodeResponse::NativeSessionsCataloged { .. }
14593 | NodeResponse::NativeSessionsPaged { .. }
14594 | NodeResponse::NativeSessionPreviewed { .. }
14595 | NodeResponse::SessionRecordPreviewed { .. }
14596 | NodeResponse::HistoryDiscovered { .. }
14597 | NodeResponse::HistoryLoaded { .. }
14598 | NodeResponse::ContextPackForSessionRecordExported { .. }
14599 | NodeResponse::ContextPackExported { .. }
14600 | NodeResponse::ContextPackForgotten { .. }
14601 | NodeResponse::DurableContextPackResolved { .. }
14602 | NodeResponse::ContextPackBytesRead { .. }
14603 | NodeResponse::SessionRecordForgotten { .. }
14604 | NodeResponse::WorkspaceRegistered { .. }
14605 | NodeResponse::StandaloneWorkspaceCreated { .. }
14606 | NodeResponse::WorkspaceUnregistered { .. }
14607 | NodeResponse::WorktreeCreated { .. }
14608 | NodeResponse::WorktreeRemoved { .. }
14609 | NodeResponse::Accepted
14610 | NodeResponse::ShuttingDown => {}
14611 }
14612}
14613
14614fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14615 for record in &mut snapshot.session_records {
14616 record.bundle = None;
14617 }
14618}
14619
14620fn project_event_without_session_bundle(
14621 mut envelope: NodeEventEnvelope,
14622) -> NodeEventEnvelope {
14623 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14624 record.bundle = None;
14625 }
14626 envelope
14627}
14628
14629fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14630 let contains_bundle = match reply.result.as_ref() {
14631 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14632 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14633 receipt.spawn.bundle.is_some()
14634 }
14635 _ => false,
14636 };
14637 if contains_bundle {
14638 reply.result = Err(failure(
14639 NodeFailureCode::UnsupportedCapability,
14640 "session bundles were not negotiated",
14641 ));
14642 return;
14643 }
14644 let Ok(response) = reply.result.as_mut() else {
14645 return;
14646 };
14647 match response {
14648 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14649 NodeResponse::Resync { snapshot, events, .. } => {
14650 clear_snapshot_session_bundles(snapshot);
14651 for event in events {
14652 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14653 record.bundle = None;
14654 }
14655 }
14656 }
14657 NodeResponse::SessionRecordUpdated { record }
14658 | NodeResponse::ProviderSessionIndexed { record }
14659 | NodeResponse::NativeSessionIndexed { record, .. }
14660 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14661 NodeResponse::Armed { .. }
14662 | NodeResponse::Spawned { .. }
14663 | NodeResponse::Activated { .. }
14664 | NodeResponse::Aborted { .. }
14665 | NodeResponse::ReplyChunkAccepted { .. }
14666 | NodeResponse::CallRejected { .. }
14667 | NodeResponse::DeliveryStageBegun { .. }
14668 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14669 | NodeResponse::DeliveryCommitted { .. }
14670 | NodeResponse::DeliveryStageAborted { .. }
14671 | NodeResponse::WorkspaceInspected { .. }
14672 | NodeResponse::HostDirectoriesBrowsed { .. }
14673 | NodeResponse::WorkspaceFileRead { .. }
14674 | NodeResponse::WorkspaceFileWritten { .. }
14675 | NodeResponse::WorkspaceFileCreated { .. }
14676 | NodeResponse::WorkspaceDirectoryCreated { .. }
14677 | NodeResponse::GitHistoryRead { .. }
14678 | NodeResponse::GitDiffRead { .. }
14679 | NodeResponse::Controller { .. }
14680 | NodeResponse::SpawnAccepted { .. }
14681 | NodeResponse::SpawnSpecAccepted { .. }
14682 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14683 | NodeResponse::ManagedWorktreeCleanup { .. }
14684 | NodeResponse::NativeSessionsCataloged { .. }
14685 | NodeResponse::NativeSessionsPaged { .. }
14686 | NodeResponse::NativeSessionPreviewed { .. }
14687 | NodeResponse::SessionRecordPreviewed { .. }
14688 | NodeResponse::HistoryDiscovered { .. }
14689 | NodeResponse::HistoryLoaded { .. }
14690 | NodeResponse::ContextPackForSessionRecordExported { .. }
14691 | NodeResponse::ContextPackExported { .. }
14692 | NodeResponse::ContextPackForgotten { .. }
14693 | NodeResponse::DurableContextPackResolved { .. }
14694 | NodeResponse::ContextPackBytesRead { .. }
14695 | NodeResponse::SessionRecordForgotten { .. }
14696 | NodeResponse::WorkspaceRegistered { .. }
14697 | NodeResponse::StandaloneWorkspaceCreated { .. }
14698 | NodeResponse::WorkspaceUnregistered { .. }
14699 | NodeResponse::WorktreeCreated { .. }
14700 | NodeResponse::WorktreeRemoved { .. }
14701 | NodeResponse::Accepted
14702 | NodeResponse::ShuttingDown => {}
14703 }
14704}
14705
14706fn project_snapshot_history_for_wire(
14707 snapshot: &mut NodeSnapshot,
14708 include_history_metadata: bool,
14709) {
14710 for workspace in &mut snapshot.workspaces {
14711 for session in &mut workspace.sessions {
14712 if include_history_metadata {
14713 session.history.loaded = None;
14714 } else {
14715 session.history = Default::default();
14716 }
14717 }
14718 }
14719}
14720
14721fn project_event_history_for_wire(
14722 mut envelope: NodeEventEnvelope,
14723 include_history_metadata: bool,
14724) -> NodeEventEnvelope {
14725 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14726 for session in &mut workspace.sessions {
14727 if include_history_metadata {
14728 session.history.loaded = None;
14729 } else {
14730 session.history = Default::default();
14731 }
14732 }
14733 }
14734 envelope
14735}
14736
14737fn project_response_history_for_wire(
14738 reply: &mut ResponseEnvelope,
14739 include_history_metadata: bool,
14740) {
14741 let Ok(response) = reply.result.as_mut() else {
14742 return;
14743 };
14744 match response {
14745 NodeResponse::Snapshot { snapshot, .. } => {
14746 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14747 }
14748 NodeResponse::Resync {
14749 snapshot, events, ..
14750 } => {
14751 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14752 for event in events {
14753 *event = project_event_history_for_wire(
14754 event.clone(),
14755 include_history_metadata,
14756 );
14757 }
14758 }
14759 NodeResponse::WorkspaceRegistered { workspace }
14760 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14761 | NodeResponse::WorktreeCreated { workspace, .. } => {
14762 for session in &mut workspace.sessions {
14763 if include_history_metadata {
14764 session.history.loaded = None;
14765 } else {
14766 session.history = Default::default();
14767 }
14768 }
14769 }
14770 _ => {}
14771 }
14772}
14773
14774fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14775 for record in &mut snapshot.session_records {
14776 record.context_id = None;
14777 record.context = None;
14778 record.exported_context = None;
14779 }
14780}
14781
14782fn project_event_without_context_pack(
14783 mut envelope: NodeEventEnvelope,
14784) -> NodeEventEnvelope {
14785 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14786 record.context_id = None;
14787 record.context = None;
14788 record.exported_context = None;
14789 }
14790 envelope
14791}
14792
14793fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14794 let contains_context = match reply.result.as_ref() {
14795 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14796 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14797 receipt.spawn.context.is_some()
14798 }
14799 Ok(NodeResponse::HistoryDiscovered { .. })
14800 | Ok(NodeResponse::HistoryLoaded { .. })
14801 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14802 | Ok(NodeResponse::ContextPackExported { .. })
14803 | Ok(NodeResponse::ContextPackForgotten { .. })
14804 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14805 _ => false,
14806 };
14807 if contains_context {
14808 reply.result = Err(failure(
14809 NodeFailureCode::UnsupportedCapability,
14810 "history context packs were not negotiated",
14811 ));
14812 return;
14813 }
14814 let Ok(response) = reply.result.as_mut() else {
14815 return;
14816 };
14817 match response {
14818 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
14819 NodeResponse::Resync {
14820 snapshot, events, ..
14821 } => {
14822 clear_snapshot_context_packs(snapshot);
14823 for event in events {
14824 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14825 record.context_id = None;
14826 record.context = None;
14827 record.exported_context = None;
14828 }
14829 }
14830 }
14831 NodeResponse::SessionRecordUpdated { record }
14832 | NodeResponse::ProviderSessionIndexed { record }
14833 | NodeResponse::NativeSessionIndexed { record, .. }
14834 | NodeResponse::SessionRecordResumed { record, .. } => {
14835 record.context_id = None;
14836 record.context = None;
14837 record.exported_context = None;
14838 }
14839 _ => {}
14840 }
14841}
14842
14843fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
14844 let contains_open_record = match reply.result.as_ref() {
14845 Ok(NodeResponse::SessionRecordUpdated { record })
14846 | Ok(NodeResponse::ProviderSessionIndexed { record })
14847 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
14848 !provider_id_is_legacy(&record.provider)
14849 }
14850 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
14851 !provider_id_is_legacy(&selection.route.provider)
14852 || !provider_id_is_legacy(&record.provider)
14853 }
14854 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14855 !provider_id_is_legacy(&receipt.provider)
14856 }
14857 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14858 !provider_id_is_legacy(&receipt.spawn.provider)
14859 }
14860 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
14861 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
14862 !provider_id_is_legacy(&route.provider)
14863 }
14864 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
14865 !provider_id_is_legacy(&selection.route.provider)
14866 }
14867 _ => false,
14868 };
14869 if contains_open_record {
14870 reply.result = Err(failure(
14871 NodeFailureCode::UnsupportedCapability,
14872 "open provider IDs were not negotiated",
14873 ));
14874 return;
14875 }
14876 let Ok(response) = reply.result.as_mut() else {
14877 return;
14878 };
14879 match response {
14880 NodeResponse::Snapshot { snapshot, .. } => {
14881 project_snapshot_legacy_provider_ids(snapshot);
14882 }
14883 NodeResponse::Resync { snapshot, events, .. } => {
14884 project_snapshot_legacy_provider_ids(snapshot);
14885 *events = std::mem::take(events)
14886 .into_iter()
14887 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
14888 .collect();
14889 }
14890 NodeResponse::WorkspaceRegistered { workspace }
14891 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14892 | NodeResponse::WorktreeCreated { workspace, .. } => {
14893 workspace
14894 .sessions
14895 .retain(|session| provider_id_is_legacy(&session.agent_id));
14896 }
14897 NodeResponse::Armed { .. }
14898 | NodeResponse::Spawned { .. }
14899 | NodeResponse::Activated { .. }
14900 | NodeResponse::Aborted { .. }
14901 | NodeResponse::ReplyChunkAccepted { .. }
14902 | NodeResponse::CallRejected { .. }
14903 | NodeResponse::DeliveryStageBegun { .. }
14904 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14905 | NodeResponse::DeliveryCommitted { .. }
14906 | NodeResponse::DeliveryStageAborted { .. }
14907 | NodeResponse::WorkspaceInspected { .. }
14908 | NodeResponse::HostDirectoriesBrowsed { .. }
14909 | NodeResponse::WorkspaceFileRead { .. }
14910 | NodeResponse::WorkspaceFileWritten { .. }
14911 | NodeResponse::WorkspaceFileCreated { .. }
14912 | NodeResponse::WorkspaceDirectoryCreated { .. }
14913 | NodeResponse::GitHistoryRead { .. }
14914 | NodeResponse::GitDiffRead { .. }
14915 | NodeResponse::Controller { .. }
14916 | NodeResponse::SpawnAccepted { .. }
14917 | NodeResponse::SpawnSpecAccepted { .. }
14918 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14919 | NodeResponse::ManagedWorktreeCleanup { .. }
14920 | NodeResponse::NativeSessionsCataloged { .. }
14921 | NodeResponse::NativeSessionsPaged { .. }
14922 | NodeResponse::NativeSessionPreviewed { .. }
14923 | NodeResponse::SessionRecordPreviewed { .. }
14924 | NodeResponse::HistoryDiscovered { .. }
14925 | NodeResponse::HistoryLoaded { .. }
14926 | NodeResponse::ContextPackForSessionRecordExported { .. }
14927 | NodeResponse::ContextPackExported { .. }
14928 | NodeResponse::ContextPackForgotten { .. }
14929 | NodeResponse::DurableContextPackResolved { .. }
14930 | NodeResponse::ContextPackBytesRead { .. }
14931 | NodeResponse::SessionRecordUpdated { .. }
14932 | NodeResponse::ProviderSessionIndexed { .. }
14933 | NodeResponse::NativeSessionIndexed { .. }
14934 | NodeResponse::SessionRecordResumed { .. }
14935 | NodeResponse::SessionRecordForgotten { .. }
14936 | NodeResponse::WorkspaceUnregistered { .. }
14937 | NodeResponse::WorktreeRemoved { .. }
14938 | NodeResponse::Accepted
14939 | NodeResponse::ShuttingDown => {}
14940 }
14941}
14942
14943fn project_event_legacy_provider_ids(
14944 shared: &NodeShared,
14945 mut envelope: NodeEventEnvelope,
14946) -> Option<NodeEventEnvelope> {
14947 let include = match &mut envelope.event {
14948 NodeEvent::HarnessMcpReadCall { .. } => true,
14949 NodeEvent::Control { address, .. }
14950 | NodeEvent::AgentStream { address, .. }
14951 | NodeEvent::TerminalFrame { address, .. } => shared
14952 .validate_address(address)
14953 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
14954 NodeEvent::WorkspaceAdded { workspace } => {
14955 workspace
14956 .sessions
14957 .retain(|session| provider_id_is_legacy(&session.agent_id));
14958 true
14959 }
14960 NodeEvent::SessionRecordUpserted { record } => {
14961 provider_id_is_legacy(&record.provider)
14962 }
14963 NodeEvent::SessionRecordHistorySummarized { .. } => false,
14964 NodeEvent::SessionRecordRemoved { .. } => shared
14965 .history
14966 .lock()
14967 .unwrap_or_else(|poisoned| poisoned.into_inner())
14968 .removed_record_providers
14969 .get(&envelope.sequence)
14970 .is_some_and(provider_id_is_legacy),
14971 NodeEvent::ControllerChanged { .. }
14972 | NodeEvent::ManagedWorktreeUpserted { .. }
14973 | NodeEvent::ManagedWorktreeRemoved { .. }
14974 | NodeEvent::WorkspaceRemoved { .. }
14975 | NodeEvent::ResyncRequired { .. } => true,
14976 };
14977 include.then_some(envelope)
14978}
14979
14980fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
14981 let Ok(response) = reply.result.as_mut() else {
14982 return;
14983 };
14984 match response {
14985 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14986 snapshot.provider_runtime_statuses.clear();
14987 }
14988 NodeResponse::Armed { .. }
14989 | NodeResponse::Spawned { .. }
14990 | NodeResponse::Activated { .. }
14991 | NodeResponse::Aborted { .. }
14992 | NodeResponse::ReplyChunkAccepted { .. }
14993 | NodeResponse::CallRejected { .. }
14994 | NodeResponse::DeliveryStageBegun { .. }
14995 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14996 | NodeResponse::DeliveryCommitted { .. }
14997 | NodeResponse::DeliveryStageAborted { .. }
14998 | NodeResponse::WorkspaceInspected { .. }
14999 | NodeResponse::HostDirectoriesBrowsed { .. }
15000 | NodeResponse::WorkspaceFileRead { .. }
15001 | NodeResponse::WorkspaceFileWritten { .. }
15002 | NodeResponse::WorkspaceFileCreated { .. }
15003 | NodeResponse::WorkspaceDirectoryCreated { .. }
15004 | NodeResponse::GitHistoryRead { .. }
15005 | NodeResponse::GitDiffRead { .. }
15006 | NodeResponse::Controller { .. }
15007 | NodeResponse::SpawnAccepted { .. }
15008 | NodeResponse::SpawnSpecAccepted { .. }
15009 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15010 | NodeResponse::ManagedWorktreeCleanup { .. }
15011 | NodeResponse::NativeSessionsCataloged { .. }
15012 | NodeResponse::NativeSessionsPaged { .. }
15013 | NodeResponse::NativeSessionPreviewed { .. }
15014 | NodeResponse::SessionRecordPreviewed { .. }
15015 | NodeResponse::HistoryDiscovered { .. }
15016 | NodeResponse::HistoryLoaded { .. }
15017 | NodeResponse::ContextPackForSessionRecordExported { .. }
15018 | NodeResponse::ContextPackExported { .. }
15019 | NodeResponse::ContextPackForgotten { .. }
15020 | NodeResponse::DurableContextPackResolved { .. }
15021 | NodeResponse::ContextPackBytesRead { .. }
15022 | NodeResponse::SessionRecordUpdated { .. }
15023 | NodeResponse::ProviderSessionIndexed { .. }
15024 | NodeResponse::NativeSessionIndexed { .. }
15025 | NodeResponse::SessionRecordResumed { .. }
15026 | NodeResponse::SessionRecordForgotten { .. }
15027 | NodeResponse::WorkspaceRegistered { .. }
15028 | NodeResponse::StandaloneWorkspaceCreated { .. }
15029 | NodeResponse::WorkspaceUnregistered { .. }
15030 | NodeResponse::WorktreeCreated { .. }
15031 | NodeResponse::WorktreeRemoved { .. }
15032 | NodeResponse::Accepted
15033 | NodeResponse::ShuttingDown => {}
15034 }
15035}
15036
15037struct ControllerReleaseGuard {
15038 shared: Arc<NodeShared>,
15039 connection_id: u64,
15040}
15041
15042impl Drop for ControllerReleaseGuard {
15043 fn drop(&mut self) {
15044 self.shared.release_controller(self.connection_id);
15045 }
15046}
15047
15048struct AbortTaskOnDrop(AbortHandle);
15049
15050impl Drop for AbortTaskOnDrop {
15051 fn drop(&mut self) {
15052 self.0.abort();
15053 }
15054}
15055
15056fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15061 std::env::var(name)
15062 .ok()
15063 .and_then(|value| value.parse::<u64>().ok())
15064 .filter(|value| *value > 0)
15065 .unwrap_or(default)
15066 .min(max)
15067}
15068
15069fn workspace_inspection_time_budget_ms() -> u64 {
15070 env_config_bounded(
15071 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15072 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15073 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15074 )
15075}
15076
15077fn workspace_inspection_entry_cap() -> usize {
15078 env_config_bounded(
15079 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15080 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15081 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15082 ) as usize
15083}
15084
15085struct WorkspaceWalkBudget {
15091 deadline: Instant,
15092 entry_cap: usize,
15093 entries_visited: u64,
15094 time_budget_exceeded: bool,
15095 entry_cap_exceeded: bool,
15096}
15097
15098impl WorkspaceWalkBudget {
15099 fn new(deadline: Instant, entry_cap: usize) -> Self {
15100 Self {
15101 deadline,
15102 entry_cap,
15103 entries_visited: 0,
15104 time_budget_exceeded: false,
15105 entry_cap_exceeded: false,
15106 }
15107 }
15108
15109 fn is_exhausted(&self) -> bool {
15110 self.time_budget_exceeded || self.entry_cap_exceeded
15111 }
15112
15113 fn record_visit(&mut self) -> bool {
15116 self.entries_visited += 1;
15117 if self.entries_visited as usize >= self.entry_cap {
15118 self.entry_cap_exceeded = true;
15119 }
15120 if Instant::now() >= self.deadline {
15121 self.time_budget_exceeded = true;
15122 }
15123 self.is_exhausted()
15124 }
15125}
15126
15127fn collect_workspace_entries(
15128 root: &Path,
15129 deadline: Instant,
15130 entry_cap: usize,
15131) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15132 let mut entries = Vec::new();
15133 let mut truncated = false;
15134 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15135 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15136 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15140 (entries, truncated, budget)
15141}
15142
15143fn workspace_inspection_truncation(
15147 walk_budget: &WorkspaceWalkBudget,
15148 git_time_budget_exceeded: bool,
15149 elapsed: Duration,
15150) -> Option<WorkspaceInspectionTruncationV1> {
15151 if !walk_budget.time_budget_exceeded
15152 && !walk_budget.entry_cap_exceeded
15153 && !git_time_budget_exceeded
15154 {
15155 return None;
15156 }
15157 Some(WorkspaceInspectionTruncationV1 {
15158 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15159 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15160 git_time_budget_exceeded,
15161 entries_visited: walk_budget.entries_visited,
15162 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15163 })
15164}
15165
15166fn walk_workspace_directory(
15167 directory: &Path,
15168 relative_directory: Option<&RepositoryPath>,
15169 depth: usize,
15170 entries: &mut Vec<WorkspaceEntry>,
15171 truncated: &mut bool,
15172 budget: &mut WorkspaceWalkBudget,
15173) {
15174 if budget.is_exhausted() {
15175 *truncated = true;
15176 return;
15177 }
15178 let read_dir = match std::fs::read_dir(directory) {
15179 Ok(read_dir) => read_dir,
15180 Err(_) => {
15181 *truncated = true;
15182 return;
15183 }
15184 };
15185 let mut children = Vec::new();
15186 for child in read_dir {
15187 match child {
15188 Ok(child) => children.push(child),
15189 Err(_) => *truncated = true,
15190 }
15191 }
15192 children.sort_by_key(|child| child.file_name());
15193 for child in children {
15194 if budget.record_visit() {
15195 *truncated = true;
15196 return;
15197 }
15198 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15199 *truncated = true;
15200 return;
15201 }
15202 let file_type = match child.file_type() {
15203 Ok(file_type) => file_type,
15204 Err(_) => {
15205 *truncated = true;
15206 continue;
15207 }
15208 };
15209 if file_type.is_symlink() {
15210 continue;
15211 }
15212 let name = match child.file_name().into_string() {
15213 Ok(name) => name,
15214 Err(_) => {
15215 *truncated = true;
15216 continue;
15217 }
15218 };
15219 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15220 continue;
15221 }
15222 let relative_path = match relative_directory {
15223 None => windows_repository_path(name),
15224 Some(parent) => parent
15225 .as_utf8()
15226 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15227 };
15228 let Some(relative_path) = relative_path else {
15229 *truncated = true;
15230 continue;
15231 };
15232 let kind = if file_type.is_dir() {
15233 WorkspaceEntryKind::Directory
15234 } else if file_type.is_file() {
15235 WorkspaceEntryKind::File
15236 } else {
15237 continue;
15238 };
15239 entries.push(WorkspaceEntry {
15240 relative_path: relative_path.clone(),
15241 kind,
15242 });
15243 if file_type.is_dir() {
15244 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15245 walk_workspace_directory(
15246 &child.path(),
15247 Some(&relative_path),
15248 depth + 1,
15249 entries,
15250 truncated,
15251 budget,
15252 );
15253 } else {
15254 *truncated = true;
15255 }
15256 }
15257 }
15258}
15259
15260fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15261 if value.contains('\\') {
15262 return None;
15263 }
15264 RepositoryPath::utf8(value).ok()
15265}
15266
15267fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15268 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15269}
15270
15271fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15280 if name.eq_ignore_ascii_case(".git")
15281 || name.eq_ignore_ascii_case("target")
15282 || name.eq_ignore_ascii_case("node_modules")
15283 {
15284 return true;
15285 }
15286 path.join("CACHEDIR.TAG").is_file()
15287}
15288
15289fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15290 matches!(
15291 status,
15292 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15293 )
15294}
15295
15296#[derive(Clone, Debug, Eq, PartialEq)]
15297enum ContextPackRepositoryHead {
15298 NotRepository,
15299 Unborn,
15300 Commit(String),
15301}
15302
15303#[derive(Clone, Debug, Eq, PartialEq)]
15304struct ContextPackRepositoryObservation {
15305 head: ContextPackRepositoryHead,
15306 git: GitSnapshot,
15307 files: Vec<ContextPackRepositoryFileSource>,
15308}
15309
15310fn stable_context_pack_repository(
15311 first: ContextPackRepositoryObservation,
15312 second: ContextPackRepositoryObservation,
15313) -> Result<ContextPackRepository, NodeFailure> {
15314 if first != second {
15315 return Err(failure(
15316 NodeFailureCode::ContextPackMaterializationFailed,
15317 "context repository changed during bounded capture",
15318 ));
15319 }
15320 Ok(ContextPackRepository::from_sources(
15321 &second.git,
15322 second.files,
15323 ))
15324}
15325
15326fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15333 if Instant::now() < deadline {
15334 return false;
15335 }
15336 append_git_diagnostic(
15337 snapshot,
15338 &format!("{stage} skipped: workspace inspection time budget exhausted"),
15339 );
15340 true
15341}
15342
15343async fn run_git_bounded_with_deadline(
15352 root: &str,
15353 arguments: &[&str],
15354 output_limit: usize,
15355 deadline: Instant,
15356) -> io::Result<GitCommandOutput> {
15357 let remaining = deadline.checked_duration_since(Instant::now());
15358 let timeout_ms = remaining
15359 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15360 .unwrap_or(0)
15361 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15362 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15363}
15364
15365async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15372 let mut snapshot = GitSnapshot {
15373 is_repository: false,
15374 branch: None,
15375 status: Vec::new(),
15376 recent_commits: Vec::new(),
15377 worktrees: Vec::new(),
15378 managed_worktree: None,
15379 truncated: false,
15380 diagnostic: None,
15381 };
15382 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15383 return (snapshot, true);
15384 }
15385 let repository = match run_git_bounded_with_deadline(
15386 root,
15387 &["rev-parse", "--is-inside-work-tree"],
15388 4 * 1_024,
15389 deadline,
15390 )
15391 .await
15392 {
15393 Ok(output) => output,
15394 Err(error) => {
15395 append_git_diagnostic(
15396 &mut snapshot,
15397 &format!("git inspection unavailable: {error}"),
15398 );
15399 return (snapshot, false);
15400 }
15401 };
15402 snapshot.truncated |= repository.truncated;
15403 if repository.timed_out {
15404 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15405 return (snapshot, false);
15406 }
15407 if !repository.success
15408 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15409 {
15410 let stderr = String::from_utf8_lossy(&repository.stderr);
15411 if !stderr.to_ascii_lowercase().contains("not a git repository") {
15412 append_git_diagnostic(&mut snapshot, stderr.trim());
15413 }
15414 return (snapshot, false);
15415 }
15416 snapshot.is_repository = true;
15417
15418 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15419 return (snapshot, true);
15420 }
15421 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15422 Ok(output) => {
15423 snapshot.truncated |= output.truncated;
15424 if output.timed_out {
15425 append_git_diagnostic(&mut snapshot, "git branch query timed out");
15426 } else if output.success {
15427 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15428 if !branch.is_empty() {
15429 snapshot.branch = Some(truncate_git_field(
15430 &branch,
15431 MAX_REPOSITORY_PATH_BYTES,
15432 &mut snapshot.truncated,
15433 ));
15434 }
15435 }
15436 }
15437 Err(error) => append_git_diagnostic(
15438 &mut snapshot,
15439 &format!("git branch query failed: {error}"),
15440 ),
15441 }
15442 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15443 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15444 snapshot.truncated |= output.truncated;
15445 if output.success && !output.timed_out {
15446 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15447 if !head.is_empty() {
15448 snapshot.branch = Some(format!("detached:{head}"));
15449 }
15450 }
15451 }
15452 }
15453
15454 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15455 return (snapshot, true);
15456 }
15457 match run_git_bounded_with_deadline(
15458 root,
15459 &[
15460 "status",
15461 "--porcelain=v1",
15462 "-z",
15463 "--renames",
15464 "--untracked-files=normal",
15465 "--",
15466 ".",
15467 ],
15468 GIT_OUTPUT_MAX_BYTES,
15469 deadline,
15470 )
15471 .await
15472 {
15473 Ok(output) => {
15474 snapshot.truncated |= output.truncated;
15475 if output.timed_out {
15476 append_git_diagnostic(&mut snapshot, "git status timed out");
15477 } else if output.success {
15478 parse_git_status(&output.stdout, &mut snapshot);
15479 } else {
15480 append_git_diagnostic(
15481 &mut snapshot,
15482 String::from_utf8_lossy(&output.stderr).trim(),
15483 );
15484 }
15485 }
15486 Err(error) => append_git_diagnostic(
15487 &mut snapshot,
15488 &format!("git status failed: {error}"),
15489 ),
15490 }
15491
15492 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15493 return (snapshot, true);
15494 }
15495 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15496 match run_git_bounded_with_deadline(
15497 root,
15498 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15499 16 * 1_024,
15500 deadline,
15501 )
15502 .await
15503 {
15504 Ok(output) => {
15505 snapshot.truncated |= output.truncated;
15506 if output.timed_out {
15507 append_git_diagnostic(&mut snapshot, "git log timed out");
15508 } else if output.success {
15509 parse_git_commits(&output.stdout, &mut snapshot);
15510 } else {
15511 let stderr = String::from_utf8_lossy(&output.stderr);
15512 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15513 append_git_diagnostic(&mut snapshot, stderr.trim());
15514 }
15515 }
15516 }
15517 Err(error) => append_git_diagnostic(
15518 &mut snapshot,
15519 &format!("git log failed: {error}"),
15520 ),
15521 }
15522
15523 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15524 return (snapshot, true);
15525 }
15526 match list_git_worktrees_with_deadline(root, deadline).await {
15527 Ok(worktrees) => {
15528 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15529 }
15530 Err(error) => append_git_diagnostic(
15531 &mut snapshot,
15532 &format!("git worktree list failed: {}", error.message),
15533 ),
15534 }
15535 (snapshot, false)
15536}
15537
15538fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15539 let mut remaining = output;
15540 while !remaining.is_empty() {
15541 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15542 snapshot.truncated = true;
15543 break;
15544 }
15545 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15546 snapshot.truncated = true;
15547 break;
15548 };
15549 let record = &remaining[..record_end];
15550 remaining = &remaining[record_end + 1..];
15551 let has_previous_path = record
15552 .get(..2)
15553 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15554 let previous_path_bytes = if has_previous_path {
15555 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15556 snapshot.truncated = true;
15557 break;
15558 };
15559 let previous = &remaining[..previous_end];
15560 remaining = &remaining[previous_end + 1..];
15561 Some(previous)
15562 } else {
15563 None
15564 };
15565 if record.len() < 4
15566 || record[2] != b' '
15567 || !is_git_status_code(record[0])
15568 || !is_git_status_code(record[1])
15569 {
15570 snapshot.truncated = true;
15571 continue;
15572 }
15573 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15574 snapshot.truncated = true;
15575 continue;
15576 };
15577 let previous_path = match previous_path_bytes {
15578 Some(previous) => {
15579 let Some(previous) = windows_repository_path_from_bytes(previous) else {
15580 snapshot.truncated = true;
15581 continue;
15582 };
15583 Some(previous)
15584 }
15585 None => None,
15586 };
15587 snapshot.status.push(GitStatusEntry {
15588 index_status: (record[0] as char).to_string(),
15589 worktree_status: (record[1] as char).to_string(),
15590 path,
15591 previous_path,
15592 });
15593 }
15594}
15595
15596fn is_git_status_code(byte: u8) -> bool {
15597 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15598}
15599
15600fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15601 for line in String::from_utf8_lossy(output).lines() {
15602 let Some((id, summary)) = line.split_once('\u{1f}') else {
15603 continue;
15604 };
15605 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15606 snapshot.truncated = true;
15607 break;
15608 }
15609 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15610 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15611 snapshot.recent_commits.push(GitCommitSummary { id, summary });
15612 }
15613}
15614
15615fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15616 if value.len() <= max_bytes {
15617 return value.to_owned();
15618 }
15619 *truncated = true;
15620 let mut boundary = max_bytes;
15621 while boundary > 0 && !value.is_char_boundary(boundary) {
15622 boundary -= 1;
15623 }
15624 value[..boundary].to_owned()
15625}
15626
15627fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15628 if message.is_empty() {
15629 return;
15630 }
15631 let normalized = message
15632 .chars()
15633 .map(|character| if character.is_control() { ' ' } else { character })
15634 .collect::<String>();
15635 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15636 if !diagnostic.is_empty() {
15637 diagnostic.push_str("; ");
15638 }
15639 diagnostic.push_str(normalized.trim());
15640 snapshot.diagnostic = Some(truncate_git_field(
15641 &diagnostic,
15642 GIT_DIAGNOSTIC_MAX_BYTES,
15643 &mut snapshot.truncated,
15644 ));
15645}
15646
15647async fn run_git_bounded(
15648 root: &str,
15649 arguments: &[&str],
15650 output_limit: usize,
15651) -> io::Result<GitCommandOutput> {
15652 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15653}
15654
15655async fn read_git_history_bounded(
15656 root: &str,
15657 path: Option<&RepositoryPath>,
15658 before: Option<&GitObjectId>,
15659 limit: u16,
15660) -> Result<GitHistoryPage, NodeFailure> {
15661 let path = path
15662 .map(|path| {
15663 path.as_utf8().ok_or_else(|| {
15664 failure(
15665 NodeFailureCode::InvalidRepositoryPath,
15666 "git history path must use UTF-8",
15667 )
15668 })
15669 })
15670 .transpose()?
15671 .unwrap_or(".");
15672 let count = (usize::from(limit) + 1).to_string();
15673 let start = before
15674 .map(|revision| format!("{}^", revision.as_str()))
15675 .unwrap_or_else(|| "HEAD".to_owned());
15676 let arguments = [
15677 "--no-pager",
15678 "log",
15679 "--no-decorate",
15680 "--date=iso-strict",
15681 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15682 "-n",
15683 count.as_str(),
15684 start.as_str(),
15685 "--",
15686 path,
15687 ];
15688 let output = run_git_bounded(root, &arguments, 256 * 1_024)
15689 .await
15690 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15691 if output.timed_out {
15692 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15693 }
15694 if !output.success {
15695 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15696 }
15697 let mut commits = parse_git_history(&output.stdout);
15698 let has_more = commits.len() > usize::from(limit) || output.truncated;
15699 commits.truncate(usize::from(limit));
15700 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15701 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15702}
15703
15704fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15705 String::from_utf8_lossy(output)
15706 .split('\u{1e}')
15707 .filter_map(|record| {
15708 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15709 if fields.len() != 9 {
15710 return None;
15711 }
15712 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15713 let parents = if fields[1].is_empty() {
15714 Vec::new()
15715 } else {
15716 fields[1]
15717 .split(' ')
15718 .map(|value| GitObjectId::new(value.to_owned()))
15719 .collect::<Result<Vec<_>, _>>()
15720 .ok()?
15721 };
15722 Some(GitCommitDetails {
15723 id,
15724 parents,
15725 subject: bounded_git_text(fields[2], 512),
15726 author_name: bounded_git_text(fields[3], 256),
15727 author_email: bounded_git_text(fields[4], 320),
15728 authored_at: bounded_git_text(fields[5], 64),
15729 committer_name: bounded_git_text(fields[6], 256),
15730 committer_email: bounded_git_text(fields[7], 320),
15731 committed_at: bounded_git_text(fields[8], 64),
15732 signature_status: GitSignatureStatus::NoSignature,
15733 signer: None,
15734 })
15735 })
15736 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15737 .collect()
15738}
15739
15740fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15741 let normalized = value
15742 .chars()
15743 .map(|character| if character.is_control() { ' ' } else { character })
15744 .collect::<String>();
15745 if normalized.len() <= max_bytes {
15746 return normalized;
15747 }
15748 let mut boundary = max_bytes;
15749 while boundary > 0 && !normalized.is_char_boundary(boundary) {
15750 boundary -= 1;
15751 }
15752 normalized[..boundary].to_owned()
15753}
15754
15755async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15756 let path = request.path.as_ref().map(|path| {
15757 path.as_utf8()
15758 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15759 }).transpose()?;
15760 let mut arguments = match &request.mode {
15761 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15762 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15763 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15764 };
15765 if let GitDiffMode::Commit { revision } = &request.mode {
15766 arguments.push(revision.as_str());
15767 }
15768 arguments.push("--");
15769 if let Some(path) = path {
15770 arguments.push(path);
15771 }
15772 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15773 .await
15774 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15775 if output.timed_out {
15776 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15777 }
15778 if !output.success {
15779 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15780 }
15781 Ok(GitDiff {
15782 mode: request.mode,
15783 path: request.path,
15784 text: String::from_utf8_lossy(&output.stdout).into_owned(),
15785 truncated: output.truncated,
15786 })
15787}
15788
15789async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15790 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15791 ResponseEnvelope { request_id: envelope.request_id, result }
15792}
15793
15794async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15795 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15796 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15797 }
15798 let read_only = matches!(
15799 &request,
15800 NodeRequest::Snapshot
15801 | NodeRequest::Resync { .. }
15802 | NodeRequest::BrowseHostDirectories { .. }
15803 | NodeRequest::InspectWorkspace { .. }
15804 | NodeRequest::ReadWorkspaceFile { .. }
15805 | NodeRequest::ReadGitHistory { .. }
15806 | NodeRequest::ReadGitDiff { .. }
15807 | NodeRequest::CatalogNativeSessions { .. }
15808 | NodeRequest::PageNativeSessions { .. }
15809 | NodeRequest::PreviewNativeSession { .. }
15810 | NodeRequest::PreviewSessionRecord { .. }
15811 );
15812 let _mutation_guard = if read_only {
15813 None
15814 } else {
15815 let guard = shared.mutation_gate.lock().await;
15816 if shared.shutdown.load(Ordering::Acquire) {
15817 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
15818 }
15819 Some(guard)
15820 };
15821 match request {
15822 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
15823 event_sequence: shared.current_sequence(),
15824 controller: shared.controller_state(),
15825 snapshot: shared.snapshot(),
15826 }),
15827 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
15828 NodeRequest::ArmHarnessMcpReservation {
15829 reservation_id,
15830 activation_digest,
15831 spawn_spec,
15832 launch,
15833 expires_at_unix_ms,
15834 } => {
15835 shared.require_controller(connection_id, role)?;
15836 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15837 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15838 })?;
15839 let expires_at_unix_ms = registry
15840 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
15841 .await
15842 .map_err(harness_mcp_failure)?;
15843 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
15844 }
15845 NodeRequest::SpawnSpecWithHarnessMcp {
15846 reservation_id,
15847 activation_digest,
15848 spec,
15849 deadline_unix_ms,
15850 } => {
15851 shared.require_controller(connection_id, role)?;
15852 let receipt = shared
15853 .spawn_from_spec_with_harness_mcp(
15854 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
15855 )
15856 .await?;
15857 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
15858 }
15859 NodeRequest::ActivateHarnessMcpReservation {
15860 reservation_id,
15861 activation_digest,
15862 record_id,
15863 session,
15864 } => {
15865 shared.require_controller(connection_id, role)?;
15866 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
15867 current.instance_id == session.session.instance_id
15868 && current.generation == session.session.generation
15869 }).and_then(|current| current.process_id).ok_or_else(|| {
15870 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
15871 })?;
15872 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15873 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15874 })?.activate(
15875 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
15876 ).map_err(harness_mcp_failure)?;
15877 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
15878 }
15879 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
15880 shared.require_controller(connection_id, role)?;
15881 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15882 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15883 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
15884 if let Some(instance_id) = instance_id {
15885 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
15886 }
15887 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
15888 }
15889 NodeRequest::PutHarnessMcpReplyChunk {
15890 reservation_id, activation_digest, record_id, session, call_id,
15891 offset, final_chunk, chunk_hex,
15892 } => {
15893 shared.require_controller(connection_id, role)?;
15894 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15895 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15896 })?.put_reply_chunk(
15897 &reservation_id, &activation_digest, &record_id, &session, &call_id,
15898 offset, final_chunk, &chunk_hex,
15899 ).map_err(harness_mcp_failure)?;
15900 Ok(NodeResponse::ReplyChunkAccepted {
15901 reservation_id, activation_digest, record_id, session, call_id,
15902 next_offset, completed,
15903 })
15904 }
15905 NodeRequest::RejectHarnessMcpCall {
15906 reservation_id, activation_digest, record_id, session, call_id, reason,
15907 } => {
15908 shared.require_controller(connection_id, role)?;
15909 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15910 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15911 })?.reject_call(
15912 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
15913 ).map_err(harness_mcp_failure)?;
15914 Ok(NodeResponse::CallRejected {
15915 reservation_id, activation_digest, record_id, session, call_id,
15916 })
15917 }
15918 NodeRequest::BeginDeliveryStage { manifest } => {
15919 shared.require_controller(connection_id, role)?;
15920 shared.begin_delivery_stage(manifest)
15921 }
15922 NodeRequest::PutDeliveryBlobChunk {
15923 stage_id,
15924 blob_digest,
15925 offset,
15926 chunk_hex,
15927 } => {
15928 shared.require_controller(connection_id, role)?;
15929 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
15930 }
15931 NodeRequest::CommitDeliveryStage { stage_id } => {
15932 shared.require_controller(connection_id, role)?;
15933 let receipt = shared.commit_delivery_stage(stage_id)?;
15934 Ok(NodeResponse::DeliveryCommitted { receipt })
15935 }
15936 NodeRequest::AbortDeliveryStage { stage_id } => {
15937 shared.require_controller(connection_id, role)?;
15938 shared.abort_delivery_stage(&stage_id)?;
15939 Ok(NodeResponse::DeliveryStageAborted { stage_id })
15940 }
15941 NodeRequest::BrowseHostDirectories { directory, after } => {
15942 let listing = shared.browse_host_directories(directory, after).await?;
15943 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
15944 }
15945 NodeRequest::InspectWorkspace { workspace_id } => {
15946 let inspection = shared.inspect_workspace(workspace_id).await?;
15947 Ok(NodeResponse::WorkspaceInspected { inspection })
15948 }
15949 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
15950 let file = shared.read_workspace_file(workspace_id, path).await?;
15951 Ok(NodeResponse::WorkspaceFileRead { file })
15952 }
15953 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
15954 shared.require_controller(connection_id, role)?;
15955 let file = shared
15956 .write_workspace_file(workspace_id, path, expected_revision, text)
15957 .await?;
15958 Ok(NodeResponse::WorkspaceFileWritten { file })
15959 }
15960 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
15961 shared.require_controller(connection_id, role)?;
15962 let file = shared.create_workspace_file(workspace_id, path).await?;
15963 Ok(NodeResponse::WorkspaceFileCreated { file })
15964 }
15965 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
15966 shared.require_controller(connection_id, role)?;
15967 let entry = shared
15968 .create_workspace_directory(workspace_id.clone(), path)
15969 .await?;
15970 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
15971 }
15972 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
15973 if role != ClientRole::Operator {
15974 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
15975 }
15976 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
15977 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
15978 }
15979 let page = shared
15980 .read_git_history(workspace_id.clone(), path, before, limit)
15981 .await?;
15982 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
15983 }
15984 NodeRequest::ReadGitDiff { workspace_id, request } => {
15985 if role != ClientRole::Operator {
15986 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
15987 }
15988 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
15989 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
15990 }
15991 NodeRequest::CatalogNativeSessions { route, limit } => {
15992 if role != ClientRole::Operator {
15993 return Err(failure(
15994 NodeFailureCode::ObserverReadOnly,
15995 "native session catalog requires an operator connection",
15996 ));
15997 }
15998 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
15999 return Err(failure(
16000 NodeFailureCode::InvalidRequest,
16001 "native session catalog limit is invalid",
16002 ));
16003 }
16004 let (entries, summary) = shared
16005 .catalog_native_sessions(route.clone(), limit)
16006 .await?;
16007 Ok(NodeResponse::NativeSessionsCataloged {
16008 route,
16009 entries,
16010 summary: Some(summary),
16011 })
16012 }
16013 NodeRequest::PageNativeSessions {
16014 route,
16015 window,
16016 catalog_revision,
16017 recent_cutoff_unix_ms,
16018 after_selection_id,
16019 limit,
16020 } => {
16021 if role != ClientRole::Operator {
16022 return Err(failure(
16023 NodeFailureCode::ObserverReadOnly,
16024 "native session catalog paging requires an operator connection",
16025 ));
16026 }
16027 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16028 || after_selection_id
16029 .as_deref()
16030 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16031 {
16032 return Err(failure(
16033 NodeFailureCode::InvalidRequest,
16034 "native session catalog page request is invalid",
16035 ));
16036 }
16037 let page = shared
16038 .page_native_sessions(
16039 route.clone(),
16040 window,
16041 catalog_revision,
16042 recent_cutoff_unix_ms,
16043 after_selection_id,
16044 limit,
16045 )
16046 .await?;
16047 Ok(NodeResponse::NativeSessionsPaged {
16048 route,
16049 page,
16050 })
16051 }
16052 NodeRequest::PreviewNativeSession {
16053 selection,
16054 message_limit,
16055 } => {
16056 if role != ClientRole::Operator {
16057 return Err(failure(
16058 NodeFailureCode::ObserverReadOnly,
16059 "native session preview requires an operator connection",
16060 ));
16061 }
16062 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16063 .contains(&message_limit)
16064 || selection.validate().is_err()
16065 {
16066 return Err(failure(
16067 NodeFailureCode::InvalidRequest,
16068 "native session preview request is invalid",
16069 ));
16070 }
16071 let preview = shared
16072 .preview_native_session(selection.clone(), message_limit)
16073 .await?;
16074 Ok(NodeResponse::NativeSessionPreviewed {
16075 selection,
16076 preview,
16077 })
16078 }
16079 NodeRequest::IndexNativeSession {
16080 selection,
16081 display_name,
16082 } => {
16083 shared.require_controller(connection_id, role)?;
16084 let record = shared
16085 .index_native_session(selection.clone(), display_name)
16086 .await?;
16087 Ok(NodeResponse::NativeSessionIndexed { selection, record })
16088 }
16089 NodeRequest::PreviewSessionRecord {
16090 record_id,
16091 message_limit,
16092 } => {
16093 if role != ClientRole::Operator {
16094 return Err(failure(
16095 NodeFailureCode::ObserverReadOnly,
16096 "native session preview requires an operator connection",
16097 ));
16098 }
16099 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16100 .contains(&message_limit)
16101 {
16102 return Err(failure(
16103 NodeFailureCode::InvalidRequest,
16104 "native session preview request is invalid",
16105 ));
16106 }
16107 let record = shared.record(&record_id)?;
16108 let working_directory = shared.workspace_root(&record.workspace_id)?;
16109 if !platform::roots_equal(
16110 &working_directory,
16111 windows_path_text(&record.canonical_root),
16112 ) {
16113 return Err(failure(
16114 NodeFailureCode::SessionRecordConflict,
16115 "managed session workspace root changed; refusing to preview another directory",
16116 ));
16117 }
16118 let identity = record.provider_session.clone().ok_or_else(|| {
16119 failure(
16120 NodeFailureCode::BackendOperationFailed,
16121 "native session preview is unavailable for this session record",
16122 )
16123 })?;
16124 if identity.key != ProviderSessionKey::SessionId {
16125 return Err(failure(
16126 NodeFailureCode::BackendOperationFailed,
16127 "native session preview is unavailable for this session identity",
16128 ));
16129 }
16130 let preview = shared
16131 .preview_native_session_by_session_id(
16132 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16133 )
16134 .await?;
16135 if preview.session_id != identity.id {
16136 return Err(failure(
16137 NodeFailureCode::SessionRecordConflict,
16138 "native session preview identity changed while loading",
16139 ));
16140 }
16141 shared.revalidate_session_record_preview(&record, &identity)?;
16142 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16143 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16144 record_id: record_id.clone(),
16145 summary: SessionHistorySummaryV1::from(&preview),
16146 });
16147 Ok(NodeResponse::SessionRecordPreviewed {
16148 record_id,
16149 preview,
16150 })
16151 }
16152 NodeRequest::AcquireController { lease_ms } => {
16153 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16154 Ok(NodeResponse::Controller { controller: Some(controller) })
16155 }
16156 NodeRequest::ReleaseController => {
16157 shared.release_controller(connection_id);
16158 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16159 }
16160 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16161 shared.require_controller(connection_id, role)?;
16162 let root = require_windows_path(root)?;
16163 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16164 let workspace = shared.register_workspace(workspace_id, root).await?;
16165 Ok(NodeResponse::WorkspaceRegistered { workspace })
16166 }
16167 NodeRequest::CreateStandaloneWorkspace {
16168 workspace_id,
16169 root,
16170 initial_branch,
16171 } => {
16172 shared.require_controller(connection_id, role)?;
16173 let root = require_windows_path(root)?;
16174 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16175 let workspace = shared
16176 .create_standalone_workspace(workspace_id, root, initial_branch)
16177 .await?;
16178 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16179 }
16180 NodeRequest::UnregisterWorkspace { workspace_id } => {
16181 shared.require_controller(connection_id, role)?;
16182 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16183 shared.unregister_workspace(&workspace_id)?;
16184 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16185 }
16186 NodeRequest::CreateWorktree {
16187 source_workspace_id,
16188 workspace_id,
16189 target_root,
16190 branch,
16191 base,
16192 } => {
16193 shared.require_controller(connection_id, role)?;
16194 let target_root = require_windows_path(target_root)?;
16195 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16196 let (worktree, workspace) = shared
16197 .create_worktree(
16198 source_workspace_id,
16199 workspace_id,
16200 target_root,
16201 branch,
16202 base,
16203 )
16204 .await?;
16205 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16206 }
16207 NodeRequest::RemoveWorktree {
16208 source_workspace_id,
16209 target_root,
16210 } => {
16211 shared.require_controller(connection_id, role)?;
16212 let native_target_root = require_windows_path(target_root.clone())?;
16213 shared.reject_managed_reservation(None, Some(&native_target_root))?;
16214 let workspace_id = shared
16215 .remove_worktree(source_workspace_id, native_target_root)
16216 .await?;
16217 Ok(NodeResponse::WorktreeRemoved {
16218 target_root,
16219 workspace_id,
16220 })
16221 }
16222 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16223 shared.require_controller(connection_id, role)?;
16224 if !terminal_size.is_valid() {
16225 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16226 }
16227 if let Some(prompt) = initial_prompt.as_deref() {
16228 validate_node_text("spawn initial prompt", prompt)?;
16229 }
16230 match shared
16231 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16232 .await
16233 {
16234 Ok(session) => {
16235 shared.mark_spawn_provisional(session.clone());
16236 Ok(NodeResponse::SpawnAccepted { session })
16237 }
16238 Err(err) => {
16239 tracing::warn!(
16240 %provider,
16241 %workspace_id,
16242 ?mode,
16243 code = ?err.code,
16244 message = %err.message,
16245 "session spawn refused"
16246 );
16247 Err(err)
16248 }
16249 }
16250 }
16251 NodeRequest::SpawnSpec { spec } => {
16252 shared.require_controller(connection_id, role)?;
16253 let receipt = shared.spawn_from_spec(spec).await?;
16254 Ok(NodeResponse::SpawnSpecAccepted { receipt })
16255 }
16256 NodeRequest::SpawnManagedWorktree { request } => {
16257 shared.require_controller(connection_id, role)?;
16258 let receipt = shared.spawn_managed_worktree(request).await?;
16259 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16260 }
16261 NodeRequest::SpawnManagedWorktreeV2 { request } => {
16262 shared.require_controller(connection_id, role)?;
16263 let receipt = shared.spawn_managed_worktree_v2(request).await?;
16264 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16265 }
16266 NodeRequest::CleanupManagedWorktree { lease_id } => {
16267 shared.require_controller(connection_id, role)?;
16268 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16269 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16270 }
16271 NodeRequest::DiscoverHistory { session, limit } => {
16272 controlled_session(shared, connection_id, role, &session)?;
16273 let candidates = shared.discover_history(&session, limit).await?;
16274 Ok(NodeResponse::HistoryDiscovered {
16275 session,
16276 candidates,
16277 })
16278 }
16279 NodeRequest::LoadHistory {
16280 session,
16281 candidate_id,
16282 } => {
16283 controlled_session(shared, connection_id, role, &session)?;
16284 let loaded = shared
16285 .load_history(&session, candidate_id)
16286 .await?;
16287 Ok(NodeResponse::HistoryLoaded {
16288 session,
16289 session_id: loaded.session_id,
16290 message_count: loaded.message_count,
16291 completed_turn_count: loaded.completed_turn_count,
16292 })
16293 }
16294 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16295 shared.require_controller(connection_id, role)?;
16296 let context = shared
16297 .export_context_pack_for_session_record(&record_id, &session, false)
16298 .await?;
16299 Ok(NodeResponse::ContextPackForSessionRecordExported {
16300 record_id,
16301 session,
16302 context,
16303 })
16304 }
16305 NodeRequest::ExportContextPack { session } => {
16306 controlled_session(shared, connection_id, role, &session)?;
16307 let context = shared.export_context_pack(&session).await?;
16308 Ok(NodeResponse::ContextPackExported { context })
16309 }
16310 NodeRequest::ForgetContextPack { context_id } => {
16311 shared.require_controller(connection_id, role)?;
16312 shared.forget_context_pack(&context_id)?;
16313 Ok(NodeResponse::ContextPackForgotten { context_id })
16314 }
16315 NodeRequest::ResolveDurableContextPack { context_id } => {
16316 let context = shared
16320 .context_catalog
16321 .read()
16322 .unwrap_or_else(|poisoned| poisoned.into_inner())
16323 .get(&context_id)
16324 .map(|pack| pack.receipt().clone())
16325 .ok_or_else(|| {
16326 failure(
16327 NodeFailureCode::UnknownContextPack,
16328 "context pack is not present",
16329 )
16330 })?;
16331 Ok(NodeResponse::DurableContextPackResolved { context })
16332 }
16333 NodeRequest::ReadContextPack { digest } => {
16334 let (id, byte_len, bytes) = shared
16339 .context_catalog
16340 .read()
16341 .unwrap_or_else(|poisoned| poisoned.into_inner())
16342 .get_by_digest(&digest)
16343 .map(|pack| {
16344 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16345 })
16346 .ok_or_else(|| {
16347 failure(
16348 NodeFailureCode::UnknownContextPack,
16349 &format!("context pack {digest} not on this node"),
16350 )
16351 })?;
16352 tracing::debug!(
16353 digest = digest.as_str(),
16354 byte_len,
16355 "read context pack bytes",
16356 );
16357 Ok(NodeResponse::ContextPackBytesRead {
16358 pack: ContextPackBytesRead {
16359 digest,
16360 id,
16361 byte_len,
16362 bytes,
16363 },
16364 })
16365 }
16366 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16367 let provider = controlled_session(shared, connection_id, role, &session)?;
16368 if let Some(prompt) = initial_prompt.as_deref() {
16369 validate_node_text("resume initial prompt", prompt)?;
16370 }
16371 let runtime_requirement = if initial_prompt.is_some() {
16372 ProviderRuntimeRequirement::ResumeWithPrompt
16373 } else {
16374 ProviderRuntimeRequirement::Resume
16375 };
16376 shared.require_session_runtime_policy(&session, runtime_requirement)?;
16377 let runtime_policy = shared
16378 .admit_provider_runtime(&provider, runtime_requirement)
16379 .await?;
16380 let working_directory = shared.bound_workspace_root(&session)?;
16381 let request = ResumeLaunchRequest {
16382 working_directory,
16383 terminal_size,
16384 initial_prompt,
16385 };
16386 request
16387 .validate()
16388 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16389 let command = shared.prepare_command(ControlCommand::Resume {
16390 instance_id: session.session.instance_id,
16391 target: ResumeTarget::CurrentProvider,
16392 runtime_policy,
16393 request,
16394 });
16395 shared.arm_resume(&session, command.id, runtime_policy)?;
16396 let dispatch = shared.dispatch_envelope(command);
16397 if let Err(error) = dispatch {
16398 shared.clear_armed_resume(&session);
16399 return Err(error);
16400 }
16401 Ok(NodeResponse::Accepted)
16402 }
16403 NodeRequest::RenameSessionRecord {
16404 record_id,
16405 display_name,
16406 } => {
16407 shared.require_controller(connection_id, role)?;
16408 let record = shared.rename_session_record(&record_id, display_name)?;
16409 Ok(NodeResponse::SessionRecordUpdated { record })
16410 }
16411 NodeRequest::SetSessionTask {
16412 record_id,
16413 expected_revision,
16414 target,
16415 } => {
16416 shared.require_controller(connection_id, role)?;
16417 let record = shared.set_session_task(&record_id, expected_revision, target)?;
16418 Ok(NodeResponse::SessionRecordUpdated { record })
16419 }
16420 NodeRequest::IndexProviderSession {
16421 workspace_id,
16422 provider,
16423 identity,
16424 display_name,
16425 } => {
16426 shared.require_controller(connection_id, role)?;
16427 let record = shared.index_provider_session(
16428 workspace_id,
16429 provider,
16430 identity,
16431 display_name,
16432 )?;
16433 Ok(NodeResponse::ProviderSessionIndexed { record })
16434 }
16435 NodeRequest::ResumeSessionRecord {
16436 record_id,
16437 terminal_size,
16438 initial_prompt,
16439 } => {
16440 shared.require_controller(connection_id, role)?;
16441 if let Some(prompt) = initial_prompt.as_deref() {
16442 validate_node_text("managed resume initial prompt", prompt)?;
16443 }
16444 let (record, session) = shared
16445 .resume_session_record(
16446 &record_id,
16447 terminal_size,
16448 initial_prompt,
16449 )
16450 .await?;
16451 Ok(NodeResponse::SessionRecordResumed { record, session })
16452 }
16453 NodeRequest::ForgetSessionRecord { record_id } => {
16454 shared.require_controller(connection_id, role)?;
16455 shared.forget_session_record(&record_id).await?;
16456 Ok(NodeResponse::SessionRecordForgotten { record_id })
16457 }
16458 NodeRequest::Prompt { session, text } => {
16459 let agent_id = controlled_session(shared, connection_id, role, &session)?;
16460 validate_node_text("prompt", &text)?;
16461 shared.require_session_runtime_policy(
16462 &session,
16463 ProviderRuntimeRequirement::SemanticPrompt,
16464 )?;
16465 let framing = prompt_framing(&agent_id);
16466 shared
16467 .dispatch_input_bounded(
16468 &session,
16469 InputAction::SubmitPrompt(PromptPayload { text, framing }),
16470 )
16471 .await?;
16472 Ok(NodeResponse::Accepted)
16473 }
16474 NodeRequest::Paste { session, text } => {
16475 controlled_session(shared, connection_id, role, &session)?;
16476 validate_node_text("paste", &text)?;
16477 shared.require_session_runtime_policy(
16478 &session,
16479 ProviderRuntimeRequirement::SemanticPrompt,
16480 )?;
16481 shared
16482 .dispatch_input_bounded(
16483 &session,
16484 InputAction::InsertDraft(PromptPayload {
16485 text,
16486 framing: PromptFraming::BracketedPaste,
16487 }),
16488 )
16489 .await?;
16490 Ok(NodeResponse::Accepted)
16491 }
16492 NodeRequest::Input { session, text } => {
16493 controlled_session(shared, connection_id, role, &session)?;
16494 validate_node_text("terminal input", &text)?;
16495 shared
16496 .dispatch_input_bounded(
16497 &session,
16498 InputAction::TerminalText(TerminalText { text }),
16499 )
16500 .await?;
16501 Ok(NodeResponse::Accepted)
16502 }
16503 NodeRequest::TerminalBytes { session, bytes } => {
16504 controlled_session(shared, connection_id, role, &session)?;
16505 shared.sessionize_spawn(session.session.instance_id);
16506 let action = terminal_bytes_action(bytes)?;
16507 shared
16508 .dispatch_input_bounded(&session, action)
16509 .await?;
16510 Ok(NodeResponse::Accepted)
16511 }
16512 NodeRequest::TerminalControl { session, control } => {
16513 controlled_session(shared, connection_id, role, &session)?;
16514 shared
16515 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16516 .await?;
16517 Ok(NodeResponse::Accepted)
16518 }
16519 NodeRequest::Resize { session, size } => {
16520 controlled_session(shared, connection_id, role, &session)?;
16521 shared.sessionize_spawn(session.session.instance_id);
16522 if !size.is_valid() {
16523 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16524 }
16525 shared.dispatch_resize_bounded(&session, size).await?;
16526 Ok(NodeResponse::Accepted)
16527 }
16528 NodeRequest::Interrupt { session } => {
16529 controlled_session(shared, connection_id, role, &session)?;
16530 shared
16531 .dispatch_input_bounded(
16532 &session,
16533 InputAction::TerminalControl(TerminalControl::Interrupt),
16534 )
16535 .await?;
16536 Ok(NodeResponse::Accepted)
16537 }
16538 NodeRequest::Stop { session, force } => {
16539 controlled_session(shared, connection_id, role, &session)?;
16540 shared.sessionize_spawn(session.session.instance_id);
16541 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16542 Ok(NodeResponse::Accepted)
16543 }
16544 NodeRequest::Remove { session } => {
16545 controlled_session(shared, connection_id, role, &session)?;
16546 shared.remove_session(&session).await?;
16547 Ok(NodeResponse::Accepted)
16548 }
16549 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16550 shared.require_controller(connection_id, role)?;
16551 let snapshot = shared.require_acp_session(&session)?;
16552 let interaction = snapshot
16553 .provider
16554 .interactions
16555 .iter()
16556 .find(|interaction| {
16557 interaction_correlation(
16558 session.session.instance_id,
16559 session.session.generation,
16560 interaction.id.0,
16561 ) == correlation_id
16562 })
16563 .ok_or_else(|| failure(
16564 NodeFailureCode::InvalidRequest,
16565 "unknown interaction correlation",
16566 ))?;
16567 if interaction.status != ProviderInteractionStatus::Pending {
16568 return Err(failure(
16569 NodeFailureCode::BackendOperationFailed,
16570 "interaction has already been resolved",
16571 ));
16572 }
16573 response
16574 .validate_for(interaction.interaction_kind)
16575 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16576 let interaction_id = interaction.id;
16577 let response_kind = response.kind();
16578 let response_outcome = response.outcome();
16579 shared.dispatch(ControlCommand::ResolveInteraction {
16580 instance_id: session.session.instance_id,
16581 generation: session.session.generation,
16582 interaction_id,
16583 response,
16584 })?;
16585 tracing::info!(
16591 node_id = %shared.node_id,
16592 session = ?session.session,
16593 correlation_id = %correlation_id,
16594 interaction_id = interaction_id.0,
16595 response_kind = ?response_kind,
16596 outcome = ?response_outcome,
16597 "interaction resolved",
16598 );
16599 Ok(NodeResponse::Accepted)
16600 }
16601 NodeRequest::SetSessionMode { session, mode_id } => {
16602 shared.require_controller(connection_id, role)?;
16603 shared.require_acp_session(&session)?;
16604 shared.dispatch(ControlCommand::SetSessionMode {
16605 instance_id: session.session.instance_id,
16606 mode_id,
16607 })?;
16608 Ok(NodeResponse::Accepted)
16609 }
16610 NodeRequest::SetSessionConfigOption {
16611 session,
16612 option_id,
16613 value_json,
16614 } => {
16615 shared.require_controller(connection_id, role)?;
16616 shared.require_acp_session(&session)?;
16617 shared.dispatch(ControlCommand::SetSessionConfigOption {
16618 instance_id: session.session.instance_id,
16619 option_id,
16620 value_json,
16621 })?;
16622 Ok(NodeResponse::Accepted)
16623 }
16624 NodeRequest::SetSessionModel { session, model_id } => {
16625 shared.require_controller(connection_id, role)?;
16626 shared.require_acp_session(&session)?;
16627 shared.dispatch(ControlCommand::SetSessionModel {
16628 instance_id: session.session.instance_id,
16629 model_id,
16630 })?;
16631 Ok(NodeResponse::Accepted)
16632 }
16633 NodeRequest::Shutdown => {
16634 shared.require_controller(connection_id, role)?;
16635 shared.begin_shutdown_locked().await?;
16636 Ok(NodeResponse::ShuttingDown)
16637 }
16638 }
16639}
16640
16641fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16642 shared.require_controller(connection_id, role)?;
16643 shared.validate_address(session)
16644}
16645
16646fn select_history_candidate(
16652 candidates: &[HistoryCandidateSummary],
16653 provider_session_id: Option<&str>,
16654) -> Result<String, NodeFailure> {
16655 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16656 Some(id) => candidate.session_id_hint == id,
16657 None => true,
16658 });
16659 let candidate = matches.next().ok_or_else(|| {
16660 failure(
16661 NodeFailureCode::SessionRecordConflict,
16662 if provider_session_id.is_some() {
16663 "no history candidate matches the managed provider session"
16664 } else {
16665 "no history candidate exists for the managed session"
16666 },
16667 )
16668 })?;
16669 if matches.next().is_some() {
16670 return Err(failure(
16671 NodeFailureCode::SessionRecordConflict,
16672 if provider_session_id.is_some() {
16673 "history identity is ambiguous for the managed provider session"
16674 } else {
16675 "history is ambiguous without a verified provider session identity"
16676 },
16677 ));
16678 }
16679 Ok(candidate.id.clone())
16680}
16681
16682fn resolved_spawn_profile_summary(
16683 profile: &SpawnProfileDefaults,
16684 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16685) -> Option<SpawnProfileSummary> {
16686 let environment_profile = match profile.environment_profile_id.as_ref() {
16687 None => None,
16688 Some(profile_id) => {
16689 let binding = environment_profiles.get(profile_id)?;
16690 if binding.provider != profile.provider
16691 || binding.native_profile_id(profile.mode).is_none()
16692 {
16693 return None;
16694 }
16695 Some(ResolvedEnvironmentProfileReceipt {
16696 profile_id: binding.id.clone(),
16697 profile_revision: binding.revision.clone(),
16698 network_allowlist: None,
16699 browser_profile_id: None,
16700 })
16701 }
16702 };
16703 Some(SpawnProfileSummary {
16704 id: profile.profile_id.clone(),
16705 revision: profile.revision.clone(),
16706 environment_profile,
16707 })
16708}
16709
16710fn session_record_export_target_matches(
16729 expected: &ManagedSessionRecord,
16730 current: &ManagedSessionRecord,
16731 session: &SessionAddress,
16732 allow_clean_detachment: bool,
16733) -> bool {
16734 if current == expected && current.active_session.as_ref() == Some(session) {
16735 return current.state == ManagedSessionState::Live;
16736 }
16737 allow_clean_detachment
16738 && current.state == ManagedSessionState::Dormant
16739 && current.active_session.is_none()
16740 && current.record_id == expected.record_id
16741 && current.display_name == expected.display_name
16742 && current.provider == expected.provider
16743 && current.mode == expected.mode
16744 && current.workspace_id == expected.workspace_id
16745 && current.canonical_root == expected.canonical_root
16746 && current.provider_session == expected.provider_session
16747 && current.environment_profile == expected.environment_profile
16748 && current.bundle == expected.bundle
16749 && current.context_id == expected.context_id
16750 && current.context == expected.context
16751 && current.exported_context == expected.exported_context
16752 && current.task_binding == expected.task_binding
16753 && current.created_at_unix_ms == expected.created_at_unix_ms
16754 && current.last_error == expected.last_error
16755}
16756
16757fn session_record_context_export_binding_is_exact(
16758 expected: &ManagedSessionRecord,
16759 current: &ManagedSessionRecord,
16760 identity: Option<&ProviderSessionIdentity>,
16761 session: &SessionAddress,
16762 binding: &SessionBinding,
16763 provider: &AgentId,
16764 current_root: &str,
16765 allow_clean_detachment: bool,
16766) -> bool {
16767 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
16768 && current.provider_session.as_ref() == identity
16769 && current.workspace_id == session.workspace_id
16770 && provider == ¤t.provider
16771 && binding.workspace_id == session.workspace_id
16772 && binding.generation == session.session.generation
16773 && binding.record_id.as_ref() == Some(¤t.record_id)
16774 && platform::roots_equal(
16775 current_root,
16776 windows_path_text(¤t.canonical_root),
16777 )
16778}
16779
16780fn session_record_context_export_source_is_usable(
16781 record: &ManagedSessionRecord,
16782 status: &SessionStatus,
16783 allow_clean_detachment: bool,
16784) -> bool {
16785 (record.state == ManagedSessionState::Live
16786 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
16787 && context_pack_source_status_is_usable(status)
16788}
16789
16790fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
16791 PromptFraming::BracketedPaste
16792}
16793
16794fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
16795 if text.len() > MAX_NODE_TEXT_BYTES {
16796 return Err(failure(
16797 NodeFailureCode::InvalidRequest,
16798 &format!(
16799 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
16800 text.len(),
16801 ),
16802 ));
16803 }
16804 Ok(())
16805}
16806
16807fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
16808 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
16809 return Err(failure(
16810 NodeFailureCode::InvalidRequest,
16811 &format!(
16812 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
16813 bytes.len(),
16814 ),
16815 ));
16816 }
16817 Ok(())
16818}
16819
16820fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
16821 validate_terminal_bytes(&bytes)?;
16822 Ok(InputAction::TerminalBytes(bytes))
16823}
16824
16825fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
16826 if root.is_empty()
16827 || root.len() > MAX_WORKSPACE_ROOT_BYTES
16828 || root.chars().any(char::is_control)
16829 {
16830 return Err(failure(
16831 NodeFailureCode::InvalidWorkspaceRoot,
16832 &format!(
16833 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
16834 ),
16835 ));
16836 }
16837 Ok(())
16838}
16839
16840fn canonical_standalone_workspace_candidate(
16841 workspace_id: &WorkspaceId,
16842 root: &str,
16843) -> Result<String, NodeServerError> {
16844 let path = Path::new(root);
16845 match std::fs::symlink_metadata(path) {
16846 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
16847 .map(|workspace| workspace.canonical_root),
16848 Err(error) if error.kind() == io::ErrorKind::NotFound => {
16849 if !path.is_absolute() {
16850 return Err(NodeServerError::InvalidWorkspaceRoot {
16851 workspace_id: workspace_id.clone(),
16852 path: root.to_owned(),
16853 message: "path is not absolute".to_owned(),
16854 });
16855 }
16856 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16857 workspace_id: workspace_id.clone(),
16858 path: root.to_owned(),
16859 message: "path has no standalone directory name".to_owned(),
16860 })?;
16861 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16862 workspace_id: workspace_id.clone(),
16863 path: root.to_owned(),
16864 message: "path has no parent directory".to_owned(),
16865 })?;
16866 let canonical = std::fs::canonicalize(parent)
16867 .map(|parent| parent.join(name))
16868 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
16869 workspace_id: workspace_id.clone(),
16870 path: root.to_owned(),
16871 message: error.to_string(),
16872 })?;
16873 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
16874 NodeServerError::InvalidWorkspaceRoot {
16875 workspace_id: workspace_id.clone(),
16876 path: path.to_string_lossy().into_owned(),
16877 message: "canonical path is not valid Unicode".to_owned(),
16878 }
16879 })?;
16880 let canonical_root = platform::normalize_canonical_root(canonical_root);
16881 validate_workspace_root(workspace_id, &canonical_root)?;
16882 Ok(canonical_root)
16883 }
16884 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
16885 workspace_id: workspace_id.clone(),
16886 path: root.to_owned(),
16887 message: error.to_string(),
16888 }),
16889 }
16890}
16891
16892fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
16893 let Some(revision) = revision else {
16894 return Ok(());
16895 };
16896 if revision.is_empty()
16897 || revision.len() > MAX_REPOSITORY_PATH_BYTES
16898 || revision.chars().any(char::is_control)
16899 {
16900 return Err(failure(
16901 NodeFailureCode::InvalidRequest,
16902 &format!(
16903 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
16904 ),
16905 ));
16906 }
16907 Ok(())
16908}
16909
16910fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16911 let code = match error.kind {
16912 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
16913 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
16914 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
16915 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
16916 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
16917 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
16918 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
16919 };
16920 failure(code, &error.message)
16921}
16922
16923fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
16924 let code = match error.kind {
16925 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
16926 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
16927 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
16928 NodeFailureCode::BackendOperationFailed
16929 }
16930 StandaloneWorkspaceErrorKind::RecoveryRequired => {
16931 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
16932 }
16933 };
16934 failure(code, error.message)
16935}
16936
16937fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16938 let (code, message) = match error.kind {
16939 GitWorktreeErrorKind::Invalid => (
16940 NodeFailureCode::InvalidRequest,
16941 "managed Git input is invalid",
16942 ),
16943 GitWorktreeErrorKind::NotRepository => (
16944 NodeFailureCode::NotGitRepository,
16945 "managed source is not a Git repository",
16946 ),
16947 GitWorktreeErrorKind::Conflict => (
16948 NodeFailureCode::WorktreeConflict,
16949 "managed Git identity conflicts with existing repository state",
16950 ),
16951 GitWorktreeErrorKind::Protected => (
16952 NodeFailureCode::ManagedWorktreeOwnershipConflict,
16953 "managed worktree ownership validation failed",
16954 ),
16955 GitWorktreeErrorKind::Dirty => (
16956 NodeFailureCode::WorktreeDirty,
16957 "managed worktree contains uncommitted changes",
16958 ),
16959 GitWorktreeErrorKind::Locked => (
16960 NodeFailureCode::WorktreeLocked,
16961 "managed worktree is locked",
16962 ),
16963 GitWorktreeErrorKind::Failed => (
16964 NodeFailureCode::BackendOperationFailed,
16965 "managed Git operation failed",
16966 ),
16967 };
16968 NodeFailure { code, message: message.to_owned() }
16969}
16970
16971fn managed_spawn_receipt(
16972 resolved: &ResolvedSpawnSpec,
16973 incarnation_id: NodeIncarnationId,
16974 session: SessionAddress,
16975 lease: ManagedWorktreeLeaseSnapshot,
16976 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
16977 bundle: Option<ResolvedBundleReceipt>,
16978 context: Option<ResolvedContextPackReceipt>,
16979) -> ManagedWorktreeSpawnReceipt {
16980 let mut managed = resolved.clone();
16981 managed.target.worktree_id = Some(lease.workspace_id.clone());
16982 ManagedWorktreeSpawnReceipt {
16983 spawn: managed.receipt_with_materialization(
16984 incarnation_id,
16985 session,
16986 environment_profile,
16987 bundle,
16988 context,
16989 ),
16990 lease,
16991 }
16992}
16993
16994fn validate_selected_worktree(
16995 source_root: &str,
16996 selected_root: &str,
16997 worktrees: &[NativeGitWorktreeSnapshot],
16998) -> Result<(), NodeFailure> {
16999 if !worktrees
17000 .iter()
17001 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17002 {
17003 return Err(failure(
17004 NodeFailureCode::WorktreeProtected,
17005 "source workspace is not an authoritative non-bare Git worktree root",
17006 ));
17007 }
17008 if !worktrees.iter().any(|worktree| {
17009 worktree_paths_equal(&worktree.path, selected_root)
17010 && !worktree.is_main
17011 && !worktree.is_bare
17012 }) {
17013 return Err(failure(
17014 NodeFailureCode::WorktreeProtected,
17015 "selected workspace is not an eligible Git worktree in the source repository",
17016 ));
17017 }
17018 Ok(())
17019}
17020
17021fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17022 let code = match error.kind() {
17023 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17024 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17025 NodeFailureCode::RepositoryPathUnsafe
17026 }
17027 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17028 WorkspaceFileReadErrorKind::NotRegularFile => {
17029 NodeFailureCode::RepositoryFileNotRegular
17030 }
17031 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17032 NodeFailureCode::RepositoryFileReadFailed
17033 }
17034 WorkspaceFileReadErrorKind::RevisionConflict => {
17035 NodeFailureCode::RepositoryFileRevisionConflict
17036 }
17037 WorkspaceFileReadErrorKind::AlreadyExists
17038 | WorkspaceFileReadErrorKind::ParentNotFound
17039 | WorkspaceFileReadErrorKind::ParentNotDirectory
17040 | WorkspaceFileReadErrorKind::Canceled => {
17041 NodeFailureCode::RepositoryFileReadFailed
17042 }
17043 };
17044 failure(code, "repository file read failed")
17045}
17046
17047fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17048 let code = match error.kind() {
17049 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17050 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17051 NodeFailureCode::RepositoryPathUnsafe
17052 }
17053 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17054 WorkspaceFileReadErrorKind::NotRegularFile => {
17055 NodeFailureCode::RepositoryFileNotRegular
17056 }
17057 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17058 NodeFailureCode::RepositoryFileWriteFailed
17059 }
17060 WorkspaceFileReadErrorKind::RevisionConflict => {
17061 NodeFailureCode::RepositoryFileRevisionConflict
17062 }
17063 WorkspaceFileReadErrorKind::AlreadyExists
17064 | WorkspaceFileReadErrorKind::ParentNotFound
17065 | WorkspaceFileReadErrorKind::ParentNotDirectory
17066 | WorkspaceFileReadErrorKind::Canceled => {
17067 NodeFailureCode::RepositoryFileWriteFailed
17068 }
17069 };
17070 failure(code, "repository file write failed")
17071}
17072
17073async fn settle_workspace_entry_create<T>(
17074 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17075 commit_state: Arc<AtomicU8>,
17076 deadline: Duration,
17077 timeout_message: &'static str,
17078 task_failure_message: &'static str,
17079) -> Result<T, NodeFailure> {
17080 let joined = match timeout(deadline, &mut task).await {
17081 Ok(joined) => joined,
17082 Err(_) => {
17083 if commit_state
17084 .compare_exchange(
17085 WORKSPACE_ENTRY_CREATE_PENDING,
17086 WORKSPACE_ENTRY_CREATE_CANCELED,
17087 Ordering::AcqRel,
17088 Ordering::Acquire,
17089 )
17090 .is_ok()
17091 {
17092 return Err(failure(
17093 NodeFailureCode::RepositoryEntryCreateTimedOut,
17094 timeout_message,
17095 ));
17096 }
17097 debug_assert_eq!(
17098 commit_state.load(Ordering::Acquire),
17099 WORKSPACE_ENTRY_CREATE_COMMITTING,
17100 );
17101 task.await
17102 }
17103 };
17104 joined
17105 .map_err(|_| {
17106 failure(
17107 NodeFailureCode::RepositoryEntryCreateFailed,
17108 task_failure_message,
17109 )
17110 })?
17111 .map_err(workspace_entry_create_failure)
17112}
17113
17114fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17115 let code = match error.kind() {
17116 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17117 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17118 NodeFailureCode::RepositoryPathUnsafe
17119 }
17120 WorkspaceFileReadErrorKind::AlreadyExists => {
17121 NodeFailureCode::RepositoryEntryAlreadyExists
17122 }
17123 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17124 NodeFailureCode::RepositoryParentNotFound
17125 }
17126 WorkspaceFileReadErrorKind::ParentNotDirectory
17127 | WorkspaceFileReadErrorKind::NotRegularFile => {
17128 NodeFailureCode::RepositoryParentNotDirectory
17129 }
17130 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17131 NodeFailureCode::RepositoryEntryCreateFailed
17132 }
17133 WorkspaceFileReadErrorKind::RevisionConflict => {
17134 NodeFailureCode::RepositoryEntryCreateFailed
17135 }
17136 WorkspaceFileReadErrorKind::Canceled => {
17137 NodeFailureCode::RepositoryEntryCreateTimedOut
17138 }
17139 };
17140 failure(code, "repository entry create failed")
17141}
17142
17143fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17144 let value = digest(&SHA256, bytes)
17145 .as_ref()
17146 .iter()
17147 .map(|byte| format!("{byte:02x}"))
17148 .collect();
17149 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17150}
17151
17152fn managed_spawn_request_digest_v2(
17153 request: &ManagedWorktreeSpawnRequestV2,
17154) -> Result<String, NodeFailure> {
17155 let encoded = serde_json::to_vec(request).map_err(|_| {
17156 failure(
17157 NodeFailureCode::InvalidRequest,
17158 "managed worktree V2 request could not be canonicalized",
17159 )
17160 })?;
17161 let mut value = String::with_capacity(71);
17162 value.push_str("sha256:");
17163 for byte in digest(&SHA256, &encoded).as_ref() {
17164 use std::fmt::Write as _;
17165 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17166 }
17167 Ok(value)
17168}
17169
17170fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17171 let code = match error {
17172 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17173 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17174 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17175 NativeSessionPreviewError::UnsupportedProvider
17176 | NativeSessionPreviewError::PreviewUnavailable
17177 | NativeSessionPreviewError::SessionNotFound
17178 | NativeSessionPreviewError::AmbiguousSession => {
17179 NodeFailureCode::BackendOperationFailed
17180 }
17181 };
17182 failure(code, "native session preview failed")
17183}
17184
17185fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17186 let code = match error.kind() {
17187 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17188 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17189 };
17190 failure(code, "host directory browse failed")
17191}
17192
17193fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17194 let code = match error {
17195 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17196 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17197 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17198 NodeFailureCode::DeliveryStageConflict
17199 }
17200 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17201 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17202 NodeFailureCode::DeliveryChunkOutOfOrder
17203 }
17204 DeliveryStoreError::BlobDigestMismatch => {
17205 NodeFailureCode::DeliveryBlobDigestMismatch
17206 }
17207 DeliveryStoreError::BundleDigestMismatch => {
17208 NodeFailureCode::DeliveryBundleDigestMismatch
17209 }
17210 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17211 DeliveryStoreError::Unavailable
17212 | DeliveryStoreError::Corrupt
17213 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17214 };
17215 failure(code, "delivery operation failed")
17216}
17217
17218fn validate_workspace_root(
17219 workspace_id: &WorkspaceId,
17220 canonical_root: &str,
17221) -> Result<(), NodeServerError> {
17222 #[cfg(windows)]
17223 if canonical_root.starts_with(r"\\") {
17224 return Err(NodeServerError::InvalidWorkspaceRoot {
17225 workspace_id: workspace_id.clone(),
17226 path: canonical_root.to_owned(),
17227 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17228 });
17229 }
17230 if !platform::workspace_root_supported(canonical_root)
17231 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17232 {
17233 return Err(NodeServerError::InvalidWorkspaceRoot {
17234 workspace_id: workspace_id.clone(),
17235 path: canonical_root.to_owned(),
17236 message: "canonical path must be absolute, bounded, and free of control characters"
17237 .to_owned(),
17238 });
17239 }
17240 Ok(())
17241}
17242
17243fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17244 NodeFailure {
17245 code,
17246 message: node_failure_category(code).to_owned(),
17247 }
17248}
17249
17250fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17251 let code = match error {
17252 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17253 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17254 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17255 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17256 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17257 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17258 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17259 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17260 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17261 };
17262 failure(code, "harness MCP proxy operation failed")
17263}
17264
17265static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17269
17270fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17277 let trace = launch.trace.as_ref()?;
17278 let trace_dir = std::env::var(&trace.dir_env).ok()?;
17279 if trace_dir.is_empty() {
17280 return None;
17281 }
17282 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17283 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17284 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17285}
17286
17287fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17288 let remaining = deadline.saturating_duration_since(Instant::now());
17289 if remaining.is_zero() {
17290 Err(failure(
17291 NodeFailureCode::SpawnDeadlineExceeded,
17292 "spawn deadline elapsed",
17293 ))
17294 } else {
17295 Ok(remaining)
17296 }
17297}
17298
17299fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17300 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17301 match deadline {
17302 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17303 None => Ok(bounded),
17304 }
17305}
17306
17307fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17308 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17309 failure(
17310 NodeFailureCode::SpawnDeadlineExceeded,
17311 "spawn dispatch exceeded the spawn deadline",
17312 )
17313 } else {
17314 error
17315 }
17316}
17317
17318fn spawn_runtime_capabilities(
17319 capabilities: &SpawnRequiredCapabilities,
17320) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17321 capabilities
17322 .iter()
17323 .map(|capability| match capability.as_str() {
17324 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17325 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17326 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17327 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17328 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17329 }
17330 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17331 _ => Err(failure(
17332 NodeFailureCode::UnsupportedSpawnCapability,
17333 "spawn required capability is unknown",
17334 )),
17335 })
17336 .collect()
17337}
17338
17339fn persistence_failure(_error: io::Error) -> NodeFailure {
17340 NodeFailure {
17341 code: NodeFailureCode::BackendOperationFailed,
17342 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17343 }
17344}
17345
17346fn node_failure_category(code: NodeFailureCode) -> &'static str {
17347 match code {
17348 NodeFailureCode::InvalidRequest => "invalid-request",
17349 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17350 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17351 NodeFailureCode::ReservationNotFound => "reservation-not-found",
17352 NodeFailureCode::ReservationConflict => "reservation-conflict",
17353 NodeFailureCode::ReservationExpired => "reservation-expired",
17354 NodeFailureCode::BindingMismatch => "binding-mismatch",
17355 NodeFailureCode::NotActivated => "not-activated",
17356 NodeFailureCode::CallNotFound => "call-not-found",
17357 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17358 NodeFailureCode::ResponseTooLarge => "response-too-large",
17359 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17360 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17361 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17362 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17363 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17364 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17365 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17366 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17367 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17368 NodeFailureCode::Unauthorized => "unauthorized",
17369 NodeFailureCode::ObserverReadOnly => "observer-read-only",
17370 NodeFailureCode::ControllerBusy => "controller-busy",
17371 NodeFailureCode::ControllerRequired => "controller-required",
17372 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17373 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17374 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17375 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17376 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17377 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17378 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17379 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17380 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17381 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17382 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17383 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17384 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17385 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17386 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17387 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17388 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17389 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17390 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17391 NodeFailureCode::GitReadFailed => "git-read-failed",
17392 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17393 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17394 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17395 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17396 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17397 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17398 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17399 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17400 NodeFailureCode::TurnInFlight => "turn-in-flight",
17401 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17402 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17403 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17404 "unsupported-network-allowlist-mapping"
17405 }
17406 NodeFailureCode::BrowserStationProbeUnavailable => {
17407 "browser-station-probe-unavailable"
17408 }
17409 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17410 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17411 NodeFailureCode::EnvironmentProfileBindingMismatch => {
17412 "environment-profile-binding-mismatch"
17413 }
17414 NodeFailureCode::UnknownBundle => "unknown-bundle",
17415 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17416 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17417 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17418 NodeFailureCode::ContextPackBusy => "context-pack-busy",
17419 NodeFailureCode::ContextPackMaterializationFailed => {
17420 "context-pack-materialization-failed"
17421 }
17422 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17423 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17424 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17425 NodeFailureCode::WorkspaceBusy => "workspace-busy",
17426 NodeFailureCode::LastWorkspace => "last-workspace",
17427 NodeFailureCode::NotGitRepository => "not-git-repository",
17428 NodeFailureCode::WorktreeConflict => "worktree-conflict",
17429 NodeFailureCode::WorktreeProtected => "worktree-protected",
17430 NodeFailureCode::WorktreeDirty => "worktree-dirty",
17431 NodeFailureCode::WorktreeLocked => "worktree-locked",
17432 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17433 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17434 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17435 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17436 "managed-worktree-profile-revision-mismatch"
17437 }
17438 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17439 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17440 "standalone-workspace-recovery-required"
17441 }
17442 NodeFailureCode::UnknownSession => "unknown-session",
17443 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17444 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17445 NodeFailureCode::SessionRecordBusy => "session-record-busy",
17446 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17447 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17448 NodeFailureCode::WorkspaceRegistrationRequired => {
17449 "workspace-registration-required"
17450 }
17451 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17452 NodeFailureCode::StaleGeneration => "stale-generation",
17453 NodeFailureCode::BackendBusy => "backend-busy",
17454 NodeFailureCode::BackendDisconnected => "backend-disconnected",
17455 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17456 NodeFailureCode::ShuttingDown => "shutting-down",
17457 }
17458}
17459
17460fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17461 NodeServerError::DurableState(io::Error::new(error.kind(), category))
17462}
17463
17464fn durable_state_load_error(error: io::Error) -> NodeServerError {
17465 let category = match session_registry::state_load_refusal(&error) {
17466 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17467 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17468 }
17469 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17470 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17471 }
17472 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17473 DURABLE_STATE_CONFLICT_ERROR
17474 }
17475 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17476 };
17477 durable_state_server_error(error, category)
17478}
17479
17480fn unix_time_ms() -> u64 {
17481 std::time::SystemTime::now()
17482 .duration_since(std::time::UNIX_EPOCH)
17483 .unwrap_or_default()
17484 .as_millis()
17485 .min(u64::MAX as u128) as u64
17486}
17487
17488fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17489 if record.provider_session.is_some() {
17490 ManagedSessionState::Dormant
17491 } else {
17492 ManagedSessionState::Unavailable
17493 }
17494}
17495
17496fn active_record_state(
17497 identity_admitted: bool,
17498 provider_session_is_known: bool,
17499) -> ManagedSessionState {
17500 if identity_admitted && !provider_session_is_known {
17501 ManagedSessionState::IdentityPending
17502 } else {
17503 ManagedSessionState::Live
17504 }
17505}
17506
17507fn managed_worktree_record_holders(
17508 records: &[ManagedSessionRecord],
17509) -> Vec<(SessionRecordId, WorkspaceId)> {
17510 records
17511 .iter()
17512 .filter(|record| record.provider_session.is_some())
17513 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17514 .collect()
17515}
17516
17517fn managed_spawn_record_ownership(
17518 binding: &SessionBinding,
17519 effective_runtime_policy: ProviderRuntimePolicy,
17520) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17521 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17522 if effective_runtime_policy.provider_session_identity {
17523 (binding.record_id.clone(), None)
17524 } else {
17525 (None, binding.record_id.clone())
17526 }
17527}
17528
17529#[derive(Debug, Error)]
17530pub enum NodeServerError {
17531 #[error("node endpoint must be a bounded absolute local transport path")]
17532 InvalidEndpoint,
17533 #[error("node access token must contain 1..=4096 bytes")]
17534 InvalidAccessToken,
17535 #[error("node HTTP observer must listen on a loopback address: {0}")]
17536 InvalidApiListen(std::net::SocketAddr),
17537 #[error("node browser bridge must listen on a loopback address: {0}")]
17538 InvalidBridgeListen(std::net::SocketAddr),
17539 #[error("node browser bridge token must contain 1..=4096 bytes")]
17540 InvalidBridgeToken,
17541 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17542 BridgeUnderlayRequiresBridgeListen,
17543 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17544 BridgeUnderlayRequiresBridgeToken,
17545 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17546 InvalidBridgeUnderlayToken,
17547 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17548 InvalidBridgeUnderlayKey,
17549 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17550 InvalidCallHome(std::net::SocketAddr),
17551 #[error("node could not reach its call-home relay: {0}")]
17552 CallHomeTransport(#[source] std::io::Error),
17553 #[error("node durable state path must be an absolute file path: {0}")]
17554 InvalidStatePath(String),
17555 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17556 InvalidHarnessMcpHelper,
17557 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17558 InvalidNetworkAllowlistCatalogPath(String),
17559 #[error("network allowlist catalog could not be read: {0}")]
17560 NetworkAllowlistCatalogIo(#[source] std::io::Error),
17561 #[error("network allowlist catalog line {line}: {message}")]
17562 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17563 #[error("network allowlist catalog: {0}")]
17564 InvalidNetworkAllowlistCatalog(String),
17565 #[error("network allowlist catalog exceeds the {max}-entry limit")]
17566 NetworkAllowlistCatalogCapacity { max: usize },
17567 #[error("the local state directory is unavailable; supply an explicit state path")]
17568 LocalStateDirectoryUnavailable,
17569 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17570 LocalRuntimeDirectoryUnavailable,
17571 #[error("node durable state failed: {0}")]
17572 DurableState(io::Error),
17573 #[error("node requires at least one configured workspace")]
17574 NoWorkspaces,
17575 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17576 InvalidWorkspaceRoot {
17577 workspace_id: WorkspaceId,
17578 path: String,
17579 message: String,
17580 },
17581 #[error("duplicate workspace ID: {0}")]
17582 DuplicateWorkspaceId(WorkspaceId),
17583 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17584 DuplicateWorkspaceRoot {
17585 first: WorkspaceId,
17586 second: WorkspaceId,
17587 root: String,
17588 },
17589 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17590 ManagedWorktreeProfileRequired(WorkspaceId),
17591 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17592 ManagedWorktreeProfileModeMismatch(WorkspaceId),
17593 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17594 DuplicateManagedWorktreeProfile {
17595 workspace_id: WorkspaceId,
17596 profile_id: WorktreeProfileId,
17597 },
17598 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17599 ManagedWorktreeProfileCapacity {
17600 workspace_id: WorkspaceId,
17601 max: usize,
17602 },
17603 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17604 InvalidManagedWorktreeProfile {
17605 workspace_id: WorkspaceId,
17606 profile_id: WorktreeProfileId,
17607 message: String,
17608 },
17609 #[error("duplicate node environment profile: {0}")]
17610 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17611 #[error("node environment profile provider is unavailable: {0}")]
17612 EnvironmentProfileProviderUnavailable(AgentId),
17613 #[error("node environment profile capacity is {max}")]
17614 EnvironmentProfileCapacity { max: usize },
17615 #[error("node environment profiles reuse native profile ID '{0}'")]
17616 DuplicateNativeEnvironmentProfile(String),
17617 #[error("node native environment profile is invalid: {0}")]
17618 NativeEnvironmentProfile(String),
17619 #[error("node session-environment materialization root must be absolute")]
17620 InvalidSessionEnvironmentRoot,
17621 #[error("node session-environment materializer is unavailable")]
17622 SessionEnvironmentMaterializerRequired,
17623 #[error("node session-environment materializer failed to initialize")]
17624 SessionEnvironmentMaterializer,
17625 #[error("node bundle catalog is invalid: {0}")]
17626 BundleCatalog(String),
17627 #[error("node delivery store failed to initialize: {0}")]
17628 DeliveryStore(String),
17629 #[error("node context pack store failed to initialize")]
17630 ContextPackStore,
17631 #[error("active agent registry failed: {0}")]
17632 Registry(String),
17633 #[error("node provider contract manifest is invalid: {0}")]
17634 ProviderContractManifest(String),
17635 #[error("named pipe I/O failed: {0}")]
17636 Io(#[from] io::Error),
17637 #[error("node HTTP observer failed: {0}")]
17638 HttpApi(io::Error),
17639 #[error("node browser bridge failed: {0}")]
17640 BridgeApi(io::Error),
17641 #[error(transparent)]
17642 Frame(#[from] FrameError),
17643 #[error("node handshake failed: {0}")]
17644 Handshake(String),
17645 #[error("node authentication frame was not received before the bounded deadline")]
17646 AuthenticationTimedOut,
17647 #[error("node authentication primitive failed: {0}")]
17648 Authentication(String),
17649 #[error("node incarnation identity generation failed: {0}")]
17650 IncarnationIdentity(String),
17651 #[error("node pre-authentication limiter is closed")]
17652 PreauthClosed,
17653 #[error("node authenticated connection limit was reached")]
17654 AuthenticatedConnectionLimit,
17655 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17656 SignalStreamClosed,
17657 #[error("node stop-all dispatch failed: {0:?}")]
17658 ShutdownDispatch(NodeFailure),
17659 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17660 ShutdownTimedOut { active_native_sessions: usize },
17661}
17662
17663impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17664 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17665 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17666 match error {
17667 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17668 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17669 E::InvalidEntry { line, message } => {
17670 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17671 }
17672 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17673 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17674 }
17675 }
17676}
17677
17678#[cfg(test)]
17679mod agent_stream_blocked_tests {
17680 use super::*;
17681 use gate4agent_types::{
17682 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17683 ProviderSource,
17684 };
17685
17686 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17687 ProviderSource {
17688 family,
17689 binding: AdapterBinding::new(
17690 AdapterId::new(adapter).unwrap(),
17691 "fixture/v1",
17692 AdapterVerification::SyntheticFixture,
17693 )
17694 .unwrap(),
17695 }
17696 }
17697
17698 fn provider_control_event_at(
17699 family: AdapterFamily,
17700 adapter: &str,
17701 source_sequence: u64,
17702 provider_event: ProviderEvent,
17703 ) -> ControlEvent {
17704 ControlEvent {
17705 sequence: 41,
17706 command_id: None,
17707 instance_id: AgentInstanceId(7),
17708 generation: SessionGeneration(3),
17709 event: ControlEventKind::ProviderEvent {
17710 sequence: 8,
17711 source: provider_source(family, adapter),
17712 source_sequence,
17713 event: provider_event,
17714 },
17715 }
17716 }
17717
17718 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17719 provider_control_event_at(family, "fixture", 9, provider_event)
17720 }
17721
17722 fn denied_host_request(
17723 by: ProviderHostDecisionAuthority,
17724 reason: Option<String>,
17725 ) -> ControlEvent {
17726 provider_control_event_at(
17727 AdapterFamily::Acp,
17728 "claude-code",
17729 2,
17730 ProviderEvent::HostRequestObserved {
17731 method: "terminal/create".to_owned(),
17732 params_json: String::new(),
17733 decision: ProviderHostRequestDecision::Denied { by },
17734 outcome: ProviderHostRequestOutcome::Executed,
17735 reason,
17736 },
17737 )
17738 }
17739
17740 fn blocked(
17741 event: &ControlEvent,
17742 ) -> (
17743 Option<String>,
17744 String,
17745 BlockAuthorityV1,
17746 Option<String>,
17747 String,
17748 Option<String>,
17749 ) {
17750 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17751 chunk.validate().expect("minted chunk must validate");
17752 let AgentStreamChunkKindV1::Blocked {
17753 correlation_id,
17754 tool_class,
17755 authority,
17756 reason_kind,
17757 reason,
17758 help,
17759 } = chunk.kind
17760 else {
17761 panic!("expected AgentStreamChunkKindV1::Blocked");
17762 };
17763 (correlation_id, tool_class, authority, reason_kind, reason, help)
17764 }
17765
17766 #[test]
17770 fn the_enabled_provider_matrix_is_exact() {
17771 let registry = active_registry().unwrap();
17772 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
17773 assert_eq!(
17774 providers
17775 .iter()
17776 .map(|contract| contract.provider.as_str())
17777 .collect::<Vec<_>>(),
17778 vec!["claude", "codex", "grok", "kimi"],
17779 );
17780 assert!(adapters.iter().all(|contract| {
17781 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
17782 }));
17783 }
17784
17785 #[test]
17788 fn gate_denied_host_request_mints_a_blocked_chunk() {
17789 let gate_text =
17790 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
17791 let event = denied_host_request(
17792 ProviderHostDecisionAuthority::Gate,
17793 Some(gate_text.to_owned()),
17794 );
17795 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
17796 assert_eq!(chunk.source_sequence, 2);
17797 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
17798 assert_eq!(authority, BlockAuthorityV1::HostGate);
17799 assert_eq!(reason, gate_text);
17800 assert_eq!(correlation_id, None);
17801 assert_eq!(reason_kind, None);
17802 assert_eq!(help, None);
17803 assert_eq!(tool_class, "Write");
17804 }
17805
17806 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.";
17807
17808 #[test]
17814 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
17815 let event = provider_control_event(
17816 AdapterFamily::Acp,
17817 ProviderEvent::ToolCompleted {
17818 id: "toolu_9".to_owned(),
17819 output: CLASSIFIER_SAMPLE.to_owned(),
17820 is_error: true,
17821 duration_ms: None,
17822 agent_id: None,
17823 non_execution_kind: Some("permission-rule".to_owned()),
17824 },
17825 );
17826 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
17827 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
17828 assert_eq!(reason, "Blocked by classifier");
17829 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
17830 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
17831 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
17832 }
17833
17834 #[test]
17839 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
17840 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.";
17841 let event = provider_control_event(
17842 AdapterFamily::Acp,
17843 ProviderEvent::SessionEnded {
17844 result: "JSON-RPC error -32603: Internal error".to_owned(),
17845 cost_usd: None,
17846 is_error: true,
17847 stop_reason: Some(ProviderStopReason::ProviderError {
17848 code: -32603,
17849 message: message.to_owned(),
17850 vendor_code: Some("usageLimitExceeded".to_owned()),
17851 }),
17852 },
17853 );
17854 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
17855 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
17856 assert_eq!(reason, message);
17857 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
17858 assert_eq!(tool_class, "Session");
17859 }
17860
17861 #[test]
17864 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
17865 let event = provider_control_event(
17866 AdapterFamily::Acp,
17867 ProviderEvent::SessionEnded {
17868 result: "refusal".to_owned(),
17869 cost_usd: None,
17870 is_error: true,
17871 stop_reason: Some(ProviderStopReason::Refusal),
17872 },
17873 );
17874 let (_, _, authority, _, _, _) = blocked(&event);
17875 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
17876 }
17877
17878 #[test]
17882 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
17883 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
17884 let event = provider_control_event(
17885 AdapterFamily::Acp,
17886 ProviderEvent::ToolCompleted {
17887 id: "toolu_1".to_owned(),
17888 output: "command exited with status 1".to_owned(),
17889 is_error: true,
17890 duration_ms: Some(12),
17891 agent_id: None,
17892 non_execution_kind,
17893 },
17894 );
17895 assert!(agent_stream_chunk(&event).is_none());
17896 }
17897 let narrative = provider_control_event(
17898 AdapterFamily::Acp,
17899 ProviderEvent::ToolCompleted {
17900 id: "toolu_2".to_owned(),
17901 output: "the harness blocked the write, approval needed".to_owned(),
17902 is_error: false,
17903 duration_ms: Some(3),
17904 agent_id: None,
17905 non_execution_kind: None,
17906 },
17907 );
17908 assert!(agent_stream_chunk(&narrative).is_none());
17909 }
17910
17911 #[test]
17915 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
17916 for (decision, outcome) in [
17917 (
17918 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17919 ProviderHostRequestOutcome::Executed,
17920 ),
17921 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
17922 (
17923 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17924 ProviderHostRequestOutcome::Failed {
17925 error: "terminal/create spawn failed: os error 3".to_owned(),
17926 },
17927 ),
17928 ] {
17929 let event = provider_control_event_at(
17930 AdapterFamily::Acp,
17931 "claude-code",
17932 3,
17933 ProviderEvent::HostRequestObserved {
17934 method: "fs/read_text_file".to_owned(),
17935 params_json: String::new(),
17936 decision: decision.clone(),
17937 outcome,
17938 reason: None,
17939 },
17940 );
17941 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
17942 }
17943 }
17944
17945 #[test]
17949 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
17950 for (by, expected_authority, expected_reason) in [
17951 (
17952 ProviderHostDecisionAuthority::Operator,
17953 BlockAuthorityV1::Operator,
17954 "declined by the operator",
17955 ),
17956 (
17957 ProviderHostDecisionAuthority::DeadlinePolicy,
17958 BlockAuthorityV1::HostDeadline,
17959 "denied by host policy after the operator deadline expired",
17960 ),
17961 ] {
17962 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
17963 assert_eq!(authority, expected_authority);
17964 assert_eq!(reason, expected_reason);
17965 }
17966 }
17967
17968 #[test]
17972 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
17973 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
17974 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
17975 ProviderHostDecisionAuthority::Policy,
17976 Some(oversized),
17977 ));
17978 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
17979 }
17980}
17981
17982#[cfg(test)]
17983mod standalone_capability_platform_tests {
17984 use super::*;
17985
17986 #[test]
17987 fn standalone_workspace_capability_requires_windows_identity_guards() {
17988 let capabilities = baseline_capabilities().unwrap();
17989 let advertised = capabilities.iter().any(|capability| {
17990 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
17991 });
17992 assert_eq!(advertised, cfg!(windows));
17993
17994 let request = NodeRequest::CreateStandaloneWorkspace {
17995 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
17996 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
17997 initial_branch: None,
17998 };
17999 assert_eq!(
18000 request_uses_unnegotiated_capability(&request, &capabilities),
18001 !cfg!(windows),
18002 );
18003 }
18004}
18005
18006
18007#[cfg(test)]
18008mod station_knobs_resolve_tests {
18009 use super::*;
18010 use gate4agent_catalog::EnvMutation;
18011 use gate4agent_runtime_native::{
18012 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18013 NativeLaunchProfileId,
18014 };
18015 use std::ffi::OsString;
18016 use std::sync::Arc;
18017
18018 struct EmptyEnvironmentResolver;
18019
18020 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18021 fn resolve_child_environment(
18022 &self,
18023 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18024 Ok(vec![EnvMutation {
18025 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18026 value: None,
18027 }])
18028 }
18029 }
18030
18031 fn agent(value: &str) -> AgentId {
18032 AgentId::new(value).unwrap()
18033 }
18034
18035 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18036 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18037 let profile_revision =
18038 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18039 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18040 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18041 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18042 provider: agent("claude"),
18043 mode: SessionMode::Pty,
18044 terminal_size: gate4agent_types::TerminalSize {
18045 rows: 24,
18046 columns: 80,
18047 },
18048 prompt: None,
18049 bundle_id: None,
18050 context_id: None,
18051 environment_profile_id: Some(profile_id.clone()),
18052 };
18053 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18054 let catalog = active_registry().unwrap();
18055 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18056 let control = runtime.native_launch_profile_control();
18057 let node_profile = NodeEnvironmentProfile::new(
18058 profile_id.clone(),
18059 profile_revision,
18060 agent("claude"),
18061 [NativeLaunchProfile::new(
18062 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18063 agent("claude"),
18064 TransportKind::Pty,
18065 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18066 Arc::new(EmptyEnvironmentResolver),
18067 )
18068 .unwrap()],
18069 )
18070 .unwrap();
18071 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18072 for native_profile in native_profiles {
18073 runtime.upsert_native_launch_profile(native_profile).unwrap();
18074 }
18075 let workspace = WorkspaceConfig::new(
18076 WorkspaceId::new("primary").unwrap(),
18077 std::env::current_dir().unwrap(),
18078 )
18079 .unwrap();
18080 let shared = NodeShared::new_with_incarnation(
18081 handle,
18082 "fixture-token".to_owned(),
18083 NodeId::new("node-station-knobs-test").unwrap(),
18084 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18085 vec![workspace],
18086 vec![agent("claude")],
18087 ProviderRuntimeStatuses::default(),
18088 None,
18089 Vec::new(),
18090 Vec::new(),
18091 spawn_profiles,
18092 Some(control),
18093 None,
18094 None,
18095 None,
18096 Vec::new(),
18097 Vec::new(),
18098 Vec::new(),
18099 Vec::new(),
18100 None,
18101 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18102 );
18103 shared
18104 .environment_profiles
18105 .write()
18106 .unwrap_or_else(|poisoned| poisoned.into_inner())
18107 .insert(binding.id.clone(), binding);
18108 (shared, spawn_defaults)
18109 }
18110
18111 #[test]
18114 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18115 let (shared, defaults) = station_knobs_fixture();
18116 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18117 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18118 let spec = crate::protocol::SpawnSpec {
18119 target: crate::protocol::SpawnTarget {
18120 node_id: shared.node_id.clone(),
18121 workspace_id: WorkspaceId::new("primary").unwrap(),
18122 worktree_id: None,
18123 },
18124 profile_id: defaults.profile_id.clone(),
18125 expected_profile_revision: defaults.revision.clone(),
18126 overrides: crate::protocol::SpawnOverrides {
18127 network_allowlist: Some(unknown),
18128 browser_profile_id: Some(browser),
18129 ..crate::protocol::SpawnOverrides::default()
18130 },
18131 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18132 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18133 "station-knobs-unknown-allowlist",
18134 )
18135 .unwrap(),
18136 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18137 };
18138 let resolved = spec.resolve(&defaults).unwrap();
18139 let failure = shared
18140 .resolve_environment_profile(&resolved)
18141 .expect_err("unknown allowlist must refuse");
18142 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18143 }
18144
18145 #[test]
18151 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18152 let (shared, defaults) = station_knobs_fixture();
18153 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18154 #[cfg(not(feature = "dig2-station-probe"))]
18155 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18156 shared
18157 .network_allowlist_catalog
18158 .write()
18159 .unwrap_or_else(|poisoned| poisoned.into_inner())
18160 .insert_id(allowlist.clone())
18161 .unwrap();
18162 let spec = crate::protocol::SpawnSpec {
18163 target: crate::protocol::SpawnTarget {
18164 node_id: shared.node_id.clone(),
18165 workspace_id: WorkspaceId::new("primary").unwrap(),
18166 worktree_id: None,
18167 },
18168 profile_id: defaults.profile_id.clone(),
18169 expected_profile_revision: defaults.revision.clone(),
18170 overrides: crate::protocol::SpawnOverrides {
18171 network_allowlist: Some(allowlist.clone()),
18172 #[cfg(not(feature = "dig2-station-probe"))]
18173 browser_profile_id: Some(browser.clone()),
18174 ..crate::protocol::SpawnOverrides::default()
18175 },
18176 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18177 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18178 .unwrap(),
18179 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18180 };
18181 let resolved = spec.resolve(&defaults).unwrap();
18182 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18183 #[cfg(not(feature = "dig2-station-probe"))]
18184 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18185 let receipt = shared
18186 .resolve_environment_profile(&resolved)
18187 .expect("resolve ok")
18188 .expect("env profile present");
18189 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18190 #[cfg(not(feature = "dig2-station-probe"))]
18191 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18192 #[cfg(feature = "dig2-station-probe")]
18193 assert_eq!(receipt.browser_profile_id, None);
18194 assert_eq!(receipt.profile_id.as_str(), "local-claude");
18195
18196 let omitted = crate::protocol::SpawnSpec {
18197 overrides: crate::protocol::SpawnOverrides::default(),
18198 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18199 .unwrap(),
18200 ..spec
18201 };
18202 let resolved_omit = omitted.resolve(&defaults).unwrap();
18203 let bare = shared
18204 .resolve_environment_profile(&resolved_omit)
18205 .expect("resolve ok")
18206 .expect("env profile present");
18207 assert_eq!(bare.network_allowlist, None);
18208 assert_eq!(bare.browser_profile_id, None);
18209 }
18210
18211 #[cfg(feature = "dig2-station-probe")]
18216 #[test]
18217 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18218 let (shared, defaults) = station_knobs_fixture();
18219 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18220 let spec = crate::protocol::SpawnSpec {
18221 target: crate::protocol::SpawnTarget {
18222 node_id: shared.node_id.clone(),
18223 workspace_id: WorkspaceId::new("primary").unwrap(),
18224 worktree_id: None,
18225 },
18226 profile_id: defaults.profile_id.clone(),
18227 expected_profile_revision: defaults.revision.clone(),
18228 overrides: crate::protocol::SpawnOverrides {
18229 browser_profile_id: Some(browser),
18230 ..crate::protocol::SpawnOverrides::default()
18231 },
18232 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18233 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18234 "station-knobs-probe-refuse",
18235 )
18236 .unwrap(),
18237 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18238 };
18239 let resolved = spec.resolve(&defaults).unwrap();
18240 let failure = shared
18241 .resolve_environment_profile(&resolved)
18242 .expect_err("browser_profile with failing probe must refuse");
18243 #[cfg(windows)]
18244 {
18245 assert!(
18246 matches!(
18247 failure.code,
18248 NodeFailureCode::BrowserStationUnreachable
18249 | NodeFailureCode::BrowserStationProbeUnavailable
18250 ),
18251 "unexpected code {:?}",
18252 failure.code
18253 );
18254 }
18255 #[cfg(not(windows))]
18256 {
18257 assert_eq!(
18258 failure.code,
18259 NodeFailureCode::BrowserStationProbeUnavailable
18260 );
18261 }
18262 }
18263
18264 #[cfg(feature = "dig2-station-probe")]
18266 #[test]
18267 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18268 let (shared, defaults) = station_knobs_fixture();
18269 let spec = crate::protocol::SpawnSpec {
18270 target: crate::protocol::SpawnTarget {
18271 node_id: shared.node_id.clone(),
18272 workspace_id: WorkspaceId::new("primary").unwrap(),
18273 worktree_id: None,
18274 },
18275 profile_id: defaults.profile_id.clone(),
18276 expected_profile_revision: defaults.revision.clone(),
18277 overrides: crate::protocol::SpawnOverrides::default(),
18278 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18279 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18280 "station-knobs-probe-omit",
18281 )
18282 .unwrap(),
18283 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18284 };
18285 let resolved = spec.resolve(&defaults).unwrap();
18286 let receipt = shared
18287 .resolve_environment_profile(&resolved)
18288 .expect("omit browser_profile must not probe")
18289 .expect("env profile present");
18290 assert_eq!(receipt.browser_profile_id, None);
18291 }
18292
18293 #[cfg(feature = "dig2-station-probe")]
18297 #[test]
18298 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18299 let (shared, _defaults) = station_knobs_fixture();
18300 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18301 let workspace_id = WorkspaceId::new("primary").unwrap();
18302 let address_a = SessionAddress {
18303 workspace_id: workspace_id.clone(),
18304 session: SessionKey {
18305 instance_id: AgentInstanceId(101),
18306 generation: SessionGeneration(1),
18307 },
18308 };
18309 let address_b = SessionAddress {
18310 workspace_id: workspace_id.clone(),
18311 session: SessionKey {
18312 instance_id: AgentInstanceId(102),
18313 generation: SessionGeneration(1),
18314 },
18315 };
18316 let guard_a = crate::dig2_station_lease::acquire_lease(
18317 &shared.browser_station_leases,
18318 profile.clone(),
18319 address_a.session.instance_id,
18320 1,
18321 )
18322 .expect("first lease");
18323 let busy = crate::dig2_station_lease::acquire_lease(
18324 &shared.browser_station_leases,
18325 profile.clone(),
18326 address_b.session.instance_id,
18327 2,
18328 )
18329 .expect_err("second holder must refuse");
18330 assert!(matches!(
18331 busy,
18332 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18333 ));
18334 shared.bind_session_with_policy(
18336 &address_a,
18337 ProviderRuntimePolicy::raw_pty(),
18338 None,
18339 );
18340 guard_a.retain();
18341 assert!(shared.remove_binding(&address_a).is_some());
18342 crate::dig2_station_lease::acquire_lease(
18343 &shared.browser_station_leases,
18344 profile,
18345 address_b.session.instance_id,
18346 3,
18347 )
18348 .expect("reacquire after clear")
18349 .retain();
18350 assert!(
18351 crate::dig2_station_lease::release_lease_for_holder(
18352 &shared.browser_station_leases,
18353 address_b.session.instance_id,
18354 )
18355 );
18356 }
18357
18358 #[cfg(not(feature = "dig2-station-probe"))]
18361 #[test]
18362 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18363 let (shared, defaults) = station_knobs_fixture();
18364 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18365 let spec = crate::protocol::SpawnSpec {
18366 target: crate::protocol::SpawnTarget {
18367 node_id: shared.node_id.clone(),
18368 workspace_id: WorkspaceId::new("primary").unwrap(),
18369 worktree_id: None,
18370 },
18371 profile_id: defaults.profile_id.clone(),
18372 expected_profile_revision: defaults.revision.clone(),
18373 overrides: crate::protocol::SpawnOverrides {
18374 browser_profile_id: Some(browser.clone()),
18375 ..crate::protocol::SpawnOverrides::default()
18376 },
18377 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18378 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18379 .unwrap(),
18380 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18381 };
18382 let resolved = spec.resolve(&defaults).unwrap();
18383 let receipt = shared
18384 .resolve_environment_profile(&resolved)
18385 .expect("stub resolve")
18386 .expect("env profile");
18387 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18388 }
18389
18390
18391 #[test]
18394 fn network_allowlist_catalog_loads_from_temp_file() {
18395 let unique = std::time::SystemTime::now()
18396 .duration_since(std::time::UNIX_EPOCH)
18397 .unwrap()
18398 .as_nanos();
18399 let path = std::env::temp_dir().join(format!(
18400 "gate4agent-node-allowlist-config-{}-{unique}.txt",
18401 std::process::id(),
18402 ));
18403 std::fs::write(
18404 &path,
18405 "# opaque station allowlist ids\negress-default\nlab-egress\n",
18406 )
18407 .unwrap();
18408 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18409 .expect("temp catalog file must load");
18410 let _ = std::fs::remove_file(&path);
18411 assert_eq!(loaded.len(), 2);
18412 let (shared, defaults) = station_knobs_fixture();
18413 {
18414 let mut catalog = shared
18415 .network_allowlist_catalog
18416 .write()
18417 .unwrap_or_else(|poisoned| poisoned.into_inner());
18418 *catalog = loaded;
18419 }
18420 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18421 let spec = crate::protocol::SpawnSpec {
18422 target: crate::protocol::SpawnTarget {
18423 node_id: shared.node_id.clone(),
18424 workspace_id: WorkspaceId::new("primary").unwrap(),
18425 worktree_id: None,
18426 },
18427 profile_id: defaults.profile_id.clone(),
18428 expected_profile_revision: defaults.revision.clone(),
18429 overrides: crate::protocol::SpawnOverrides {
18430 network_allowlist: Some(allowlist.clone()),
18431 ..crate::protocol::SpawnOverrides::default()
18432 },
18433 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18434 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18435 "station-knobs-catalog-file",
18436 )
18437 .unwrap(),
18438 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18439 };
18440 let resolved = spec.resolve(&defaults).unwrap();
18441 let receipt = shared
18442 .resolve_environment_profile(&resolved)
18443 .expect("registered via file load")
18444 .expect("env profile present");
18445 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18446 }
18447
18448 #[test]
18451 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18452 let (shared, _defaults) = station_knobs_fixture();
18453 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18454 shared
18455 .network_allowlist_catalog
18456 .write()
18457 .unwrap_or_else(|poisoned| poisoned.into_inner())
18458 .insert_id(allowlist.clone())
18459 .unwrap();
18460 let snapshot = shared.snapshot();
18461 let inventory = snapshot
18462 .launch_inventory
18463 .expect("launch inventory present");
18464 let ids = inventory
18465 .network_allowlists
18466 .expect("non-empty catalog surfaces ids");
18467 assert_eq!(ids, vec![allowlist]);
18468 }
18469
18470 #[test]
18474 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18475 let (shared, defaults) = station_knobs_fixture();
18476 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18477 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18478 id: allowlist.clone(),
18479 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18480 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18481 peer: "127.0.0.1:443".to_owned(),
18482 }],
18483 provider_native: Some(
18484 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18485 codex_network_access: Some(true),
18486 },
18487 ),
18488 };
18489 shared
18490 .network_allowlist_catalog
18491 .write()
18492 .unwrap_or_else(|poisoned| poisoned.into_inner())
18493 .insert(entry)
18494 .unwrap();
18495 let spec = crate::protocol::SpawnSpec {
18496 target: crate::protocol::SpawnTarget {
18497 node_id: shared.node_id.clone(),
18498 workspace_id: WorkspaceId::new("primary").unwrap(),
18499 worktree_id: None,
18500 },
18501 profile_id: defaults.profile_id.clone(),
18502 expected_profile_revision: defaults.revision.clone(),
18503 overrides: crate::protocol::SpawnOverrides {
18504 network_allowlist: Some(allowlist),
18505 ..crate::protocol::SpawnOverrides::default()
18506 },
18507 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18508 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18509 "station-knobs-unsupported-mapping",
18510 )
18511 .unwrap(),
18512 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18513 };
18514 let resolved = spec.resolve(&defaults).unwrap();
18515 assert_eq!(resolved.provider.as_str(), "claude");
18516 let failure = shared
18517 .resolve_environment_profile(&resolved)
18518 .expect_err("claude + codex_network_access must refuse");
18519 assert_eq!(
18520 failure.code,
18521 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18522 );
18523 }
18524
18525 #[test]
18529 fn resolve_codex_network_access_overlay_moderate_only() {
18530 let (shared, defaults) = station_knobs_fixture();
18531 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18532 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18533 id: allowlist.clone(),
18534 permits: Vec::new(),
18535 provider_native: Some(
18536 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18537 codex_network_access: Some(true),
18538 },
18539 ),
18540 };
18541 shared
18542 .network_allowlist_catalog
18543 .write()
18544 .unwrap_or_else(|poisoned| poisoned.into_inner())
18545 .insert(entry)
18546 .unwrap();
18547
18548 let overlay = shared
18549 .resolve_codex_network_access_overlay_args(
18550 &agent("codex"),
18551 SessionMode::Pty,
18552 ApprovalLevel::Moderate,
18553 Some(&allowlist),
18554 )
18555 .expect("Moderate Codex maps network_access");
18556 assert_eq!(
18557 overlay,
18558 vec![
18559 std::ffi::OsString::from("-c"),
18560 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18561 ]
18562 );
18563
18564 let acp = shared
18565 .resolve_codex_network_access_overlay_args(
18566 &agent("codex"),
18567 SessionMode::Acp,
18568 ApprovalLevel::Moderate,
18569 Some(&allowlist),
18570 )
18571 .unwrap();
18572 assert_eq!(acp, overlay);
18573
18574 let inline = shared
18575 .resolve_codex_network_access_overlay_args(
18576 &agent("codex"),
18577 SessionMode::Inline,
18578 ApprovalLevel::Moderate,
18579 Some(&allowlist),
18580 )
18581 .unwrap_err();
18582 assert_eq!(
18583 inline.code,
18584 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18585 );
18586
18587 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18588 let spec = crate::protocol::SpawnSpec {
18589 target: crate::protocol::SpawnTarget {
18590 node_id: shared.node_id.clone(),
18591 workspace_id: WorkspaceId::new("primary").unwrap(),
18592 worktree_id: None,
18593 },
18594 profile_id: defaults.profile_id.clone(),
18595 expected_profile_revision: defaults.revision.clone(),
18596 overrides: crate::protocol::SpawnOverrides {
18597 provider: crate::protocol::SpawnOverride::Set {
18598 value: agent("codex"),
18599 },
18600 approval_level: Some(level),
18601 network_allowlist: Some(allowlist.clone()),
18602 ..crate::protocol::SpawnOverrides::default()
18603 },
18604 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18605 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18606 "codex-net-refuse-{}",
18607 match level {
18608 ApprovalLevel::ReadOnly => "ro",
18609 ApprovalLevel::FullAuto => "fa",
18610 _ => "other",
18611 }
18612 ))
18613 .unwrap(),
18614 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18615 };
18616 let resolved = spec.resolve(&defaults).unwrap();
18617 let failure = shared
18618 .resolve_environment_profile(&resolved)
18619 .expect_err("RO/FullAuto must refuse");
18620 assert_eq!(
18621 failure.code,
18622 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18623 );
18624 }
18625 }
18626
18627 #[test]
18631 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18632 let (shared, _defaults) = station_knobs_fixture();
18633 let allowlist =
18634 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18635 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18636 id: allowlist.clone(),
18637 permits: vec![
18638 crate::network_allowlist_catalog::NetworkPermitSketch {
18639 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18640 peer: "github.com:443".to_owned(),
18641 },
18642 crate::network_allowlist_catalog::NetworkPermitSketch {
18643 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18644 peer: "*.npmjs.org".to_owned(),
18645 },
18646 ],
18647 provider_native: None,
18648 };
18649 shared
18650 .network_allowlist_catalog
18651 .write()
18652 .unwrap_or_else(|poisoned| poisoned.into_inner())
18653 .insert(entry)
18654 .unwrap();
18655
18656 let unique = std::time::SystemTime::now()
18657 .duration_since(std::time::UNIX_EPOCH)
18658 .unwrap()
18659 .as_nanos();
18660 let provider_home = std::env::temp_dir().join(format!(
18661 "gate4agent-claude-provider-home-{}-{unique}",
18662 std::process::id(),
18663 ));
18664 std::fs::create_dir_all(&provider_home).unwrap();
18665
18666 let overlay = shared
18667 .resolve_network_allowlist_overlay_args(
18668 &agent("claude"),
18669 SessionMode::Pty,
18670 ApprovalLevel::Moderate,
18671 Some(&allowlist),
18672 Some(provider_home.as_path()),
18673 )
18674 .expect("Claude PTY settings overlay on unix");
18675 let settings_path = provider_home.join(
18676 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18677 );
18678 assert_eq!(
18679 overlay,
18680 vec![
18681 std::ffi::OsString::from("--settings"),
18682 std::ffi::OsString::from(settings_path.as_os_str()),
18683 ]
18684 );
18685 let body = std::fs::read_to_string(&settings_path).unwrap();
18686 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18687 assert_eq!(value["sandbox"]["enabled"], true);
18688 assert_eq!(
18689 value["sandbox"]["network"]["allowedDomains"],
18690 serde_json::json!(["github.com", "*.npmjs.org"])
18691 );
18692 assert!(settings_path.starts_with(&provider_home));
18693
18694 let acp = shared
18695 .resolve_network_allowlist_overlay_args(
18696 &agent("claude"),
18697 SessionMode::Acp,
18698 ApprovalLevel::Moderate,
18699 Some(&allowlist),
18700 Some(provider_home.as_path()),
18701 )
18702 .expect_err("Claude ACP must refuse settings overlay");
18703 assert_eq!(
18704 acp.code,
18705 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18706 );
18707
18708 let no_home = shared
18709 .resolve_network_allowlist_overlay_args(
18710 &agent("claude"),
18711 SessionMode::Pty,
18712 ApprovalLevel::Moderate,
18713 Some(&allowlist),
18714 None,
18715 )
18716 .expect_err("Claude overlay requires ProviderHome");
18717 assert_eq!(
18718 no_home.code,
18719 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18720 );
18721
18722 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18723 shared
18724 .network_allowlist_catalog
18725 .write()
18726 .unwrap_or_else(|poisoned| poisoned.into_inner())
18727 .insert_id(bare.clone())
18728 .unwrap();
18729 let empty = shared
18730 .resolve_network_allowlist_overlay_args(
18731 &agent("claude"),
18732 SessionMode::Pty,
18733 ApprovalLevel::Moderate,
18734 Some(&bare),
18735 Some(provider_home.as_path()),
18736 )
18737 .unwrap();
18738 assert!(empty.is_empty());
18739
18740 let _ = std::fs::remove_dir_all(&provider_home);
18741 }
18742
18743 #[test]
18745 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18746 let (shared, _defaults) = station_knobs_fixture();
18747 let allowlist =
18748 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18749 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18750 id: allowlist.clone(),
18751 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18752 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18753 peer: "example.com:443".to_owned(),
18754 }],
18755 provider_native: None,
18756 };
18757 shared
18758 .network_allowlist_catalog
18759 .write()
18760 .unwrap_or_else(|poisoned| poisoned.into_inner())
18761 .insert(entry)
18762 .unwrap();
18763 for provider in ["kimi", "grok"] {
18764 let overlay = shared
18765 .resolve_network_allowlist_overlay_args(
18766 &agent(provider),
18767 SessionMode::Pty,
18768 ApprovalLevel::Moderate,
18769 Some(&allowlist),
18770 None,
18771 )
18772 .unwrap();
18773 assert!(
18774 overlay.is_empty(),
18775 "{provider} must not invent network argv from permits"
18776 );
18777 }
18778 }
18779
18780 #[test]
18782 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
18783 let unique = std::time::SystemTime::now()
18784 .duration_since(std::time::UNIX_EPOCH)
18785 .unwrap()
18786 .as_nanos();
18787 let path = std::env::temp_dir().join(format!(
18788 "gate4agent-node-allowlist-json-{}-{unique}.json",
18789 std::process::id(),
18790 ));
18791 std::fs::write(
18792 &path,
18793 r#"{
18794 "schema_version": 2,
18795 "entries": [
18796 {
18797 "id": "egress-default",
18798 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
18799 },
18800 { "id": "lab-egress" }
18801 ]
18802 }"#,
18803 )
18804 .unwrap();
18805 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18806 .expect("json v2 catalog must load");
18807 let _ = std::fs::remove_file(&path);
18808 assert_eq!(loaded.len(), 2);
18809 let entry = loaded
18810 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
18811 .unwrap();
18812 assert_eq!(entry.permits.len(), 1);
18813 let (shared, defaults) = station_knobs_fixture();
18814 {
18815 let mut catalog = shared
18816 .network_allowlist_catalog
18817 .write()
18818 .unwrap_or_else(|poisoned| poisoned.into_inner());
18819 *catalog = loaded;
18820 }
18821 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18822 let spec = crate::protocol::SpawnSpec {
18823 target: crate::protocol::SpawnTarget {
18824 node_id: shared.node_id.clone(),
18825 workspace_id: WorkspaceId::new("primary").unwrap(),
18826 worktree_id: None,
18827 },
18828 profile_id: defaults.profile_id.clone(),
18829 expected_profile_revision: defaults.revision.clone(),
18830 overrides: crate::protocol::SpawnOverrides {
18831 network_allowlist: Some(allowlist.clone()),
18832 ..crate::protocol::SpawnOverrides::default()
18833 },
18834 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18835 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18836 "station-knobs-catalog-json",
18837 )
18838 .unwrap(),
18839 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18840 };
18841 let resolved = spec.resolve(&defaults).unwrap();
18842 let receipt = shared
18843 .resolve_environment_profile(&resolved)
18844 .expect("permits-only entry ok for claude")
18845 .expect("env profile present");
18846 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18847 let inventory = shared
18848 .snapshot()
18849 .launch_inventory
18850 .expect("launch inventory present");
18851 let ids = inventory.network_allowlists.expect("ids surface");
18852 assert_eq!(
18853 ids,
18854 vec![
18855 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
18856 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
18857 ]
18858 );
18859 }
18860
18861
18862}
18863
18864
18865#[cfg(test)]
18866mod workspace_environment_collision_tests {
18867 use super::*;
18868 use crate::session_environment::{
18869 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
18870 };
18871 use gate4agent_catalog::EnvMutation;
18872 use gate4agent_runtime_native::{
18873 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18874 NativeLaunchProfileId,
18875 };
18876 use std::ffi::OsString;
18877 use std::sync::Arc;
18878
18879 struct EmptyEnvironmentResolver;
18880
18881 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18882 fn resolve_child_environment(
18883 &self,
18884 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18885 Ok(vec![EnvMutation {
18886 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18887 value: None,
18888 }])
18889 }
18890 }
18891
18892 fn agent(value: &str) -> AgentId {
18893 AgentId::new(value).unwrap()
18894 }
18895
18896 fn claude_config_dir_collision_fixture() -> (
18898 NodeShared,
18899 ResolvedEnvironmentProfileReceipt,
18900 ResolvedEnvironmentProfileReceipt,
18901 ) {
18902 let catalog = active_registry().unwrap();
18903 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18904 let control = runtime.native_launch_profile_control();
18905 let workspace = WorkspaceConfig::new(
18906 WorkspaceId::new("primary").unwrap(),
18907 std::env::current_dir().unwrap(),
18908 )
18909 .unwrap();
18910 let shared = NodeShared::new_with_incarnation(
18911 handle,
18912 "fixture-token".to_owned(),
18913 NodeId::new("node-env-collision-test").unwrap(),
18914 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18915 vec![workspace],
18916 vec![agent("claude")],
18917 ProviderRuntimeStatuses::default(),
18918 None,
18919 Vec::new(),
18920 Vec::new(),
18921 SpawnProfileRegistry::default(),
18922 Some(control),
18923 None,
18924 None,
18925 None,
18926 Vec::new(),
18927 Vec::new(),
18928 Vec::new(),
18929 Vec::new(),
18930 None,
18931 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18932 );
18933
18934 let mut receipts = Vec::new();
18935 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
18936 let profile_id =
18937 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
18938 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
18939 "claude-config-dir-{suffix}-r1"
18940 ))
18941 .unwrap();
18942 let materialization = NodeSessionMaterializationProfile::new(
18943 Vec::new(),
18944 vec![NodeSessionPathBinding::new(
18945 "CLAUDE_CONFIG_DIR",
18946 NodeSessionPathClass::ProviderHome,
18947 )
18948 .unwrap()],
18949 Vec::new(),
18950 )
18951 .unwrap();
18952 let node_profile = NodeEnvironmentProfile::new_with_materialization(
18953 profile_id.clone(),
18954 profile_revision.clone(),
18955 agent("claude"),
18956 [NativeLaunchProfile::new(
18957 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
18958 .unwrap(),
18959 agent("claude"),
18960 TransportKind::Pty,
18961 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18962 Arc::new(EmptyEnvironmentResolver),
18963 )
18964 .unwrap()],
18965 Some(materialization),
18966 )
18967 .unwrap();
18968 let (binding, native_profiles, materialization) = node_profile.into_parts();
18969 for native_profile in native_profiles {
18970 runtime.upsert_native_launch_profile(native_profile).unwrap();
18971 }
18972 shared
18973 .environment_profiles
18974 .write()
18975 .unwrap_or_else(|poisoned| poisoned.into_inner())
18976 .insert(binding.id.clone(), binding);
18977 shared
18978 .environment_materialization_profiles
18979 .write()
18980 .unwrap_or_else(|poisoned| poisoned.into_inner())
18981 .insert(profile_id.clone(), materialization.unwrap());
18982 receipts.push(ResolvedEnvironmentProfileReceipt {
18983 profile_id,
18984 profile_revision,
18985 network_allowlist: None,
18986 browser_profile_id: None,
18987 });
18988 }
18989 let profile_b = receipts.pop().unwrap();
18990 let profile_a = receipts.pop().unwrap();
18991 (shared, profile_a, profile_b)
18992 }
18993
18994 #[test]
18995 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
18996 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18997 let primary = WorkspaceId::new("primary").unwrap();
18998 let worktree = WorkspaceId::new("worktree-b").unwrap();
18999 let address = SessionAddress {
19000 workspace_id: primary.clone(),
19001 session: SessionKey {
19002 instance_id: AgentInstanceId(41),
19003 generation: SessionGeneration(1),
19004 },
19005 };
19006 shared.bind_session_with_policy(
19007 &address,
19008 ProviderRuntimePolicy::raw_pty(),
19009 Some(profile_a.clone()),
19010 );
19011
19012 let refused = shared
19013 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19014 .unwrap_err();
19015 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19016
19017 let refused_none = shared
19019 .ensure_workspace_environment_profile_compatible(&primary, None)
19020 .unwrap_err();
19021 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19022
19023 shared
19025 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19026 .unwrap();
19027
19028 shared
19030 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19031 .unwrap();
19032 }
19033
19034 #[tokio::test]
19035 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19036 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19037 let primary = WorkspaceId::new("primary").unwrap();
19038 let address = SessionAddress {
19039 workspace_id: primary.clone(),
19040 session: SessionKey {
19041 instance_id: AgentInstanceId(41),
19042 generation: SessionGeneration(1),
19043 },
19044 };
19045 shared.bind_session_with_policy(
19046 &address,
19047 ProviderRuntimePolicy::raw_pty(),
19048 Some(profile_a),
19049 );
19050
19051 let failure = shared
19052 .spawn_session_with_deadline(
19053 primary,
19054 agent("claude"),
19055 SessionMode::Pty,
19056 gate4agent_types::TerminalSize {
19057 rows: 24,
19058 columns: 80,
19059 },
19060 None,
19061 Some(profile_b),
19062 None,
19063 None,
19064 None,
19065 None,
19066 SpawnRecordPolicy::ProviderIdentityOnly,
19067 Some(Instant::now() + Duration::from_secs(30)),
19068 &[],
19069 None,
19070 ApprovalLevel::default(),
19071 None,
19072 None,
19073 )
19074 .await
19075 .unwrap_err();
19076 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19077 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19079 assert!(shared.handle.snapshot().sessions.is_empty());
19080 }
19081}
19082
19083#[cfg(test)]
19084mod spawn_characterization_tests {
19085 use super::*;
19086 use crate::session_environment::{
19087 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19088 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19089 NodeSessionPathClass,
19090 };
19091 use gate4agent_catalog::EnvMutation;
19092 use gate4agent_runtime_native::{
19093 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19094 NativeLaunchProfile, NativeLaunchProfileId,
19095 };
19096 use std::ffi::OsString;
19097 use std::sync::Arc;
19098
19099 struct EmptyEnvironmentResolver;
19100
19101 struct FixtureSecretResolver {
19102 deny: Arc<AtomicBool>,
19103 }
19104
19105 impl NodeSecretResolver for FixtureSecretResolver {
19106 fn resolve(
19107 &self,
19108 _reference: &NodeSecretReference,
19109 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19110 if self.deny.load(Ordering::Acquire) {
19111 Err(NodeSecretResolveError::Denied)
19112 } else {
19113 NodeSecretValue::text("fixture-secret")
19114 .map_err(|_| NodeSecretResolveError::Unavailable)
19115 }
19116 }
19117 }
19118
19119 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19120 fn resolve_child_environment(
19121 &self,
19122 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19123 Ok(vec![EnvMutation {
19124 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19125 value: None,
19126 }])
19127 }
19128 }
19129
19130 fn agent(value: &str) -> AgentId {
19131 AgentId::new(value).unwrap()
19132 }
19133
19134 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19135 let unique = std::time::SystemTime::now()
19136 .duration_since(std::time::UNIX_EPOCH)
19137 .unwrap()
19138 .as_nanos();
19139 std::env::temp_dir().join(format!(
19140 "gate4agent-node-{label}-{}-{unique}",
19141 std::process::id(),
19142 ))
19143 }
19144
19145 fn materialization_test_shared(
19146 deny_secret: bool,
19147 ) -> (
19148 NodeShared,
19149 NativeRuntime,
19150 ResolvedEnvironmentProfileReceipt,
19151 PathBuf,
19152 Arc<AtomicBool>,
19153 ) {
19154 let workspace = WorkspaceConfig::new(
19155 WorkspaceId::new("primary").unwrap(),
19156 std::env::current_dir().unwrap(),
19157 )
19158 .unwrap();
19159 materialization_test_shared_with_workspace(deny_secret, workspace)
19160 }
19161
19162 fn materialization_test_shared_with_workspace(
19163 deny_secret: bool,
19164 workspace: WorkspaceConfig,
19165 ) -> (
19166 NodeShared,
19167 NativeRuntime,
19168 ResolvedEnvironmentProfileReceipt,
19169 PathBuf,
19170 Arc<AtomicBool>,
19171 ) {
19172 let root = temporary_workspace_root(if deny_secret {
19173 "materialization-denied"
19174 } else {
19175 "materialization-ready"
19176 });
19177 std::fs::create_dir_all(&root).unwrap();
19178 let materialization_root = root.join("session-environments");
19179 let deny = Arc::new(AtomicBool::new(deny_secret));
19180 let resolver = Arc::new(FixtureSecretResolver {
19181 deny: Arc::clone(&deny),
19182 });
19183 let materializer = SessionEnvironmentMaterializer::new(
19184 materialization_root,
19185 resolver,
19186 )
19187 .unwrap();
19188 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19189 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19190 "materialized-claude-r1",
19191 )
19192 .unwrap();
19193 let materialization = NodeSessionMaterializationProfile::new(
19194 vec![NodeSessionEnvironmentMutation::SetSecret {
19195 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19196 reference: NodeSecretReference::new("fixture-token").unwrap(),
19197 }],
19198 vec![NodeSessionPathBinding::new(
19199 "GATE4AGENT_PROVIDER_HOME",
19200 NodeSessionPathClass::ProviderHome,
19201 )
19202 .unwrap()],
19203 vec![NodeSessionFile::secret(
19204 NodeSessionPathClass::Config,
19205 "auth/token",
19206 NodeSecretReference::new("fixture-token").unwrap(),
19207 )
19208 .unwrap()],
19209 )
19210 .unwrap();
19211 let catalog = active_registry().unwrap();
19212 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19213 let control = runtime.native_launch_profile_control();
19214 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19215 profile_id.clone(),
19216 profile_revision.clone(),
19217 agent("claude"),
19218 [NativeLaunchProfile::new(
19219 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19220 agent("claude"),
19221 TransportKind::Pty,
19222 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19223 Arc::new(EmptyEnvironmentResolver),
19224 )
19225 .unwrap()],
19226 Some(materialization),
19227 )
19228 .unwrap();
19229 let (binding, native_profiles, materialization) = node_profile.into_parts();
19230 for native_profile in native_profiles {
19231 runtime.upsert_native_launch_profile(native_profile).unwrap();
19232 }
19233 let shared = NodeShared::new_with_incarnation(
19234 handle,
19235 "fixture-token".to_owned(),
19236 NodeId::new("node-materialization-test").unwrap(),
19237 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19238 vec![workspace],
19239 vec![agent("claude")],
19240 ProviderRuntimeStatuses::default(),
19241 None,
19242 Vec::new(),
19243 Vec::new(),
19244 SpawnProfileRegistry::default(),
19245 Some(control),
19246 None,
19247 Some(materializer),
19248 None,
19249 Vec::new(),
19250 Vec::new(),
19251 Vec::new(),
19252 Vec::new(),
19253 None,
19254 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19255 );
19256 shared
19257 .environment_profiles
19258 .write()
19259 .unwrap_or_else(|poisoned| poisoned.into_inner())
19260 .insert(binding.id.clone(), binding);
19261 shared
19262 .environment_materialization_profiles
19263 .write()
19264 .unwrap_or_else(|poisoned| poisoned.into_inner())
19265 .insert(profile_id.clone(), materialization.unwrap());
19266 (
19267 shared,
19268 runtime,
19269 ResolvedEnvironmentProfileReceipt {
19270 profile_id,
19271 profile_revision,
19272 network_allowlist: None,
19273 browser_profile_id: None,
19274 },
19275 root,
19276 deny,
19277 )
19278 }
19279
19280
19281 async fn characterization_spawn(
19284 shared: &NodeShared,
19285 mode: SessionMode,
19286 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19287 required: &[ProviderRuntimeCapability],
19288 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19289 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19290 shared
19291 .spawn_session_with_deadline(
19292 WorkspaceId::new("primary").unwrap(),
19293 agent("claude"),
19294 mode,
19295 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19296 None,
19297 environment_profile,
19298 None,
19299 None,
19300 None,
19301 Some(ProviderRuntimePolicy::raw_pty()),
19302 SpawnRecordPolicy::ProviderIdentityOnly,
19303 Some(Instant::now() + Duration::from_secs(30)),
19304 required,
19305 harness_mcp,
19306 ApprovalLevel::default(),
19307 None,
19308 None,
19309 )
19310 .await
19311 }
19312
19313 fn assert_no_session_state(shared: &NodeShared) {
19314 assert!(shared.handle.snapshot().sessions.is_empty());
19315 assert!(shared
19316 .session_bindings
19317 .lock()
19318 .unwrap_or_else(|poisoned| poisoned.into_inner())
19319 .is_empty());
19320 assert!(shared
19321 .materializations
19322 .lock()
19323 .unwrap_or_else(|poisoned| poisoned.into_inner())
19324 .is_empty());
19325 }
19326
19327 #[tokio::test]
19328 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19329 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19330 let error = characterization_spawn(
19331 &shared,
19332 SessionMode::Pty,
19333 None,
19334 &[ProviderRuntimeCapability::SemanticReadiness],
19335 None,
19336 )
19337 .await
19338 .unwrap_err();
19339 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19340 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19341 assert_no_session_state(&shared);
19342 drop(shared);
19343 drop(runtime);
19344 std::fs::remove_dir_all(root).unwrap();
19345 }
19346
19347 #[tokio::test]
19348 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19349 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19350 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19351 .await
19352 .unwrap_err();
19353 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19354 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19355 assert_no_session_state(&shared);
19356 drop(shared);
19357 drop(runtime);
19358 std::fs::remove_dir_all(root).unwrap();
19359 }
19360
19361 #[test]
19362 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19363 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19364 let address = SessionAddress {
19365 workspace_id: WorkspaceId::new("primary").unwrap(),
19366 session: SessionKey {
19367 instance_id: AgentInstanceId(77),
19368 generation: SessionGeneration(1),
19369 },
19370 };
19371 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19372 let removed = shared.remove_binding(&address).expect("binding removed");
19373 assert!(removed.environment_profile.is_some());
19374 assert!(shared.remove_binding(&address).is_none());
19375 assert_no_session_state(&shared);
19376 drop(shared);
19377 drop(runtime);
19378 std::fs::remove_dir_all(root).unwrap();
19379 }
19380
19381 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19382 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19383 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19384 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19385 .expect("review current test executable as fixture helper");
19386 let registry = HarnessMcpProxyRegistry::new(helper);
19387 let spec = crate::protocol::SpawnSpec {
19388 target: crate::protocol::SpawnTarget {
19389 node_id: NodeId::new("node-characterization").unwrap(),
19390 workspace_id: WorkspaceId::new("primary").unwrap(),
19391 worktree_id: None,
19392 },
19393 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19394 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19395 overrides: crate::protocol::SpawnOverrides::default(),
19396 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19397 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19398 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19399 };
19400 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19401 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19402 let launch = HarnessMcpLaunchV1 {
19403 server_name: "fixture-harness".to_owned(),
19404 args: vec!["--session-proxy".to_owned()],
19405 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19406 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19407 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19408 trace: None,
19409 scrub_env: Vec::new(),
19410 };
19411 let expires = unix_time_ms().saturating_add(30_000);
19412 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19413 registry
19414 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19415 .unwrap()
19416 }
19417
19418 #[tokio::test]
19419 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19420 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19421 let prepared = characterization_prepared_harness("claude").await;
19422 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19423 .await
19424 .unwrap_err();
19425 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19427 let mismatched = characterization_prepared_harness("codex").await;
19428 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19429 .await
19430 .unwrap_err();
19431 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19432 assert_no_session_state(&shared);
19433 drop(shared);
19434 drop(runtime);
19435 std::fs::remove_dir_all(root).unwrap();
19436 }
19437
19438 #[tokio::test]
19439 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19440 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19441 shared.native_launch_profile_control = None;
19442 let prepared = characterization_prepared_harness("claude").await;
19443 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19444 .await
19445 .unwrap_err();
19446 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19447 assert_no_session_state(&shared);
19448 drop(shared);
19449 drop(runtime);
19450 std::fs::remove_dir_all(root).unwrap();
19451 }
19452}
19453
19454#[cfg(all(test, windows))]
19455mod tests {
19456 use super::*;
19457 use gate4agent_types::ProviderActivity;
19458 use crate::session_environment::{
19459 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19460 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19461 NodeSessionPathClass,
19462 };
19463 use gate4agent_catalog::EnvMutation;
19464 use gate4agent_runtime_native::{
19465 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19466 NativeLaunchProfile, NativeLaunchProfileId,
19467 };
19468 use std::ffi::OsString;
19469 use std::sync::Arc;
19470
19471 struct EmptyEnvironmentResolver;
19472
19473 struct FixtureSecretResolver {
19474 deny: Arc<AtomicBool>,
19475 }
19476
19477 impl NodeSecretResolver for FixtureSecretResolver {
19478 fn resolve(
19479 &self,
19480 _reference: &NodeSecretReference,
19481 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19482 if self.deny.load(Ordering::Acquire) {
19483 Err(NodeSecretResolveError::Denied)
19484 } else {
19485 NodeSecretValue::text("fixture-secret")
19486 .map_err(|_| NodeSecretResolveError::Unavailable)
19487 }
19488 }
19489 }
19490
19491 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19492 fn resolve_child_environment(
19493 &self,
19494 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19495 Ok(vec![EnvMutation {
19496 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19497 value: None,
19498 }])
19499 }
19500 }
19501
19502 fn agent(value: &str) -> AgentId {
19503 AgentId::new(value).unwrap()
19504 }
19505
19506 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19507 let catalog = active_registry().unwrap();
19508 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19509 let workspace = WorkspaceConfig::new(
19510 WorkspaceId::new("primary").unwrap(),
19511 std::env::current_dir().unwrap(),
19512 )
19513 .unwrap()
19514 .with_worktree_service_mode(mode);
19515 NodeShared::new(
19516 handle,
19517 "fixture-token".to_owned(),
19518 NodeId::new("node-terminal-test").unwrap(),
19519 vec![workspace],
19520 vec![agent("claude")],
19521 )
19522 }
19523
19524 fn terminal_test_shared() -> NodeShared {
19525 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19526 }
19527
19528 #[cfg(feature = "fixture")]
19529 #[test]
19530 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19531 let probe = SpawnManagedWorktreeV2FailureProbe::default();
19532 assert_eq!(probe.latest_code(), None);
19533
19534 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19535 assert_eq!(
19536 probe.latest_code(),
19537 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19538 );
19539
19540 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19541 assert_eq!(
19542 probe.latest_code(),
19543 Some(NodeFailureCode::SpawnDeadlineExceeded),
19544 );
19545
19546 probe.record(None);
19547 assert_eq!(probe.latest_code(), None);
19548 }
19549
19550 #[test]
19551 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19552 let source = temporary_workspace_root("launch-inventory-source");
19553 let allocation = temporary_workspace_root("launch-inventory-allocation");
19554 std::fs::create_dir_all(&source).unwrap();
19555 std::fs::create_dir_all(&allocation).unwrap();
19556 let workspace_id = WorkspaceId::new("primary").unwrap();
19557 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19558 .unwrap()
19559 .with_worktree_service_mode(WorktreeServiceMode::Managed);
19560 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19561 workspace = workspace
19562 .with_managed_worktree_profile(
19563 ManagedWorktreeProfile::new(
19564 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19565 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19566 &allocation,
19567 "gate4agent",
19568 "HEAD",
19569 ManagedWorktreeRetention::RemoveWhenReleased,
19570 )
19571 .unwrap(),
19572 )
19573 .unwrap();
19574 }
19575 let overflow = workspace.clone().with_managed_worktree_profile(
19576 ManagedWorktreeProfile::new(
19577 WorktreeProfileId::new("overflow").unwrap(),
19578 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19579 &allocation,
19580 "gate4agent",
19581 "HEAD",
19582 ManagedWorktreeRetention::Retain,
19583 )
19584 .unwrap(),
19585 );
19586 assert!(matches!(
19587 overflow,
19588 Err(NodeServerError::ManagedWorktreeProfileCapacity {
19589 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19590 ..
19591 }),
19592 ));
19593
19594 let catalog = active_registry().unwrap();
19595 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19596 let shared = NodeShared::new(
19597 handle,
19598 "fixture-token".to_owned(),
19599 NodeId::new("node-launch-inventory").unwrap(),
19600 vec![workspace],
19601 vec![agent("claude")],
19602 );
19603 let snapshot = shared.snapshot();
19604 let launch = snapshot.launch_inventory.as_ref().unwrap();
19605 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19606 assert!(launch.bundles.as_ref().unwrap().is_empty());
19607 let profiles = snapshot.workspaces[0]
19608 .managed_worktree_profiles
19609 .as_ref()
19610 .unwrap();
19611 assert_eq!(
19612 snapshot.workspaces[0].worktree_service_mode,
19613 Some(WorktreeServiceMode::Managed),
19614 );
19615 assert_eq!(
19616 profiles.profiles.len(),
19617 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19618 );
19619 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19620 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19621 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19622 }
19623 let legacy = snapshot_for_wire(
19624 &shared,
19625 true,
19626 true,
19627 false,
19628 false,
19629 true,
19630 false,
19631 true,
19632 false,
19633 true,
19634 );
19635 assert!(legacy.launch_inventory.is_none());
19636 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19637
19638 drop(shared);
19639 std::fs::remove_dir_all(source).unwrap();
19640 std::fs::remove_dir_all(allocation).unwrap();
19641 }
19642
19643 #[test]
19644 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19645 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19646 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19647 }));
19648 let source = gate4agent_types::ProviderSource {
19649 family: AdapterFamily::PtySemantic,
19650 binding: AdapterBinding::new(
19651 gate4agent_types::AdapterId::new("claude").unwrap(),
19652 "fixture/v1",
19653 gate4agent_types::AdapterVerification::SyntheticFixture,
19654 )
19655 .unwrap(),
19656 };
19657 let provider = ProviderSnapshot {
19658 sequence: 17,
19659 session: Some(ProviderSessionIdentity {
19660 key: ProviderSessionKey::SessionId,
19661 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19662 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
19663 }),
19664 model: Some("PRIVATE_MODEL".to_owned()),
19665 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
19666 completed_turns: 3,
19667 usage: gate4agent_types::TokenUsage {
19668 input_tokens: 10,
19669 output_tokens: 20,
19670 cache_read_tokens: 30,
19671 cache_write_tokens: 40,
19672 reasoning_tokens: 50,
19673 context_window: Some(200_000),
19674 },
19675 lead_activity: ProviderActivity::WaitingForInput,
19676 activity: ProviderActivity::WaitingForInput,
19677 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
19678 active_tools: (0..9)
19679 .map(|index| gate4agent_types::ActiveProviderTool {
19680 id: format!("private-tool-id-{index}"),
19681 name: if index == 0 {
19682 format!(" Read\u{0000}{}", "x".repeat(80))
19683 } else {
19684 format!("Tool-{index}")
19685 },
19686 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
19687 })
19688 .collect(),
19689 interactions: vec![gate4agent_types::ProviderInteraction {
19690 id: gate4agent_types::ProviderInteractionId(17),
19691 source: source.clone(),
19692 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
19693 interaction_kind: ProviderInteractionKind::Approval,
19694 tool_name: " Approve\u{0007}Tool ".to_owned(),
19695 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
19696 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
19697 resume_lead_activity: Some(ProviderActivity::Working),
19698 status: ProviderInteractionStatus::Pending,
19699 }],
19700 subagents: vec![gate4agent_types::ProviderSubagent {
19701 source: source.clone(),
19702 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
19703 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
19704 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
19705 }],
19706 sources: vec![gate4agent_types::ProviderSourceCursor {
19707 source,
19708 sequence: 17,
19709 gap_count: 2,
19710 stale: true,
19711 }],
19712 last_event: Some(ProviderEvent::Error {
19713 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
19714 }),
19715 gap_count: 2,
19716 stale: true,
19717 };
19718 let address = terminal_address(3);
19719 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
19720 assert_eq!(entry.address, address);
19721 assert_eq!(entry.progress.provider_sequence, 17);
19722 assert_eq!(entry.progress.active_tool_count, 9);
19723 assert_eq!(
19724 entry.progress.active_tool_labels.len(),
19725 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
19726 );
19727 assert_eq!(entry.progress.subagent_count, 1);
19728 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
19729 assert!(entry.progress.truncated);
19730 let encoded = serde_json::to_string(&entry).unwrap();
19731 for forbidden in [
19732 "PRIVATE_PROVIDER_SESSION",
19733 "PRIVATE_PROVIDER_PATH",
19734 "PRIVATE_MODEL",
19735 "PRIVATE_TOOL_INVENTORY",
19736 "PRIVATE_CURRENT_PROMPT",
19737 "PRIVATE_TOOL_INPUT_JSON",
19738 "PRIVATE_PROVIDER_REQUEST",
19739 "PRIVATE_INTERACTION_PROMPT",
19740 "PRIVATE_INTERACTION_AGENT",
19741 "PRIVATE_SUBAGENT_ID",
19742 "PRIVATE_SUBAGENT_TYPE",
19743 "PRIVATE_SUBAGENT_DESCRIPTION",
19744 "PRIVATE_PROVIDER_ERROR",
19745 "input_json",
19746 "current_prompt",
19747 "session_id",
19748 ] {
19749 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
19750 }
19751
19752 let snapshot = NodeSnapshot {
19753 node_id: NodeId::new("node-progress-test").unwrap(),
19754 enabled_providers: vec![agent("claude")],
19755 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19756 workspaces: Vec::new(),
19757 session_records: Vec::new(),
19758 managed_worktrees: Vec::new(),
19759 launch_inventory: None,
19760 agent_progress: vec![entry],
19761 };
19762 let mut legacy_reply = ResponseEnvelope {
19763 request_id: 1,
19764 result: Ok(NodeResponse::Snapshot {
19765 event_sequence: 0,
19766 controller: None,
19767 snapshot: snapshot.clone(),
19768 }),
19769 };
19770 project_response_without_agent_progress(&mut legacy_reply);
19771 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
19772 panic!("agent progress projection changed response kind");
19773 };
19774 assert!(legacy.agent_progress.is_empty());
19775 assert_eq!(snapshot.agent_progress.len(), 1);
19776 }
19777
19778 fn terminal_address(generation: u64) -> SessionAddress {
19779 SessionAddress {
19780 workspace_id: WorkspaceId::new("primary").unwrap(),
19781 session: SessionKey {
19782 instance_id: AgentInstanceId(41),
19783 generation: SessionGeneration(generation),
19784 },
19785 }
19786 }
19787
19788 fn spawn_spec_fixture() -> SpawnSpec {
19789 SpawnSpec {
19790 target: crate::protocol::SpawnTarget {
19791 node_id: NodeId::new("node-terminal-test").unwrap(),
19792 workspace_id: WorkspaceId::new("primary").unwrap(),
19793 worktree_id: None,
19794 },
19795 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19796 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
19797 .unwrap(),
19798 overrides: crate::protocol::SpawnOverrides::default(),
19799 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19800 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
19801 required_capabilities: SpawnRequiredCapabilities::default(),
19802 }
19803 }
19804
19805 fn environment_profile_test_shared(
19806 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
19807 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
19808 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19809 "local-claude-r1",
19810 )
19811 .unwrap();
19812 let spawn_profiles = SpawnProfileRegistry::new([
19813 crate::protocol::SpawnProfileDefaults {
19814 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19815 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
19816 provider: agent("claude"),
19817 mode: SessionMode::Pty,
19818 terminal_size: gate4agent_types::TerminalSize {
19819 rows: 24,
19820 columns: 80,
19821 },
19822 prompt: None,
19823 bundle_id: None,
19824 context_id: None,
19825 environment_profile_id: Some(profile_id.clone()),
19826 },
19827 ])
19828 .unwrap();
19829 let catalog = active_registry().unwrap();
19830 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19831 let control = runtime.native_launch_profile_control();
19832 let node_profile = NodeEnvironmentProfile::new(
19833 profile_id.clone(),
19834 profile_revision.clone(),
19835 agent("claude"),
19836 [NativeLaunchProfile::new(
19837 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
19838 agent("claude"),
19839 TransportKind::Pty,
19840 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19841 Arc::new(EmptyEnvironmentResolver),
19842 )
19843 .unwrap()],
19844 )
19845 .unwrap();
19846 let (binding, native_profiles, _materialization) = node_profile.into_parts();
19847 for native_profile in native_profiles {
19848 runtime.upsert_native_launch_profile(native_profile).unwrap();
19849 }
19850 let workspace = WorkspaceConfig::new(
19851 WorkspaceId::new("primary").unwrap(),
19852 std::env::current_dir().unwrap(),
19853 )
19854 .unwrap();
19855 let shared = NodeShared::new_with_incarnation(
19856 handle,
19857 "fixture-token".to_owned(),
19858 NodeId::new("node-terminal-test").unwrap(),
19859 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19860 vec![workspace],
19861 vec![agent("claude")],
19862 ProviderRuntimeStatuses::default(),
19863 None,
19864 Vec::new(),
19865 Vec::new(),
19866 spawn_profiles,
19867 Some(control),
19868 None,
19869 None,
19870 None,
19871 Vec::new(),
19872 Vec::new(),
19873 Vec::new(),
19874 Vec::new(),
19875 None,
19876 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19877 );
19878 shared
19879 .environment_profiles
19880 .write()
19881 .unwrap_or_else(|poisoned| poisoned.into_inner())
19882 .insert(binding.id.clone(), binding);
19883 (
19884 shared,
19885 runtime,
19886 ResolvedEnvironmentProfileReceipt {
19887 profile_id,
19888 profile_revision,
19889 network_allowlist: None,
19890 browser_profile_id: None,
19891 },
19892 )
19893 }
19894
19895 fn materialization_test_shared(
19896 deny_secret: bool,
19897 ) -> (
19898 NodeShared,
19899 NativeRuntime,
19900 ResolvedEnvironmentProfileReceipt,
19901 PathBuf,
19902 Arc<AtomicBool>,
19903 ) {
19904 let workspace = WorkspaceConfig::new(
19905 WorkspaceId::new("primary").unwrap(),
19906 std::env::current_dir().unwrap(),
19907 )
19908 .unwrap();
19909 materialization_test_shared_with_workspace(deny_secret, workspace)
19910 }
19911
19912 fn materialization_test_shared_with_workspace(
19913 deny_secret: bool,
19914 workspace: WorkspaceConfig,
19915 ) -> (
19916 NodeShared,
19917 NativeRuntime,
19918 ResolvedEnvironmentProfileReceipt,
19919 PathBuf,
19920 Arc<AtomicBool>,
19921 ) {
19922 let root = temporary_workspace_root(if deny_secret {
19923 "materialization-denied"
19924 } else {
19925 "materialization-ready"
19926 });
19927 std::fs::create_dir_all(&root).unwrap();
19928 let materialization_root = root.join("session-environments");
19929 let deny = Arc::new(AtomicBool::new(deny_secret));
19930 let resolver = Arc::new(FixtureSecretResolver {
19931 deny: Arc::clone(&deny),
19932 });
19933 let materializer = SessionEnvironmentMaterializer::new(
19934 materialization_root,
19935 resolver,
19936 )
19937 .unwrap();
19938 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19939 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19940 "materialized-claude-r1",
19941 )
19942 .unwrap();
19943 let materialization = NodeSessionMaterializationProfile::new(
19944 vec![NodeSessionEnvironmentMutation::SetSecret {
19945 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19946 reference: NodeSecretReference::new("fixture-token").unwrap(),
19947 }],
19948 vec![NodeSessionPathBinding::new(
19949 "GATE4AGENT_PROVIDER_HOME",
19950 NodeSessionPathClass::ProviderHome,
19951 )
19952 .unwrap()],
19953 vec![NodeSessionFile::secret(
19954 NodeSessionPathClass::Config,
19955 "auth/token",
19956 NodeSecretReference::new("fixture-token").unwrap(),
19957 )
19958 .unwrap()],
19959 )
19960 .unwrap();
19961 let catalog = active_registry().unwrap();
19962 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19963 let control = runtime.native_launch_profile_control();
19964 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19965 profile_id.clone(),
19966 profile_revision.clone(),
19967 agent("claude"),
19968 [NativeLaunchProfile::new(
19969 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19970 agent("claude"),
19971 TransportKind::Pty,
19972 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19973 Arc::new(EmptyEnvironmentResolver),
19974 )
19975 .unwrap()],
19976 Some(materialization),
19977 )
19978 .unwrap();
19979 let (binding, native_profiles, materialization) = node_profile.into_parts();
19980 for native_profile in native_profiles {
19981 runtime.upsert_native_launch_profile(native_profile).unwrap();
19982 }
19983 let shared = NodeShared::new_with_incarnation(
19984 handle,
19985 "fixture-token".to_owned(),
19986 NodeId::new("node-materialization-test").unwrap(),
19987 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19988 vec![workspace],
19989 vec![agent("claude")],
19990 ProviderRuntimeStatuses::default(),
19991 None,
19992 Vec::new(),
19993 Vec::new(),
19994 SpawnProfileRegistry::default(),
19995 Some(control),
19996 None,
19997 Some(materializer),
19998 None,
19999 Vec::new(),
20000 Vec::new(),
20001 Vec::new(),
20002 Vec::new(),
20003 None,
20004 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20005 );
20006 shared
20007 .environment_profiles
20008 .write()
20009 .unwrap_or_else(|poisoned| poisoned.into_inner())
20010 .insert(binding.id.clone(), binding);
20011 shared
20012 .environment_materialization_profiles
20013 .write()
20014 .unwrap_or_else(|poisoned| poisoned.into_inner())
20015 .insert(profile_id.clone(), materialization.unwrap());
20016 (
20017 shared,
20018 runtime,
20019 ResolvedEnvironmentProfileReceipt {
20020 profile_id,
20021 profile_revision,
20022 network_allowlist: None,
20023 browser_profile_id: None,
20024 },
20025 root,
20026 deny,
20027 )
20028 }
20029
20030 #[cfg(feature = "fixture")]
20031 #[derive(Clone, Copy)]
20032 enum CodexHomeProfile {
20033 Missing,
20034 WrongClass,
20035 Exact,
20036 }
20037
20038 #[cfg(feature = "fixture")]
20039 fn codex_bundle_test_shared(
20040 home_profile: CodexHomeProfile,
20041 ) -> (
20042 NodeShared,
20043 NativeRuntime,
20044 ResolvedEnvironmentProfileReceipt,
20045 NodeBundle,
20046 PathBuf,
20047 ) {
20048 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20049 const CLAUDE_MANIFEST: &[u8] =
20050 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20051 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20052 const DIGEST: &str =
20053 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20054
20055 let (shared, mut runtime, _claude_receipt, root, _deny) =
20056 materialization_test_shared(false);
20057 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20058 CodexHomeProfile::Missing => "codex-home-missing",
20059 CodexHomeProfile::WrongClass => "codex-home-wrong",
20060 CodexHomeProfile::Exact => "codex-home-exact",
20061 })
20062 .unwrap();
20063 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20064 "codex-home-r1",
20065 )
20066 .unwrap();
20067 let materialization = match home_profile {
20068 CodexHomeProfile::Missing => None,
20069 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20070 Some(NodeSessionMaterializationProfile::new(
20071 Vec::new(),
20072 vec![NodeSessionPathBinding::new(
20073 "CODEX_HOME",
20074 if matches!(home_profile, CodexHomeProfile::Exact) {
20075 NodeSessionPathClass::ProviderHome
20076 } else {
20077 NodeSessionPathClass::Config
20078 },
20079 )
20080 .unwrap()],
20081 Vec::new(),
20082 )
20083 .unwrap())
20084 }
20085 };
20086 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20087 profile_id.clone(),
20088 profile_revision.clone(),
20089 agent("codex"),
20090 [NativeLaunchProfile::new(
20091 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20092 agent("codex"),
20093 TransportKind::Pty,
20094 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20095 Arc::new(EmptyEnvironmentResolver),
20096 )
20097 .unwrap()],
20098 materialization,
20099 )
20100 .unwrap();
20101 let (binding, native_profiles, materialization) = node_profile.into_parts();
20102 for native_profile in native_profiles {
20103 runtime.upsert_native_launch_profile(native_profile).unwrap();
20104 }
20105 shared
20106 .environment_profiles
20107 .write()
20108 .unwrap_or_else(|poisoned| poisoned.into_inner())
20109 .insert(binding.id.clone(), binding);
20110 if let Some(materialization) = materialization {
20111 shared
20112 .environment_materialization_profiles
20113 .write()
20114 .unwrap_or_else(|poisoned| poisoned.into_inner())
20115 .insert(profile_id.clone(), materialization);
20116 }
20117
20118 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20119 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20120 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20121 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20122 std::fs::write(
20123 bundle_source.join(".claude-plugin/plugin.json"),
20124 CLAUDE_MANIFEST,
20125 )
20126 .unwrap();
20127 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20128 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20129 let bundle = NodeBundle::new(
20130 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20131 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20132 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20133 &bundle_source,
20134 )
20135 .unwrap();
20136 *shared
20137 .bundle_catalog
20138 .write()
20139 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20140 BundleCatalog::new([bundle.clone()]).unwrap();
20141 (
20142 shared,
20143 runtime,
20144 ResolvedEnvironmentProfileReceipt {
20145 profile_id,
20146 profile_revision,
20147 network_allowlist: None,
20148 browser_profile_id: None,
20149 },
20150 bundle,
20151 root,
20152 )
20153 }
20154
20155 #[cfg(feature = "fixture")]
20156 #[test]
20157 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20158 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20159 let (shared, runtime, receipt, bundle, root) =
20160 codex_bundle_test_shared(home_profile);
20161 let error = shared
20162 .bundle_layout(
20163 &agent("codex"),
20164 SessionMode::Pty,
20165 Some(&receipt),
20166 &bundle,
20167 )
20168 .unwrap_err();
20169 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20170 assert!(shared
20171 .materializations
20172 .lock()
20173 .unwrap_or_else(|poisoned| poisoned.into_inner())
20174 .is_empty());
20175 let materialization_root = root.join("session-environments");
20176 assert!(!std::fs::read_dir(&materialization_root)
20177 .unwrap()
20178 .filter_map(Result::ok)
20179 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20180 drop(shared);
20181 drop(runtime);
20182 let _ = std::fs::remove_dir_all(root);
20183 }
20184 }
20185
20186 #[cfg(feature = "fixture")]
20187 #[test]
20188 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20189 let (shared, runtime, receipt, bundle, root) =
20190 codex_bundle_test_shared(CodexHomeProfile::Exact);
20191 let address = SessionAddress {
20192 workspace_id: WorkspaceId::new("primary").unwrap(),
20193 session: SessionKey {
20194 instance_id: AgentInstanceId(310),
20195 generation: SessionGeneration(1),
20196 },
20197 };
20198 let (overlay, guard) = shared
20199 .prepare_session_materialization(
20200 &address,
20201 &agent("codex"),
20202 SessionMode::Pty,
20203 Some(&receipt),
20204 Some(&bundle.receipt()),
20205 None,
20206 None,
20207 )
20208 .unwrap()
20209 .unwrap();
20210 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20211 let ownership = shared
20212 .materializations
20213 .lock()
20214 .unwrap_or_else(|poisoned| poisoned.into_inner())
20215 .get(guard.id().unwrap())
20216 .unwrap()
20217 .clone();
20218 let (profile, _, _) = shared
20219 .materialization_profile(
20220 &agent("codex"),
20221 SessionMode::Pty,
20222 Some(&receipt),
20223 Some(&bundle.receipt()),
20224 None,
20225 )
20226 .unwrap()
20227 .unwrap();
20228 let environment = shared
20229 .session_environment_materializer
20230 .as_ref()
20231 .unwrap()
20232 .resolve_environment(&ownership, &profile)
20233 .unwrap();
20234 assert!(environment.iter().any(|mutation| {
20235 mutation.key == OsString::from("CODEX_HOME")
20236 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20237 }));
20238 assert_eq!(
20239 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20240 .unwrap(),
20241 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20242 );
20243 assert!(std::fs::read_dir(ownership.bundle_root())
20244 .unwrap()
20245 .next()
20246 .is_none());
20247 drop(guard);
20248 drop(shared);
20249 drop(runtime);
20250 let _ = std::fs::remove_dir_all(root);
20251 }
20252
20253 #[cfg(feature = "fixture")]
20254 #[test]
20255 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20256 let (shared, runtime, receipt, bundle, root) =
20257 codex_bundle_test_shared(CodexHomeProfile::Exact);
20258 let bundle_receipt = bundle.receipt();
20259 let address = SessionAddress {
20260 workspace_id: WorkspaceId::new("primary").unwrap(),
20261 session: SessionKey {
20262 instance_id: AgentInstanceId(311),
20263 generation: SessionGeneration(1),
20264 },
20265 };
20266 let (_overlay, guard) = shared
20267 .prepare_session_materialization(
20268 &address,
20269 &agent("codex"),
20270 SessionMode::Pty,
20271 Some(&receipt),
20272 Some(&bundle_receipt),
20273 None,
20274 None,
20275 )
20276 .unwrap()
20277 .unwrap();
20278 let materialization_id = guard.id().unwrap().clone();
20279 let skill_path = shared
20280 .materializations
20281 .lock()
20282 .unwrap_or_else(|poisoned| poisoned.into_inner())
20283 .get(&materialization_id)
20284 .unwrap()
20285 .provider_home()
20286 .join("skills/review-code/SKILL.md");
20287 let record_id = shared
20288 .bind_spawn_session_with_materialization(
20289 &address,
20290 agent("codex"),
20291 SessionMode::Pty,
20292 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20293 SpawnRecordPolicy::ProviderIdentityOnly,
20294 Some(receipt.clone()),
20295 Some(bundle_receipt.clone()),
20296 None,
20297 Some(materialization_id.clone()),
20298 )
20299 .unwrap()
20300 .unwrap();
20301 guard.retain();
20302 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20303
20304 let error = match shared.resolve_record_materialization(
20305 &record_id,
20306 &agent("codex"),
20307 SessionMode::Pty,
20308 Some(&receipt),
20309 Some(&bundle_receipt),
20310 None,
20311 ) {
20312 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20313 Err(error) => error,
20314 };
20315 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20316 assert_eq!(
20317 shared
20318 .materializations
20319 .lock()
20320 .unwrap_or_else(|poisoned| poisoned.into_inner())
20321 .get(&materialization_id)
20322 .unwrap()
20323 .state(),
20324 MaterializationState::RecoveryRequired,
20325 );
20326 drop(shared);
20327 drop(runtime);
20328 let _ = std::fs::remove_dir_all(root);
20329 }
20330
20331 fn bind_record_owned_materialization_fixture(
20332 shared: &NodeShared,
20333 receipt: &ResolvedEnvironmentProfileReceipt,
20334 instance_id: u64,
20335 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20336 let address = SessionAddress {
20337 workspace_id: WorkspaceId::new("primary").unwrap(),
20338 session: SessionKey {
20339 instance_id: AgentInstanceId(instance_id),
20340 generation: SessionGeneration(1),
20341 },
20342 };
20343 let (overlay, materialization_guard) = shared
20344 .prepare_session_materialization(
20345 &address,
20346 &agent("claude"),
20347 SessionMode::Pty,
20348 Some(receipt),
20349 None,
20350 None,
20351 None,
20352 )
20353 .unwrap()
20354 .unwrap();
20355 let materialization_id = materialization_guard.id().unwrap().clone();
20356 let materialization_root = shared
20357 .materializations
20358 .lock()
20359 .unwrap_or_else(|poisoned| poisoned.into_inner())
20360 .get(&materialization_id)
20361 .unwrap()
20362 .root()
20363 .to_path_buf();
20364 let selection = shared
20365 .select_environment_profile(
20366 address.session.instance_id,
20367 &agent("claude"),
20368 SessionMode::Pty,
20369 Some(receipt),
20370 )
20371 .unwrap()
20372 .unwrap();
20373 assert!(shared
20374 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20375 .unwrap()
20376 .is_none());
20377 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20378 let record_id = shared
20379 .bind_spawn_session_with_materialization(
20380 &address,
20381 agent("claude"),
20382 SessionMode::Pty,
20383 policy,
20384 SpawnRecordPolicy::ProviderIdentityOnly,
20385 Some(receipt.clone()),
20386 None,
20387 None,
20388 Some(materialization_id.clone()),
20389 )
20390 .unwrap()
20391 .unwrap();
20392 selection.retain();
20393 materialization_guard.retain();
20394 (address, record_id, materialization_id, materialization_root)
20395 }
20396
20397 #[test]
20398 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20399 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20400 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20401 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20402 }));
20403 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20404 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20405 let mut spec = spawn_spec_fixture();
20406 spec.expected_profile_revision =
20407 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20408 assert!(matches!(
20409 &spec.overrides.environment_profile_id,
20410 crate::protocol::SpawnOverride::Inherit,
20411 ));
20412 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20413 .requires_child_environment_profile_capability());
20414 assert!(request_requires_child_environment_profile(
20415 &shared,
20416 &NodeRequest::SpawnSpec { spec: spec.clone() },
20417 ));
20418
20419 let address = terminal_address(1);
20420 shared.bind_session_with_policy(
20421 &address,
20422 ProviderRuntimePolicy::raw_pty(),
20423 Some(environment_profile.clone()),
20424 );
20425 assert!(request_requires_child_environment_profile(
20426 &shared,
20427 &NodeRequest::Input {
20428 session: address.clone(),
20429 text: "x".to_owned(),
20430 },
20431 ));
20432
20433 let mut record = record("claude", "profile-record");
20434 record.active_session = Some(address.clone());
20435 record.environment_profile = Some(environment_profile.clone());
20436 let record_id = record.record_id.clone();
20437 shared.insert_record(record.clone()).unwrap();
20438 assert!(request_requires_child_environment_profile(
20439 &shared,
20440 &NodeRequest::ResumeSessionRecord {
20441 record_id,
20442 terminal_size: gate4agent_types::TerminalSize {
20443 rows: 24,
20444 columns: 80,
20445 },
20446 initial_prompt: None,
20447 },
20448 ));
20449
20450 let snapshot = snapshot_for_wire(
20451 &shared, false, true, true, true, false, false, false, false, true,
20452 );
20453 assert_eq!(snapshot.session_records.len(), 1);
20454 assert!(snapshot.session_records[0].environment_profile.is_none());
20455 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20456 sequence: 1,
20457 event: NodeEvent::SessionRecordUpserted { record },
20458 });
20459 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20460 panic!("record projection changed the event kind");
20461 };
20462 assert!(record.environment_profile.is_none());
20463
20464 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20465 let mut reply = ResponseEnvelope {
20466 request_id: 1,
20467 result: Ok(NodeResponse::SpawnSpecAccepted {
20468 receipt: resolved.receipt_with_environment(
20469 shared.incarnation_id,
20470 address,
20471 Some(environment_profile),
20472 ),
20473 }),
20474 };
20475 project_response_without_child_environment_profile(&mut reply);
20476 assert_eq!(
20477 reply.result.unwrap_err().code,
20478 NodeFailureCode::UnsupportedCapability,
20479 );
20480 }
20481
20482 #[test]
20483 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20484 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20485 let snapshot = shared.snapshot();
20486 let profiles = snapshot
20487 .launch_inventory
20488 .as_ref()
20489 .and_then(|inventory| inventory.spawn_profiles.as_ref())
20490 .unwrap();
20491 assert_eq!(profiles.len(), 1);
20492 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20493 assert!(snapshot.requires_child_environment_profile_capability());
20494
20495 let mut legacy = snapshot.clone();
20496 clear_snapshot_child_environment_profiles(&mut legacy);
20497 assert!(legacy
20498 .launch_inventory
20499 .as_ref()
20500 .unwrap()
20501 .spawn_profiles
20502 .as_ref()
20503 .unwrap()[0]
20504 .environment_profile
20505 .is_none());
20506 assert!(!legacy.requires_child_environment_profile_capability());
20507
20508 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20509 let bindings = shared
20510 .environment_profiles
20511 .read()
20512 .unwrap_or_else(|poisoned| poisoned.into_inner())
20513 .clone();
20514 let mut provider_mismatch = bindings.clone();
20515 provider_mismatch
20516 .values_mut()
20517 .next()
20518 .unwrap()
20519 .provider = agent("codex");
20520 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20521 let mut mode_mismatch = profile;
20522 mode_mismatch.mode = SessionMode::Inline;
20523 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20524
20525 shared
20526 .environment_profiles
20527 .write()
20528 .unwrap_or_else(|poisoned| poisoned.into_inner())
20529 .clear();
20530 assert!(shared
20531 .snapshot()
20532 .launch_inventory
20533 .unwrap()
20534 .spawn_profiles
20535 .unwrap()
20536 .is_empty());
20537 }
20538
20539 #[test]
20540 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20541 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20542 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20543 }));
20544 let shared = terminal_test_shared();
20545 let bundle = ResolvedBundleReceipt {
20546 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20547 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20548 digest: crate::protocol::SpawnBundleDigest::new(format!(
20549 "sha256:{}",
20550 "0".repeat(64),
20551 ))
20552 .unwrap(),
20553 };
20554 let mut bundled = record("claude", "bundle-record");
20555 bundled.bundle = Some(bundle.clone());
20556 let record_id = bundled.record_id.clone();
20557 shared.insert_record(bundled.clone()).unwrap();
20558 assert!(request_requires_session_bundle(
20559 &shared,
20560 &NodeRequest::ForgetSessionRecord {
20561 record_id: record_id.clone(),
20562 },
20563 ));
20564
20565 let snapshot = snapshot_for_wire(
20566 &shared, false, true, true, true, true, false, false, false, true,
20567 );
20568 assert!(snapshot.session_records[0].bundle.is_none());
20569 let launch = snapshot.launch_inventory.as_ref().unwrap();
20570 assert!(launch.spawn_profiles.is_some());
20571 assert!(launch.bundles.is_none());
20572 let event = project_event_without_session_bundle(NodeEventEnvelope {
20573 sequence: 1,
20574 event: NodeEvent::SessionRecordUpserted { record: bundled },
20575 });
20576 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20577 panic!("record projection changed the event kind");
20578 };
20579 assert!(record.bundle.is_none());
20580
20581 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20582 let mut reply = ResponseEnvelope {
20583 request_id: 1,
20584 result: Ok(NodeResponse::SpawnSpecAccepted {
20585 receipt: resolved.receipt_with_materialization(
20586 shared.incarnation_id,
20587 terminal_address(17),
20588 None,
20589 Some(bundle),
20590 None,
20591 ),
20592 }),
20593 };
20594 project_response_without_session_bundle(&mut reply);
20595 assert_eq!(
20596 reply.result.unwrap_err().code,
20597 NodeFailureCode::UnsupportedCapability,
20598 );
20599 }
20600
20601 #[test]
20602 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20603 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20604 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20605 }));
20606 let context_id = SpawnContextId::new("context-review").unwrap();
20607 let context = ResolvedContextPackReceipt {
20608 id: context_id.clone(),
20609 digest: crate::protocol::SpawnContextDigest::new(format!(
20610 "sha256:{}",
20611 "c".repeat(64),
20612 ))
20613 .unwrap(),
20614 lineage: ContextPackLineageReceipt {
20615 source_node_id: NodeId::new("node-source").unwrap(),
20616 source_session: terminal_address(1),
20617 source_provider: agent("kimi"),
20618 },
20619 source_message_count: 2,
20620 retained_message_count: 2,
20621 byte_len: 256,
20622 truncated: false,
20623 };
20624 assert!(context.is_valid());
20625 let mut bound_record = record("codex", "context-record");
20626 bound_record.context_id = Some(context_id);
20627 bound_record.context = Some(context.clone());
20628 let session = gate4agent_types::SessionSnapshot {
20629 instance_id: AgentInstanceId(91),
20630 agent_id: agent("kimi"),
20631 transport: TransportKind::Pty,
20632 generation: SessionGeneration(3),
20633 status: SessionStatus::Running,
20634 pending_operation: None,
20635 pending_input: None,
20636 process_id: Some(9001),
20637 terminal_size: None,
20638 terminal_frame: None,
20639 terminal_stale: None,
20640 session_options: None,
20641 capabilities: gate4agent_types::CapabilitySnapshot::default(),
20642 history: gate4agent_types::HistorySnapshot {
20643 pending: None,
20644 candidates: vec![HistoryCandidateSummary {
20645 id: "candidate-opaque".to_owned(),
20646 session_id_hint: "session-hint".to_owned(),
20647 modified_at_unix_ms: Some(1),
20648 }],
20649 loaded_candidate_id: Some("candidate-opaque".to_owned()),
20650 loaded: Some(HistorySessionRecord {
20651 session_id: "private-vendor-session".to_owned(),
20652 title: Some("private title".to_owned()),
20653 cwd: Some(r"C:\private\history-root".to_owned()),
20654 model: Some("private-model".to_owned()),
20655 message_count: 2,
20656 completed_turn_count: None,
20657 total_tokens: 19,
20658 messages: vec![
20659 gate4agent_types::HistoryMessageRecord {
20660 role: gate4agent_types::HistoryMessageRole::User,
20661 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20662 },
20663 gate4agent_types::HistoryMessageRecord {
20664 role: gate4agent_types::HistoryMessageRole::Assistant,
20665 text: "private answer".to_owned(),
20666 },
20667 ],
20668 }),
20669 last_error: None,
20670 },
20671 resume: gate4agent_types::ResumeSnapshot::default(),
20672 foreground: gate4agent_types::ForegroundSnapshot::default(),
20673 provider: gate4agent_types::ProviderSnapshot::default(),
20674 screen_state: gate4agent_types::PtyScreenState::default(),
20675 };
20676 let original = NodeSnapshot {
20677 node_id: NodeId::new("node-terminal-test").unwrap(),
20678 enabled_providers: vec![agent("kimi"), agent("codex")],
20679 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20680 workspaces: vec![WorkspaceSnapshot {
20681 workspace_id: WorkspaceId::new("primary").unwrap(),
20682 canonical_root: opaque_windows_path(
20683 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20684 ),
20685 sessions: vec![session],
20686 worktree_service_mode: None,
20687 managed_worktree_profiles: None,
20688 }],
20689 session_records: vec![bound_record],
20690 managed_worktrees: Vec::new(),
20691 launch_inventory: None,
20692 agent_progress: Vec::new(),
20693 };
20694
20695 let mut negotiated = original.clone();
20696 project_snapshot_history_for_wire(&mut negotiated, true);
20697 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
20698 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
20699 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
20700 assert!(!serde_json::to_string(&negotiated)
20701 .unwrap()
20702 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
20703
20704 let mut legacy = original;
20705 project_snapshot_history_for_wire(&mut legacy, false);
20706 clear_snapshot_context_packs(&mut legacy);
20707 assert_eq!(
20708 legacy.workspaces[0].sessions[0].history,
20709 gate4agent_types::HistorySnapshot::default(),
20710 );
20711 assert!(legacy.session_records[0].context_id.is_none());
20712 assert!(legacy.session_records[0].context.is_none());
20713 let encoded = serde_json::to_string(&legacy).unwrap();
20714 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
20715 assert!(!encoded.contains("context-review"));
20716
20717 let mut reply = ResponseEnvelope {
20718 request_id: 7,
20719 result: Ok(NodeResponse::ContextPackExported { context }),
20720 };
20721 project_response_without_context_pack(&mut reply);
20722 assert_eq!(
20723 reply.result.unwrap_err().code,
20724 NodeFailureCode::UnsupportedCapability,
20725 );
20726 }
20727
20728 #[test]
20729 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
20730 let shared = terminal_test_shared();
20731 let history = HistorySessionRecord {
20732 session_id: "fixture-history".to_owned(),
20733 title: None,
20734 cwd: None,
20735 model: None,
20736 message_count: 1,
20737 completed_turn_count: None,
20738 total_tokens: 0,
20739 messages: vec![gate4agent_types::HistoryMessageRecord {
20740 role: gate4agent_types::HistoryMessageRole::User,
20741 text: "bounded handoff".to_owned(),
20742 }],
20743 };
20744 let pack = NodeContextPack::export(
20745 ContextPackLineageReceipt {
20746 source_node_id: shared.node_id.clone(),
20747 source_session: terminal_address(1),
20748 source_provider: agent("claude"),
20749 },
20750 &history,
20751 )
20752 .unwrap();
20753 let context = shared
20754 .context_catalog
20755 .write()
20756 .unwrap_or_else(|poisoned| poisoned.into_inner())
20757 .insert(pack)
20758 .unwrap();
20759 let mut retained = record("codex", "context-retained-record");
20760 retained.context_id = Some(context.id.clone());
20761 retained.context = Some(context.clone());
20762 let record_id = retained.record_id.clone();
20763 shared.insert_record(retained).unwrap();
20764 assert_eq!(
20765 shared.forget_context_pack(&context.id).unwrap_err().code,
20766 NodeFailureCode::ContextPackBusy,
20767 );
20768 assert!(shared
20769 .context_catalog
20770 .read()
20771 .unwrap_or_else(|poisoned| poisoned.into_inner())
20772 .get(&context.id)
20773 .is_some());
20774 shared
20775 .session_records
20776 .lock()
20777 .unwrap_or_else(|poisoned| poisoned.into_inner())
20778 .records
20779 .remove(&record_id);
20780
20781 let mut spec = spawn_spec_fixture();
20782 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
20783 value: context.id.clone(),
20784 };
20785 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20786 let receipt = resolved.receipt_with_materialization(
20787 shared.incarnation_id,
20788 terminal_address(2),
20789 None,
20790 None,
20791 Some(context.clone()),
20792 );
20793 assert!(receipt.context_binding_is_valid());
20794 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
20795
20796 shared.forget_context_pack(&context.id).unwrap();
20797 assert!(shared
20798 .context_catalog
20799 .read()
20800 .unwrap_or_else(|poisoned| poisoned.into_inner())
20801 .get(&context.id)
20802 .is_none());
20803 assert!(shared
20804 .replay_spawn_spec(&spec, Instant::now())
20805 .unwrap()
20806 .is_none());
20807 }
20808
20809 #[test]
20810 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
20811 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20812 let address = terminal_address(1);
20813 let selection = shared
20814 .select_environment_profile(
20815 address.session.instance_id,
20816 &agent("claude"),
20817 SessionMode::Pty,
20818 Some(&environment_profile),
20819 )
20820 .unwrap()
20821 .unwrap();
20822 shared.bind_session_with_policy(
20823 &address,
20824 ProviderRuntimePolicy::raw_pty(),
20825 Some(environment_profile.clone()),
20826 );
20827 selection.retain();
20828 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20829 assert!(matches!(
20830 runtime.remove_native_launch_profile(&native_id),
20831 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20832 ));
20833 assert!(shared.remove_binding(&address).is_some());
20834 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20835
20836 let mismatched = ResolvedEnvironmentProfileReceipt {
20837 profile_id: environment_profile.profile_id,
20838 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
20839 "local-claude-r2",
20840 )
20841 .unwrap(),
20842 network_allowlist: None,
20843 browser_profile_id: None,
20844 };
20845 let error = match shared.select_environment_profile(
20846 AgentInstanceId(42),
20847 &agent("claude"),
20848 SessionMode::Pty,
20849 Some(&mismatched),
20850 ) {
20851 Ok(_) => panic!("mismatched environment profile revision was selected"),
20852 Err(error) => error,
20853 };
20854 assert_eq!(
20855 error.code,
20856 NodeFailureCode::EnvironmentProfileBindingMismatch,
20857 );
20858 assert!(!shared
20859 .session_bindings
20860 .lock()
20861 .unwrap_or_else(|poisoned| poisoned.into_inner())
20862 .contains_key(&AgentInstanceId(42)));
20863 }
20864
20865 #[test]
20866 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
20867 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20868 let address = terminal_address(1);
20869 let selection = shared
20870 .select_environment_profile(
20871 address.session.instance_id,
20872 &agent("claude"),
20873 SessionMode::Pty,
20874 Some(&environment_profile),
20875 )
20876 .unwrap()
20877 .unwrap();
20878 let overlay = NativeInstanceLaunchOverlay::new(
20879 agent("claude"),
20880 TransportKind::Pty,
20881 vec![EnvMutation {
20882 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
20883 value: Some(OsString::from("fixture-secret")),
20884 }],
20885 vec![
20886 OsString::from("--plugin-dir"),
20887 std::env::current_dir().unwrap().into_os_string(),
20888 ],
20889 )
20890 .unwrap();
20891 let instance_overlay = shared
20892 .install_prepared_launch_overlay(
20893 address.session.instance_id,
20894 PreparedNativeLaunchOverlay::Instance(overlay),
20895 )
20896 .unwrap()
20897 .unwrap();
20898 let bundle = ResolvedBundleReceipt {
20899 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
20900 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
20901 digest: crate::protocol::SpawnBundleDigest::new(format!(
20902 "sha256:{}",
20903 "0".repeat(64),
20904 ))
20905 .unwrap(),
20906 };
20907 shared.bind_session_with_materialization(
20908 &address,
20909 ProviderRuntimePolicy::raw_pty(),
20910 Some(environment_profile),
20911 Some(bundle),
20912 None,
20913 None,
20914 );
20915 selection.retain();
20916 instance_overlay.retain();
20917
20918 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20919 assert!(matches!(
20920 runtime.remove_native_launch_profile(&native_id),
20921 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20922 ));
20923 assert!(shared.remove_binding(&address).is_some());
20924 assert!(!shared
20925 .native_launch_profile_control
20926 .as_ref()
20927 .unwrap()
20928 .clear_native_instance_launch_overlay(address.session.instance_id));
20929 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20930 }
20931
20932 #[test]
20933 fn session_environment_materialization_failure_is_pre_child_and_durable() {
20934 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20935 let address = SessionAddress {
20936 workspace_id: WorkspaceId::new("primary").unwrap(),
20937 session: SessionKey {
20938 instance_id: AgentInstanceId(201),
20939 generation: SessionGeneration(1),
20940 },
20941 };
20942 let error = match shared.prepare_session_materialization(
20943 &address,
20944 &agent("claude"),
20945 SessionMode::Pty,
20946 Some(&receipt),
20947 None,
20948 None,
20949 None,
20950 ) {
20951 Ok(_) => panic!("denied local reference unexpectedly materialized"),
20952 Err(error) => error,
20953 };
20954 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20955 assert!(shared.handle.snapshot().sessions.is_empty());
20956 assert!(shared
20957 .session_bindings
20958 .lock()
20959 .unwrap_or_else(|poisoned| poisoned.into_inner())
20960 .is_empty());
20961 assert!(shared
20962 .materializations
20963 .lock()
20964 .unwrap_or_else(|poisoned| poisoned.into_inner())
20965 .is_empty());
20966 drop(shared);
20967 drop(runtime);
20968 std::fs::remove_dir_all(root).unwrap();
20969 }
20970
20971 #[test]
20972 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
20973 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20974 let address = SessionAddress {
20975 workspace_id: WorkspaceId::new("primary").unwrap(),
20976 session: SessionKey {
20977 instance_id: AgentInstanceId(202),
20978 generation: SessionGeneration(1),
20979 },
20980 };
20981 let (overlay, materialization_guard) = shared
20982 .prepare_session_materialization(
20983 &address,
20984 &agent("claude"),
20985 SessionMode::Pty,
20986 Some(&receipt),
20987 None,
20988 None,
20989 None,
20990 )
20991 .unwrap()
20992 .unwrap();
20993 let materialization_id = materialization_guard.id().unwrap().clone();
20994 let materialization_root = shared
20995 .materializations
20996 .lock()
20997 .unwrap_or_else(|poisoned| poisoned.into_inner())
20998 .get(&materialization_id)
20999 .unwrap()
21000 .root()
21001 .to_path_buf();
21002 let selection = shared
21003 .select_environment_profile(
21004 address.session.instance_id,
21005 &agent("claude"),
21006 SessionMode::Pty,
21007 Some(&receipt),
21008 )
21009 .unwrap()
21010 .unwrap();
21011 assert!(shared
21012 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21013 .unwrap()
21014 .is_none());
21015 shared.bind_session_with_materialization(
21016 &address,
21017 ProviderRuntimePolicy::raw_pty(),
21018 Some(receipt),
21019 None,
21020 None,
21021 Some(materialization_id),
21022 );
21023 selection.retain();
21024 materialization_guard.retain();
21025
21026 assert!(materialization_root.is_dir());
21027 assert!(shared
21028 .session_bindings
21029 .lock()
21030 .unwrap_or_else(|poisoned| poisoned.into_inner())
21031 .contains_key(&address.session.instance_id));
21032
21033 let removed = shared.remove_binding(&address).unwrap();
21034 shared
21035 .cleanup_session_owned_materialization(&address, &removed)
21036 .unwrap();
21037 assert!(!materialization_root.exists());
21038 assert!(shared
21039 .materializations
21040 .lock()
21041 .unwrap_or_else(|poisoned| poisoned.into_inner())
21042 .is_empty());
21043 drop(shared);
21044 drop(runtime);
21045 std::fs::remove_dir_all(root).unwrap();
21046 }
21047
21048 #[tokio::test]
21049 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21050 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21051 let address = SessionAddress {
21052 workspace_id: WorkspaceId::new("primary").unwrap(),
21053 session: SessionKey {
21054 instance_id: AgentInstanceId(203),
21055 generation: SessionGeneration(1),
21056 },
21057 };
21058 let (overlay, materialization_guard) = shared
21059 .prepare_session_materialization(
21060 &address,
21061 &agent("claude"),
21062 SessionMode::Pty,
21063 Some(&receipt),
21064 None,
21065 None,
21066 None,
21067 )
21068 .unwrap()
21069 .unwrap();
21070 let materialization_id = materialization_guard.id().unwrap().clone();
21071 let materialization_root = shared
21072 .materializations
21073 .lock()
21074 .unwrap_or_else(|poisoned| poisoned.into_inner())
21075 .get(&materialization_id)
21076 .unwrap()
21077 .root()
21078 .to_path_buf();
21079 let selection = shared
21080 .select_environment_profile(
21081 address.session.instance_id,
21082 &agent("claude"),
21083 SessionMode::Pty,
21084 Some(&receipt),
21085 )
21086 .unwrap()
21087 .unwrap();
21088 assert!(shared
21089 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21090 .unwrap()
21091 .is_none());
21092 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21093 let record_id = shared
21094 .bind_spawn_session_with_materialization(
21095 &address,
21096 agent("claude"),
21097 SessionMode::Pty,
21098 policy,
21099 SpawnRecordPolicy::ProviderIdentityOnly,
21100 Some(receipt.clone()),
21101 None,
21102 None,
21103 Some(materialization_id.clone()),
21104 )
21105 .unwrap()
21106 .unwrap();
21107 selection.retain();
21108 materialization_guard.retain();
21109 assert_eq!(
21110 shared
21111 .materializations
21112 .lock()
21113 .unwrap_or_else(|poisoned| poisoned.into_inner())
21114 .get(&materialization_id)
21115 .unwrap()
21116 .owner(),
21117 &MaterializationOwner::Record {
21118 record_id: record_id.clone(),
21119 },
21120 );
21121
21122 let removed = shared.remove_binding(&address).unwrap();
21123 shared
21124 .cleanup_session_owned_materialization(&address, &removed)
21125 .unwrap();
21126 assert!(materialization_root.is_dir());
21127 assert!(shared
21128 .resolve_record_materialization(
21129 &record_id,
21130 &agent("claude"),
21131 SessionMode::Pty,
21132 Some(&receipt),
21133 None,
21134 None,
21135 )
21136 .unwrap()
21137 .is_some());
21138 {
21139 let mut records = shared
21140 .session_records
21141 .lock()
21142 .unwrap_or_else(|poisoned| poisoned.into_inner());
21143 let record = records.records.get_mut(&record_id).unwrap();
21144 record.active_session = None;
21145 record.state = ManagedSessionState::Unavailable;
21146 }
21147 shared.forget_session_record(&record_id).await.unwrap();
21148 assert!(!materialization_root.exists());
21149 assert!(shared
21150 .materializations
21151 .lock()
21152 .unwrap_or_else(|poisoned| poisoned.into_inner())
21153 .is_empty());
21154 drop(shared);
21155 drop(runtime);
21156 std::fs::remove_dir_all(root).unwrap();
21157 }
21158
21159 #[test]
21160 fn record_materialization_revalidation_failure_persists_recovery_required() {
21161 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21162 let state_path = root.join("node-state-v6.json");
21163 shared.state_path = Some(state_path.clone());
21164 let (_address, _record_id, materialization_id, materialization_root) =
21165 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21166 std::fs::write(
21167 materialization_root.join(".gate4agent-materialization-owner"),
21168 b"tampered",
21169 )
21170 .unwrap();
21171
21172 let error = match shared.resolve_record_materialization(
21173 &_record_id,
21174 &agent("claude"),
21175 SessionMode::Pty,
21176 Some(&receipt),
21177 None,
21178 None,
21179 ) {
21180 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21181 Err(error) => error,
21182 };
21183 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21184 assert_eq!(
21185 shared
21186 .materializations
21187 .lock()
21188 .unwrap_or_else(|poisoned| poisoned.into_inner())
21189 .get(&materialization_id)
21190 .unwrap()
21191 .state(),
21192 MaterializationState::RecoveryRequired,
21193 );
21194 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21195 assert_eq!(loaded.materializations.len(), 1);
21196 assert_eq!(
21197 loaded.materializations[0].state(),
21198 MaterializationState::RecoveryRequired,
21199 );
21200 drop(shared);
21201 drop(runtime);
21202 std::fs::remove_dir_all(root).unwrap();
21203 }
21204
21205 #[test]
21206 fn transient_record_materialization_resolution_failure_remains_ready() {
21207 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21208 let (_address, record_id, materialization_id, _materialization_root) =
21209 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21210 deny.store(true, Ordering::Release);
21211
21212 let error = match shared.resolve_record_materialization(
21213 &record_id,
21214 &agent("claude"),
21215 SessionMode::Pty,
21216 Some(&receipt),
21217 None,
21218 None,
21219 ) {
21220 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21221 Err(error) => error,
21222 };
21223 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21224 assert_eq!(
21225 shared
21226 .materializations
21227 .lock()
21228 .unwrap_or_else(|poisoned| poisoned.into_inner())
21229 .get(&materialization_id)
21230 .unwrap()
21231 .state(),
21232 MaterializationState::Ready,
21233 );
21234 drop(shared);
21235 drop(runtime);
21236 std::fs::remove_dir_all(root).unwrap();
21237 }
21238
21239 #[test]
21240 fn startup_missing_record_materialization_is_not_resumable() {
21241 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21242 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21243 shared
21244 .insert_record(ManagedSessionRecord {
21245 record_id: record_id.clone(),
21246 display_name: "missing materialization".to_owned(),
21247 provider: agent("claude"),
21248 mode: SessionMode::Pty,
21249 state: ManagedSessionState::Dormant,
21250 workspace_id: WorkspaceId::new("primary").unwrap(),
21251 canonical_root: opaque_windows_path(
21252 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21253 ),
21254 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21255 key: gate4agent_types::ProviderSessionKey::SessionId,
21256 id: "provider-session-missing-materialization".to_owned(),
21257 transcript_path: None,
21258 }),
21259 active_session: None,
21260 environment_profile: Some(receipt),
21261 bundle: None,
21262 context_id: None,
21263 context: None,
21264 exported_context: None,
21265 task_binding: None,
21266 created_at_unix_ms: 1,
21267 updated_at_unix_ms: 1,
21268 last_error: None,
21269 })
21270 .unwrap();
21271
21272 shared.reconcile_materializations();
21273 let record = shared.record(&record_id).unwrap();
21274 assert_eq!(record.state, ManagedSessionState::Unavailable);
21275 assert!(record.provider_session.is_none());
21276 assert_eq!(
21277 record.last_error.as_deref(),
21278 Some("environment-profile-unavailable"),
21279 );
21280 drop(shared);
21281 drop(runtime);
21282 std::fs::remove_dir_all(root).unwrap();
21283 }
21284
21285 #[tokio::test]
21286 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21287 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21288 let lease = install_managed_lease(&shared);
21289 let address = SessionAddress {
21290 workspace_id: WorkspaceId::new("primary").unwrap(),
21291 session: SessionKey {
21292 instance_id: AgentInstanceId(204),
21293 generation: SessionGeneration(1),
21294 },
21295 };
21296 let (overlay, materialization_guard) = shared
21297 .prepare_session_materialization(
21298 &address,
21299 &agent("claude"),
21300 SessionMode::Pty,
21301 Some(&receipt),
21302 None,
21303 None,
21304 Some(lease.lease_id.clone()),
21305 )
21306 .unwrap()
21307 .unwrap();
21308 let materialization_id = materialization_guard.id().unwrap().clone();
21309 let materialization_root = shared
21310 .materializations
21311 .lock()
21312 .unwrap_or_else(|poisoned| poisoned.into_inner())
21313 .get(&materialization_id)
21314 .unwrap()
21315 .root()
21316 .to_path_buf();
21317 let selection = shared
21318 .select_environment_profile(
21319 address.session.instance_id,
21320 &agent("claude"),
21321 SessionMode::Pty,
21322 Some(&receipt),
21323 )
21324 .unwrap()
21325 .unwrap();
21326 assert!(shared
21327 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21328 .unwrap()
21329 .is_none());
21330 shared.bind_session_with_materialization(
21331 &address,
21332 ProviderRuntimePolicy::raw_pty(),
21333 Some(receipt),
21334 None,
21335 None,
21336 Some(materialization_id),
21337 );
21338 selection.retain();
21339 materialization_guard.retain();
21340
21341 let blocked = shared
21342 .cleanup_managed_worktree(&lease.lease_id, false)
21343 .await
21344 .unwrap_err();
21345 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21346 assert!(materialization_root.is_dir());
21347 let removed = shared.remove_binding(&address).unwrap();
21348 shared
21349 .cleanup_session_owned_materialization(&address, &removed)
21350 .unwrap();
21351 assert!(!materialization_root.exists());
21352 drop(shared);
21353 drop(runtime);
21354 std::fs::remove_dir_all(root).unwrap();
21355 }
21356
21357 #[tokio::test]
21358 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21359 let git_root = temporary_workspace_root("raw-managed-inventory");
21360 let source = git_root.join("source");
21361 let allocation = git_root.join("allocation");
21362 std::fs::create_dir_all(&source).unwrap();
21363 std::fs::create_dir_all(&allocation).unwrap();
21364 run_test_git(&source, &["init"]);
21365 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21366 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21367 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21368 run_test_git(&source, &["add", "seed.txt"]);
21369 run_test_git(&source, &["commit", "-m", "seed"]);
21370 let profile = ManagedWorktreeProfile::new(
21371 WorktreeProfileId::new("default").unwrap(),
21372 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21373 &allocation,
21374 "gate4agent",
21375 "HEAD",
21376 ManagedWorktreeRetention::RemoveWhenReleased,
21377 )
21378 .unwrap();
21379 let workspace = WorkspaceConfig::new(
21380 WorkspaceId::new("primary").unwrap(),
21381 &source,
21382 )
21383 .unwrap()
21384 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21385 .with_managed_worktree_profile(profile.clone())
21386 .unwrap();
21387 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21388 materialization_test_shared_with_workspace(false, workspace);
21389 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21390 let source_text = source.to_string_lossy().into_owned();
21391 let target_text = target.to_string_lossy().into_owned();
21392 let created = create_git_worktree(
21393 &source_text,
21394 &target_text,
21395 "gate4agent/mw-raw-inventory",
21396 Some("HEAD"),
21397 )
21398 .await
21399 .unwrap();
21400 let lease = ManagedWorktreeLeaseRecord {
21401 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21402 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21403 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21404 profile_id: profile.profile_id().clone(),
21405 profile_revision: profile.revision().clone(),
21406 target_root: created.path.clone(),
21407 branch: "gate4agent/mw-raw-inventory".to_owned(),
21408 base_commit: created.head.clone(),
21409 expected_head: Some(created.head.clone()),
21410 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21411 state: ManagedWorktreeLeaseState::Ready,
21412 session_holders: Vec::new(),
21413 record_holders: Vec::new(),
21414 cleanup_failure: None,
21415 created_at_unix_ms: 1,
21416 updated_at_unix_ms: 1,
21417 };
21418 shared
21419 .workspaces
21420 .write()
21421 .unwrap_or_else(|poisoned| poisoned.into_inner())
21422 .insert(lease.workspace_id.clone(), created.path.clone());
21423 *shared
21424 .managed_worktrees
21425 .lock()
21426 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21427 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21428 let address = SessionAddress {
21429 workspace_id: lease.workspace_id.clone(),
21430 session: SessionKey {
21431 instance_id: AgentInstanceId(205),
21432 generation: SessionGeneration(1),
21433 },
21434 };
21435 let (overlay, materialization_guard) = shared
21436 .prepare_session_materialization(
21437 &address,
21438 &agent("claude"),
21439 SessionMode::Pty,
21440 Some(&receipt),
21441 None,
21442 None,
21443 Some(lease.lease_id.clone()),
21444 )
21445 .unwrap()
21446 .unwrap();
21447 let materialization_id = materialization_guard.id().unwrap().clone();
21448 let materialization_root = shared
21449 .materializations
21450 .lock()
21451 .unwrap_or_else(|poisoned| poisoned.into_inner())
21452 .get(&materialization_id)
21453 .unwrap()
21454 .root()
21455 .to_path_buf();
21456 let selection = shared
21457 .select_environment_profile(
21458 address.session.instance_id,
21459 &agent("claude"),
21460 SessionMode::Pty,
21461 Some(&receipt),
21462 )
21463 .unwrap()
21464 .unwrap();
21465 assert!(shared
21466 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21467 .unwrap()
21468 .is_none());
21469 let record_id = shared
21470 .bind_spawn_session_with_materialization(
21471 &address,
21472 agent("claude"),
21473 SessionMode::Pty,
21474 ProviderRuntimePolicy::raw_pty(),
21475 SpawnRecordPolicy::Always,
21476 Some(receipt.clone()),
21477 None,
21478 None,
21479 Some(materialization_id.clone()),
21480 )
21481 .unwrap()
21482 .unwrap();
21483 selection.retain();
21484 materialization_guard.retain();
21485 let record = shared.record(&record_id).unwrap();
21486 assert_eq!(record.state, ManagedSessionState::Live);
21487 assert!(record.provider_session.is_none());
21488 assert!(record.environment_profile.is_none());
21489 assert!(record.bundle.is_none());
21490 assert!(record.context_id.is_none());
21491 assert!(record.context.is_none());
21492 assert_eq!(record.active_session.as_ref(), Some(&address));
21493 assert_eq!(
21494 shared
21495 .materializations
21496 .lock()
21497 .unwrap_or_else(|poisoned| poisoned.into_inner())
21498 .get(&materialization_id)
21499 .unwrap()
21500 .owner(),
21501 &MaterializationOwner::Session {
21502 incarnation_id: shared.incarnation_id,
21503 instance_id: address.session.instance_id,
21504 generation: address.session.generation,
21505 },
21506 );
21507 {
21508 let mut bindings = shared
21509 .session_bindings
21510 .lock()
21511 .unwrap_or_else(|poisoned| poisoned.into_inner());
21512 bindings
21513 .get_mut(&address.session.instance_id)
21514 .unwrap()
21515 .managed_worktree_lease_id = Some(lease.lease_id.clone());
21516 }
21517 let lease_snapshot = shared
21518 .managed_worktrees
21519 .lock()
21520 .unwrap_or_else(|poisoned| poisoned.into_inner())
21521 .bind_session(
21522 &lease.lease_id,
21523 ManagedWorktreeSessionHolder {
21524 incarnation_id: shared.incarnation_id,
21525 instance_id: address.session.instance_id,
21526 generation: address.session.generation,
21527 },
21528 None,
21529 unix_time_ms(),
21530 )
21531 .unwrap();
21532 assert_eq!(lease_snapshot.active_session_count, 1);
21533 assert_eq!(lease_snapshot.managed_record_count, 0);
21534 shared.persist_state().unwrap();
21535 assert!(shared
21536 .managed_worktrees
21537 .lock()
21538 .unwrap_or_else(|poisoned| poisoned.into_inner())
21539 .get(&lease.lease_id)
21540 .unwrap()
21541 .record_holders
21542 .is_empty());
21543 shared
21544 .handle
21545 .dispatch(shared.prepare_command(ControlCommand::Register {
21546 instance_id: address.session.instance_id,
21547 agent_id: agent("claude"),
21548 transport: TransportKind::Pty,
21549 }))
21550 .unwrap();
21551 runtime.tick().await;
21552 shared.reconcile_managed_record(
21553 &record_id,
21554 &address,
21555 &ControlEvent {
21556 sequence: 1,
21557 command_id: None,
21558 instance_id: address.session.instance_id,
21559 generation: address.session.generation,
21560 event: ControlEventKind::Removed,
21561 },
21562 false,
21563 false,
21564 );
21565 let remove = shared.remove_session(&address);
21566 let drive_runtime = async {
21567 runtime.tick().await;
21568 };
21569 let (removed, ()) = tokio::join!(remove, drive_runtime);
21570 removed.unwrap();
21571
21572 assert!(!materialization_root.exists());
21573 assert!(!Path::new(&created.path).exists());
21574 assert_eq!(
21575 shared
21576 .managed_worktrees
21577 .lock()
21578 .unwrap_or_else(|poisoned| poisoned.into_inner())
21579 .get(&lease.lease_id)
21580 .unwrap()
21581 .state,
21582 ManagedWorktreeLeaseState::Removed,
21583 );
21584 let renamed = shared
21585 .rename_session_record(&record_id, "raw managed inventory".to_owned())
21586 .unwrap();
21587 assert_eq!(renamed.record_id, record_id);
21588 assert_eq!(renamed.display_name, "raw managed inventory");
21589 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21590 assert!(renamed.active_session.is_none());
21591
21592 drop(shared);
21593 drop(runtime);
21594 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21595 std::fs::remove_dir_all(git_root).unwrap();
21596 }
21597
21598 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21599 let root = std::env::current_dir().unwrap()
21600 .join("managed-test-target")
21601 .to_string_lossy()
21602 .into_owned();
21603 let lease = ManagedWorktreeLeaseRecord {
21604 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21605 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21606 workspace_id: WorkspaceId::new("managed-test").unwrap(),
21607 profile_id: WorktreeProfileId::new("default").unwrap(),
21608 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21609 target_root: root.clone(),
21610 branch: "gate4agent/mw-test".to_owned(),
21611 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21612 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21613 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21614 state: ManagedWorktreeLeaseState::Ready,
21615 session_holders: Vec::new(),
21616 record_holders: Vec::new(),
21617 cleanup_failure: None,
21618 created_at_unix_ms: 1,
21619 updated_at_unix_ms: 1,
21620 };
21621 shared.workspaces.write()
21622 .unwrap_or_else(|poisoned| poisoned.into_inner())
21623 .insert(lease.workspace_id.clone(), root);
21624 *shared.managed_worktrees.lock()
21625 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21626 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21627 lease
21628 }
21629
21630 #[cfg(feature = "fixture")]
21631 #[test]
21632 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21633 let mut shared = terminal_test_shared();
21634 shared.fixture_semantic_hook_policy = true;
21635 let lease = install_managed_lease(&shared);
21636 let address = SessionAddress {
21637 workspace_id: lease.workspace_id.clone(),
21638 session: SessionKey {
21639 instance_id: AgentInstanceId(206),
21640 generation: SessionGeneration(1),
21641 },
21642 };
21643 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21644 let effective_runtime_policy =
21645 shared.effective_spawn_runtime_policy(pre_admission_policy);
21646 assert_ne!(effective_runtime_policy, pre_admission_policy);
21647 assert!(effective_runtime_policy.provider_session_identity);
21648
21649 let record_id = shared
21650 .bind_spawn_session(
21651 &address,
21652 agent("claude"),
21653 SessionMode::Pty,
21654 effective_runtime_policy,
21655 SpawnRecordPolicy::Always,
21656 None,
21657 )
21658 .unwrap()
21659 .unwrap();
21660 assert_eq!(
21661 shared.record(&record_id).unwrap().state,
21662 ManagedSessionState::IdentityPending,
21663 );
21664 let (record_holder, raw_inventory_record_id) = {
21665 let mut bindings = shared
21666 .session_bindings
21667 .lock()
21668 .unwrap_or_else(|poisoned| poisoned.into_inner());
21669 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
21670 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
21671 managed_spawn_record_ownership(binding, effective_runtime_policy)
21672 };
21673 assert_eq!(record_holder.as_ref(), Some(&record_id));
21674 assert!(raw_inventory_record_id.is_none());
21675 let snapshot = shared
21676 .managed_worktrees
21677 .lock()
21678 .unwrap_or_else(|poisoned| poisoned.into_inner())
21679 .bind_session(
21680 &lease.lease_id,
21681 ManagedWorktreeSessionHolder {
21682 incarnation_id: shared.incarnation_id,
21683 instance_id: address.session.instance_id,
21684 generation: address.session.generation,
21685 },
21686 record_holder,
21687 unix_time_ms(),
21688 )
21689 .unwrap();
21690 assert_eq!(snapshot.managed_record_count, 1);
21691 assert_eq!(
21692 shared
21693 .managed_worktrees
21694 .lock()
21695 .unwrap_or_else(|poisoned| poisoned.into_inner())
21696 .get(&lease.lease_id)
21697 .unwrap()
21698 .record_holders,
21699 vec![record_id.clone()],
21700 );
21701
21702 let identity = ProviderSessionIdentity {
21703 key: ProviderSessionKey::SessionId,
21704 id: "monitoring-fixture-managed-session".to_owned(),
21705 transcript_path: None,
21706 };
21707 shared.publish_control(ControlEvent {
21708 sequence: 1,
21709 command_id: None,
21710 instance_id: address.session.instance_id,
21711 generation: address.session.generation,
21712 event: ControlEventKind::ProviderEvent {
21713 sequence: 1,
21714 source: gate4agent_types::ProviderSource {
21715 family: AdapterFamily::Hook,
21716 binding: AdapterBinding::new(
21717 gate4agent_types::AdapterId::new("claude-code").unwrap(),
21718 "fixture/v1",
21719 gate4agent_types::AdapterVerification::SyntheticFixture,
21720 )
21721 .unwrap(),
21722 },
21723 source_sequence: 1,
21724 event: ProviderEvent::SessionIdentityObserved {
21725 identity: identity.clone(),
21726 },
21727 },
21728 });
21729
21730 let live = shared.record(&record_id).unwrap();
21731 assert_eq!(live.state, ManagedSessionState::Live);
21732 assert_eq!(live.provider_session.as_ref(), Some(&identity));
21733 assert_eq!(live.active_session.as_ref(), Some(&address));
21734 assert_eq!(
21735 shared
21736 .managed_worktrees
21737 .lock()
21738 .unwrap_or_else(|poisoned| poisoned.into_inner())
21739 .get(&lease.lease_id)
21740 .unwrap()
21741 .record_holders,
21742 vec![record_id],
21743 );
21744 }
21745
21746 fn managed_v2_test_shared(
21747 allocation_root: &Path,
21748 state_path: Option<PathBuf>,
21749 profile_revision: &str,
21750 ) -> NodeShared {
21751 let profile = ManagedWorktreeProfile::new(
21752 WorktreeProfileId::new("default").unwrap(),
21753 WorktreeProfileRevision::new(profile_revision).unwrap(),
21754 allocation_root,
21755 "gate4agent",
21756 "HEAD",
21757 ManagedWorktreeRetention::RemoveWhenReleased,
21758 )
21759 .unwrap();
21760 let workspace = WorkspaceConfig::new(
21761 WorkspaceId::new("primary").unwrap(),
21762 std::env::current_dir().unwrap(),
21763 )
21764 .unwrap()
21765 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21766 .with_managed_worktree_profile(profile)
21767 .unwrap();
21768 let catalog = active_registry().unwrap();
21769 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21770 let mut shared = NodeShared::new(
21771 handle,
21772 "fixture-token".to_owned(),
21773 NodeId::new("node-terminal-test").unwrap(),
21774 vec![workspace],
21775 vec![agent("claude")],
21776 );
21777 shared.state_path = state_path;
21778 shared
21779 }
21780
21781 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
21782 let mut spawn_spec = spawn_spec_fixture();
21783 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
21784 ManagedWorktreeSpawnRequestV2 {
21785 spawn_spec,
21786 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21787 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
21788 }
21789 }
21790
21791 #[test]
21792 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
21793 let shared = terminal_test_shared();
21794 let mut lease = install_managed_lease(&shared);
21795 let session = SessionAddress {
21796 workspace_id: lease.workspace_id.clone(),
21797 session: SessionKey {
21798 instance_id: AgentInstanceId(73),
21799 generation: SessionGeneration(4),
21800 },
21801 };
21802 lease.state = ManagedWorktreeLeaseState::InUse;
21803 lease.session_holders.push(ManagedWorktreeSessionHolder {
21804 incarnation_id: shared.incarnation_id,
21805 instance_id: session.session.instance_id,
21806 generation: session.session.generation,
21807 });
21808 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21809 assert!(resolved.target.worktree_id.is_none());
21810
21811 let receipt = managed_spawn_receipt(
21812 &resolved,
21813 shared.incarnation_id,
21814 session.clone(),
21815 lease.snapshot(),
21816 None,
21817 None,
21818 None,
21819 );
21820 assert!(resolved.target.worktree_id.is_none());
21821 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
21822 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
21823 assert_eq!(receipt.spawn.session, session);
21824 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
21825 let encoded = serde_json::to_vec(&receipt).unwrap();
21826 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
21827 assert_eq!(decoded, receipt);
21828 }
21829
21830 #[tokio::test]
21831 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
21832 let root = temporary_workspace_root("managed-v2-revision");
21833 let allocation = root.join("allocation");
21834 std::fs::create_dir_all(&allocation).unwrap();
21835 let shared = managed_v2_test_shared(
21836 &allocation,
21837 Some(root.join("node-state.json")),
21838 "v1",
21839 );
21840 let mut request = managed_v2_request("managed-v2-revision");
21841 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
21842
21843 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
21844
21845 assert_eq!(
21846 failure.code,
21847 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21848 );
21849 assert!(shared
21850 .managed_worktrees
21851 .lock()
21852 .unwrap_or_else(|poisoned| poisoned.into_inner())
21853 .records()
21854 .is_empty());
21855 assert!(shared
21856 .managed_spawn_replays
21857 .lock()
21858 .unwrap_or_else(|poisoned| poisoned.into_inner())
21859 .is_empty());
21860 std::fs::remove_dir_all(root).unwrap();
21861 }
21862
21863 #[test]
21864 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
21865 let root = temporary_workspace_root("managed-v2-concurrent");
21866 let allocation = root.join("allocation");
21867 std::fs::create_dir_all(&allocation).unwrap();
21868 let shared = Arc::new(managed_v2_test_shared(
21869 &allocation,
21870 Some(root.join("node-state.json")),
21871 "v1",
21872 ));
21873 let request = managed_v2_request("managed-v2-concurrent");
21874 let barrier = Arc::new(std::sync::Barrier::new(3));
21875 let mut joins = Vec::new();
21876 for _ in 0..2 {
21877 let shared = Arc::clone(&shared);
21878 let request = request.clone();
21879 let barrier = Arc::clone(&barrier);
21880 joins.push(std::thread::spawn(move || {
21881 let digest = managed_spawn_request_digest_v2(&request).unwrap();
21882 barrier.wait();
21883 shared.acquire_durable_managed_spawn_v2(&request, digest)
21884 }));
21885 }
21886 barrier.wait();
21887 let results = joins
21888 .into_iter()
21889 .map(|join| join.join().unwrap())
21890 .collect::<Vec<_>>();
21891 assert_eq!(
21892 results
21893 .iter()
21894 .filter(|result| {
21895 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
21896 })
21897 .count(),
21898 1,
21899 );
21900 assert_eq!(
21901 results
21902 .iter()
21903 .filter(|result| {
21904 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
21905 })
21906 .count(),
21907 1,
21908 );
21909 assert_eq!(
21910 shared
21911 .managed_spawn_replays
21912 .lock()
21913 .unwrap_or_else(|poisoned| poisoned.into_inner())
21914 .len(),
21915 1,
21916 );
21917 assert!(shared
21918 .managed_worktrees
21919 .lock()
21920 .unwrap_or_else(|poisoned| poisoned.into_inner())
21921 .records()
21922 .is_empty());
21923 drop(shared);
21924 std::fs::remove_dir_all(root).unwrap();
21925 }
21926
21927 #[tokio::test]
21928 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
21929 let root = temporary_workspace_root("managed-v2-linked-pending");
21930 let allocation = root.join("allocation");
21931 std::fs::create_dir_all(&allocation).unwrap();
21932 let state_path = root.join("node-state.json");
21933 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21934 let lease = install_managed_lease(&shared);
21935 let request = managed_v2_request("managed-v2-linked-pending");
21936 assert!(matches!(
21937 shared
21938 .acquire_durable_managed_spawn_v2(
21939 &request,
21940 managed_spawn_request_digest_v2(&request).unwrap(),
21941 )
21942 .unwrap(),
21943 DurableManagedSpawnReplayDecision::Reserved,
21944 ));
21945 {
21946 let _transaction = shared
21947 .state_transaction
21948 .lock()
21949 .unwrap_or_else(|poisoned| poisoned.into_inner());
21950 shared
21951 .link_durable_managed_spawn_v2_lease_locked(
21952 &request.spawn_spec.idempotency_key,
21953 &lease.lease_id,
21954 )
21955 .unwrap();
21956 shared.persist_state_locked().unwrap();
21957 }
21958 drop(shared);
21959
21960 let mut loaded = session_registry::load(
21961 Some(&state_path),
21962 &NodeId::new("node-terminal-test").unwrap(),
21963 )
21964 .unwrap();
21965 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21966 *reopened
21967 .managed_worktrees
21968 .lock()
21969 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21970 ManagedWorktreeRegistry::from_records(
21971 std::mem::take(&mut loaded.managed_worktrees),
21972 std::mem::take(&mut loaded.managed_worktree_tombstones),
21973 )
21974 .unwrap();
21975 *reopened
21976 .managed_spawn_replays
21977 .lock()
21978 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21979 .managed_spawn_replays
21980 .into_iter()
21981 .map(|record| (record.idempotency_key.clone(), record))
21982 .collect();
21983 let lease_count = reopened
21984 .managed_worktrees
21985 .lock()
21986 .unwrap_or_else(|poisoned| poisoned.into_inner())
21987 .records()
21988 .len();
21989
21990 let failure = reopened
21991 .spawn_managed_worktree_v2(request)
21992 .await
21993 .unwrap_err();
21994
21995 assert_eq!(
21996 failure.code,
21997 NodeFailureCode::ManagedWorktreeRecoveryRequired,
21998 );
21999 assert_eq!(
22000 reopened
22001 .managed_worktrees
22002 .lock()
22003 .unwrap_or_else(|poisoned| poisoned.into_inner())
22004 .records()
22005 .len(),
22006 lease_count,
22007 );
22008 std::fs::remove_dir_all(root).unwrap();
22009 }
22010
22011 #[tokio::test]
22012 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22013 let root = temporary_workspace_root("managed-v2-reopen");
22014 let allocation = root.join("allocation");
22015 std::fs::create_dir_all(&allocation).unwrap();
22016 let state_path = root.join("node-state.json");
22017 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22018 let lease = install_managed_lease(&shared);
22019 let request = managed_v2_request("managed-v2-reopen");
22020 let session = SessionAddress {
22021 workspace_id: lease.workspace_id.clone(),
22022 session: SessionKey {
22023 instance_id: AgentInstanceId(91),
22024 generation: SessionGeneration(1),
22025 },
22026 };
22027 let snapshot = {
22028 let mut registry = shared
22029 .managed_worktrees
22030 .lock()
22031 .unwrap_or_else(|poisoned| poisoned.into_inner());
22032 let current = registry.get_mut(&lease.lease_id).unwrap();
22033 current.state = ManagedWorktreeLeaseState::InUse;
22034 current.session_holders.push(ManagedWorktreeSessionHolder {
22035 incarnation_id: shared.incarnation_id,
22036 instance_id: session.session.instance_id,
22037 generation: session.session.generation,
22038 });
22039 current.snapshot()
22040 };
22041 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22042 let receipt = managed_spawn_receipt(
22043 &resolved,
22044 shared.incarnation_id,
22045 session,
22046 snapshot,
22047 None,
22048 None,
22049 None,
22050 );
22051 assert!(matches!(
22052 shared
22053 .acquire_durable_managed_spawn_v2(
22054 &request,
22055 managed_spawn_request_digest_v2(&request).unwrap(),
22056 )
22057 .unwrap(),
22058 DurableManagedSpawnReplayDecision::Reserved,
22059 ));
22060 {
22061 let _transaction = shared
22062 .state_transaction
22063 .lock()
22064 .unwrap_or_else(|poisoned| poisoned.into_inner());
22065 shared
22066 .link_durable_managed_spawn_v2_lease_locked(
22067 &request.spawn_spec.idempotency_key,
22068 &lease.lease_id,
22069 )
22070 .unwrap();
22071 shared.persist_state_locked().unwrap();
22072 }
22073 shared
22074 .commit_durable_managed_spawn_v2(&request, receipt.clone())
22075 .unwrap();
22076 let legacy_same_key = ManagedWorktreeSpawnRequest {
22077 spawn_spec: request.spawn_spec.clone(),
22078 worktree_profile_id: request.worktree_profile_id.clone(),
22079 };
22080 shared.remember_managed_spawn_attempt(
22081 Some(&request.spawn_spec.idempotency_key),
22082 legacy_same_key.clone(),
22083 Ok(receipt.clone()),
22084 Instant::now(),
22085 );
22086 assert!(shared
22087 .replay_managed_spawn(&legacy_same_key, Instant::now())
22088 .unwrap()
22089 .is_none());
22090 drop(shared);
22091
22092 let mut loaded = session_registry::load(
22093 Some(&state_path),
22094 &NodeId::new("node-terminal-test").unwrap(),
22095 )
22096 .unwrap();
22097 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22098 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22099 *reopened
22100 .managed_worktrees
22101 .lock()
22102 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22103 ManagedWorktreeRegistry::from_records(
22104 std::mem::take(&mut loaded.managed_worktrees),
22105 std::mem::take(&mut loaded.managed_worktree_tombstones),
22106 )
22107 .unwrap();
22108 *reopened
22109 .managed_spawn_replays
22110 .lock()
22111 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22112 .managed_spawn_replays
22113 .into_iter()
22114 .map(|record| (record.idempotency_key.clone(), record))
22115 .collect();
22116 let lease_count_before = reopened
22117 .managed_worktrees
22118 .lock()
22119 .unwrap_or_else(|poisoned| poisoned.into_inner())
22120 .records()
22121 .len();
22122
22123 assert_eq!(
22124 reopened
22125 .spawn_managed_worktree_v2(request.clone())
22126 .await
22127 .unwrap(),
22128 receipt,
22129 );
22130 assert_eq!(
22131 reopened
22132 .managed_worktrees
22133 .lock()
22134 .unwrap_or_else(|poisoned| poisoned.into_inner())
22135 .records()
22136 .len(),
22137 lease_count_before,
22138 );
22139 let mut changed = request;
22140 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22141 let failure = reopened
22142 .spawn_managed_worktree_v2(changed)
22143 .await
22144 .unwrap_err();
22145 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22146 assert_eq!(
22147 reopened
22148 .managed_worktrees
22149 .lock()
22150 .unwrap_or_else(|poisoned| poisoned.into_inner())
22151 .records()
22152 .len(),
22153 lease_count_before,
22154 );
22155 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22156 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22157 let failure = reopened
22158 .spawn_managed_worktree_v2(new_key)
22159 .await
22160 .unwrap_err();
22161 assert_eq!(
22162 failure.code,
22163 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22164 );
22165 assert_eq!(
22166 reopened
22167 .managed_worktrees
22168 .lock()
22169 .unwrap_or_else(|poisoned| poisoned.into_inner())
22170 .records()
22171 .len(),
22172 lease_count_before,
22173 );
22174 assert_eq!(
22175 reopened
22176 .managed_spawn_replays
22177 .lock()
22178 .unwrap_or_else(|poisoned| poisoned.into_inner())
22179 .len(),
22180 1,
22181 );
22182 std::fs::remove_dir_all(root).unwrap();
22183 }
22184
22185 #[test]
22186 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22187 let shared = terminal_test_shared();
22188 let request = ManagedWorktreeSpawnRequest {
22189 spawn_spec: spawn_spec_fixture(),
22190 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22191 };
22192 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22193 shared.remember_managed_spawn(
22194 request.clone(),
22195 Err(expected.clone()),
22196 Instant::now(),
22197 );
22198
22199 assert_eq!(
22200 shared
22201 .replay_managed_spawn(&request, Instant::now())
22202 .unwrap()
22203 .unwrap(),
22204 Err(expected),
22205 );
22206 assert!(shared
22207 .managed_spawn_replays
22208 .lock()
22209 .unwrap_or_else(|poisoned| poisoned.into_inner())
22210 .is_empty());
22211 }
22212
22213 #[tokio::test]
22214 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22215 let shared = terminal_test_shared();
22216 let lease = install_managed_lease(&shared);
22217 let raw = shared.spawn_session(
22218 lease.workspace_id.clone(),
22219 agent("claude"),
22220 SessionMode::Pty,
22221 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22222 None,
22223 ).await.unwrap_err();
22224 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22225 let mut spec = spawn_spec_fixture();
22226 spec.target.workspace_id = lease.workspace_id.clone();
22227 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22228 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22229 let mut selected = spawn_spec_fixture();
22230 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22231 selected.target.worktree_id = Some(lease.workspace_id);
22232 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22233 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22234 assert!(shared.handle.snapshot().sessions.is_empty());
22235 }
22236
22237 #[test]
22238 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22239 let shared = terminal_test_shared();
22240 let lease = install_managed_lease(&shared);
22241 let snapshot = snapshot_for_wire(
22242 &shared, true, true, true, false, true, true, false, false, true,
22243 );
22244 assert!(snapshot.managed_worktrees.is_empty());
22245 assert!(snapshot.workspaces.iter().all(|workspace| {
22246 workspace.managed_worktree_profiles.is_none()
22247 }));
22248 let mut reply = ResponseEnvelope {
22249 request_id: 1,
22250 result: Ok(NodeResponse::Resync {
22251 event_sequence: 1,
22252 oldest_available_sequence: 1,
22253 snapshot: shared.snapshot(),
22254 events: vec![NodeEventEnvelope {
22255 sequence: 1,
22256 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22257 }],
22258 }),
22259 };
22260 project_response_without_managed_worktrees(&mut reply);
22261 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22262 panic!("projection changed the response kind");
22263 };
22264 assert!(snapshot.managed_worktrees.is_empty());
22265 assert!(events.is_empty());
22266 let mut git = git_snapshot_for_parser();
22267 git.branch = Some(lease.branch.clone());
22268 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22269 lease_id: lease.lease_id.clone(),
22270 source_workspace_id: lease.source_workspace_id.clone(),
22271 branch: lease.branch.clone(),
22272 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22273 active_session_count: 1,
22274 managed_record_count: 0,
22275 });
22276 let mut inspection_reply = ResponseEnvelope {
22277 request_id: 2,
22278 result: Ok(NodeResponse::WorkspaceInspected {
22279 inspection: WorkspaceInspection {
22280 workspace_id: lease.workspace_id.clone(),
22281 entries: Vec::new(),
22282 tree_truncated: false,
22283 git,
22284 truncation: None,
22285 },
22286 }),
22287 };
22288 project_response_without_managed_worktrees(&mut inspection_reply);
22289 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22290 panic!("projection changed the inspection response kind");
22291 };
22292 assert!(inspection.git.managed_worktree.is_none());
22293 let failure = managed_git_worktree_failure(GitWorktreeError {
22294 kind: GitWorktreeErrorKind::Failed,
22295 message: r"git failed at C:\private\managed-target".to_owned(),
22296 });
22297 assert!(!failure.message.contains("private"));
22298 assert!(!failure.message.contains("managed-target"));
22299 }
22300
22301 #[tokio::test]
22302 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22303 let root = temporary_workspace_root("managed-restart");
22304 let source = root.join("source");
22305 let allocation = root.join("allocation");
22306 std::fs::create_dir_all(&source).unwrap();
22307 std::fs::create_dir_all(&allocation).unwrap();
22308 run_test_git(&source, &["init"]);
22309 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22310 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22311 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22312 run_test_git(&source, &["add", "seed.txt"]);
22313 run_test_git(&source, &["commit", "-m", "seed"]);
22314 let profile = ManagedWorktreeProfile::new(
22315 WorktreeProfileId::new("default").unwrap(),
22316 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22317 &allocation,
22318 "gate4agent",
22319 "HEAD",
22320 ManagedWorktreeRetention::Retain,
22321 ).unwrap();
22322 let workspace = WorkspaceConfig::new(
22323 WorkspaceId::new("primary").unwrap(),
22324 &source,
22325 ).unwrap()
22326 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22327 .with_managed_worktree_profile(profile.clone()).unwrap();
22328 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22329 let source_text = workspace.canonical_root().to_owned();
22330 let target_text = target.to_string_lossy().into_owned();
22331 let created = create_git_worktree(
22332 &source_text,
22333 &target_text,
22334 "gate4agent/mw-restart",
22335 Some("HEAD"),
22336 ).await.unwrap();
22337 let lease = ManagedWorktreeLeaseRecord {
22338 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22339 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22340 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22341 profile_id: profile.profile_id().clone(),
22342 profile_revision: profile.revision().clone(),
22343 target_root: created.path.clone(),
22344 branch: "gate4agent/mw-restart".to_owned(),
22345 base_commit: created.head.clone(),
22346 expected_head: None,
22347 retention: ManagedWorktreeRetention::Retain,
22348 state: ManagedWorktreeLeaseState::Allocating,
22349 session_holders: Vec::new(),
22350 record_holders: Vec::new(),
22351 cleanup_failure: None,
22352 created_at_unix_ms: 1,
22353 updated_at_unix_ms: 1,
22354 };
22355 let catalog = active_registry().unwrap();
22356 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22357 let shared = NodeShared::new_with_incarnation(
22358 handle,
22359 "fixture-token".to_owned(),
22360 NodeId::new("node-managed-restart").unwrap(),
22361 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22362 vec![workspace],
22363 vec![agent("claude")],
22364 ProviderRuntimeStatuses::default(),
22365 None,
22366 Vec::new(),
22367 Vec::new(),
22368 SpawnProfileRegistry::default(),
22369 None,
22370 None,
22371 None,
22372 None,
22373 Vec::new(),
22374 vec![lease.clone()],
22375 Vec::new(),
22376 Vec::new(),
22377 None,
22378 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22379 );
22380 shared.reconcile_managed_worktrees().await;
22381 let retained = shared.managed_worktrees.lock()
22382 .unwrap_or_else(|poisoned| poisoned.into_inner())
22383 .get(&lease.lease_id).unwrap().clone();
22384 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22385 assert!(shared.workspaces.read()
22386 .unwrap_or_else(|poisoned| poisoned.into_inner())
22387 .contains_key(&lease.workspace_id));
22388 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22389 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22390 assert!(!Path::new(&created.path).exists());
22391 std::fs::remove_dir_all(root).unwrap();
22392 }
22393
22394 fn run_test_git(root: &Path, arguments: &[&str]) {
22395 let output = std::process::Command::new("git")
22396 .arg("-C").arg(root).args(arguments).output().unwrap();
22397 assert!(
22398 output.status.success(),
22399 "git {:?} failed: {}",
22400 arguments,
22401 String::from_utf8_lossy(&output.stderr),
22402 );
22403 }
22404
22405 #[tokio::test]
22406 async fn spawn_spec_validation_fails_before_session_mutation() {
22407 let shared = terminal_test_shared();
22408 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22409 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22410 }));
22411 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22412 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22413 }));
22414 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22415 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22416 }));
22417
22418 let mut target_mismatch = spawn_spec_fixture();
22419 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22420 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22421 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22422
22423 let mut unknown_profile = spawn_spec_fixture();
22424 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22425 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22426 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22427 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22428
22429 let mut revision_mismatch = spawn_spec_fixture();
22430 revision_mismatch.idempotency_key =
22431 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22432 revision_mismatch.expected_profile_revision =
22433 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22434 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22435 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22436
22437 let mut unsupported_materializer = spawn_spec_fixture();
22438 unsupported_materializer.idempotency_key =
22439 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22440 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22441 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22442 };
22443 let failure = shared
22444 .spawn_from_spec(unsupported_materializer)
22445 .await
22446 .unwrap_err();
22447 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22448
22449 let failure = shared
22450 .spawn_session_with_deadline(
22451 WorkspaceId::new("primary").unwrap(),
22452 agent("claude"),
22453 SessionMode::Pty,
22454 gate4agent_types::TerminalSize {
22455 rows: 24,
22456 columns: 80,
22457 },
22458 None,
22459 None,
22460 None,
22461 None,
22462 None,
22463 None,
22464 SpawnRecordPolicy::ProviderIdentityOnly,
22465 Some(Instant::now()),
22466 &[],
22467 None,
22468 ApprovalLevel::default(),
22469 None,
22470 None,
22471 )
22472 .await
22473 .unwrap_err();
22474 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22475 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22476 assert!(shared.handle.snapshot().sessions.is_empty());
22477 }
22478
22479 #[test]
22480 fn worktree_selection_requires_its_separate_negotiated_capability() {
22481 let mut spec = spawn_spec_fixture();
22482 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22483 let request = NodeRequest::SpawnSpec { spec };
22484 let spawn_spec_capability =
22485 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22486 assert!(request_uses_unnegotiated_capability(
22487 &request,
22488 &[spawn_spec_capability.clone()],
22489 ));
22490 let worktree_capability =
22491 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22492 assert!(request_uses_unnegotiated_capability(
22493 &request,
22494 &[spawn_spec_capability.clone(), worktree_capability.clone()],
22495 ));
22496 let profile_revision_capability =
22497 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22498 assert!(request_uses_unnegotiated_capability(
22499 &request,
22500 &[
22501 spawn_spec_capability.clone(),
22502 worktree_capability.clone(),
22503 profile_revision_capability.clone(),
22504 ],
22505 ));
22506 let bundle_materialization_capability =
22513 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22514 assert!(request_uses_unnegotiated_capability(
22515 &request,
22516 &[
22517 spawn_spec_capability.clone(),
22518 worktree_capability.clone(),
22519 profile_revision_capability.clone(),
22520 bundle_materialization_capability.clone(),
22521 ],
22522 ));
22523 let history_context_pack_capability =
22524 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22525 assert!(!request_uses_unnegotiated_capability(
22526 &request,
22527 &[
22528 spawn_spec_capability,
22529 worktree_capability,
22530 profile_revision_capability,
22531 bundle_materialization_capability,
22532 history_context_pack_capability,
22533 ],
22534 ));
22535 }
22536
22537 #[tokio::test]
22538 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22539 let mut shared = terminal_test_shared();
22540 let spec = spawn_spec_fixture();
22541 let receipt = shared
22542 .resolve_spawn_spec(&spec)
22543 .unwrap()
22544 .receipt(shared.incarnation_id, terminal_address(1));
22545 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22546
22547 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22548 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22549 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22550
22551 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22552
22553 let mut revision_conflicting = spec.clone();
22554 revision_conflicting.expected_profile_revision =
22555 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22556 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22557 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22558
22559 let mut stale_cache_miss = spec.clone();
22560 stale_cache_miss.idempotency_key =
22561 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22562 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22563 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22564
22565 let mut conflicting = spec;
22566 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22567 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22568 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22569 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22570 assert!(shared.handle.snapshot().sessions.is_empty());
22571 }
22572
22573 #[tokio::test]
22574 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22575 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22576 let mut spec = spawn_spec_fixture();
22577 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22578 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22579 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22580
22581 let failure = shared
22582 .create_worktree(
22583 WorkspaceId::new("primary").unwrap(),
22584 WorkspaceId::new("new-worktree").unwrap(),
22585 "not-an-absolute-path".to_owned(),
22586 "invalid branch".to_owned(),
22587 None,
22588 )
22589 .await
22590 .unwrap_err();
22591 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22592
22593 let failure = shared
22594 .remove_worktree(
22595 WorkspaceId::new("primary").unwrap(),
22596 "not-an-absolute-path".to_owned(),
22597 )
22598 .await
22599 .unwrap_err();
22600 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22601 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22602 assert!(shared.handle.snapshot().sessions.is_empty());
22603 }
22604
22605 #[tokio::test]
22606 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22607 let root = temporary_workspace_root("manual-worktree-compensation");
22608 let source = root.join("source");
22609 let target = root.join("target");
22610 let invalid_state_parent = root.join("state-parent-is-a-file");
22611 let invalid_state_path = invalid_state_parent.join("state.json");
22612 std::fs::create_dir_all(&source).unwrap();
22613 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22614 run_test_git(&source, &["init"]);
22615 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22616 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22617 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22618 run_test_git(&source, &["add", "seed.txt"]);
22619 run_test_git(&source, &["commit", "-m", "seed"]);
22620
22621 let catalog = active_registry().unwrap();
22622 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22623 let workspace = WorkspaceConfig::new(
22624 WorkspaceId::new("primary").unwrap(),
22625 &source,
22626 ).unwrap();
22627 let mut shared = NodeShared::new(
22628 handle,
22629 "fixture-token".to_owned(),
22630 NodeId::new("node-worktree-compensation").unwrap(),
22631 vec![workspace],
22632 vec![agent("claude")],
22633 );
22634 shared.state_path = Some(invalid_state_path);
22635
22636 let error = shared.create_worktree(
22637 WorkspaceId::new("primary").unwrap(),
22638 WorkspaceId::new("compensated").unwrap(),
22639 target.to_string_lossy().into_owned(),
22640 "gate4agent/compensated".to_owned(),
22641 Some("HEAD".to_owned()),
22642 ).await.unwrap_err();
22643
22644 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22645 assert!(!target.exists());
22646 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22647 .iter()
22648 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22649 std::fs::remove_dir_all(root).unwrap();
22650 }
22651
22652 #[test]
22653 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22654 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22655 NativeGitWorktreeSnapshot {
22656 path: path.to_owned(),
22657 head: "0123456789abcdef".to_owned(),
22658 branch: Some("refs/heads/test".to_owned()),
22659 is_bare,
22660 is_main,
22661 locked: false,
22662 lock_reason: None,
22663 prunable: false,
22664 prunable_reason: None,
22665 workspace_id: None,
22666 }
22667 }
22668
22669 let source = r"C:\repo";
22670 let selected = r"C:\repo-worktree";
22671 let listed = vec![
22672 worktree(source, true, false),
22673 worktree(selected, false, false),
22674 ];
22675 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
22676
22677 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
22678 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
22679
22680 let bare = vec![
22681 worktree(source, true, false),
22682 worktree(selected, false, true),
22683 ];
22684 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
22685 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
22686
22687 let foreign_failure =
22688 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
22689 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
22690 }
22691
22692 fn terminal_frame(sequence: u64) -> TerminalFrame {
22693 TerminalFrame {
22694 sequence,
22695 size: gate4agent_types::TerminalSize {
22696 rows: 24,
22697 columns: 80,
22698 },
22699 cursor_row: 2,
22700 cursor_column: 3,
22701 contents: format!("frame-{sequence}"),
22702 formatted: vec![1, 2, 3],
22703 scrollback_formatted: Vec::new(),
22704 alternate_screen: false,
22705 mouse_protocol_enabled: false,
22706 mouse_protocol_encoding: Default::default(),
22707 produced_at_unix_ms: 0,
22708 screen_state: gate4agent_types::PtyScreenState::default(),
22709 bracketed_paste: None,
22710 }
22711 }
22712
22713 #[test]
22714 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
22715 let shared = terminal_test_shared();
22716 let mut receiver = shared.terminal_event_tx.subscribe();
22717 let original = terminal_address(1);
22718
22719 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22720 let first = receiver.try_recv().unwrap();
22721 assert_eq!(first.len(), 1);
22722 assert!(matches!(
22723 &first[0].event,
22724 NodeEvent::TerminalFrame { address, frame }
22725 if address == &original && frame.sequence == 7
22726 ));
22727
22728 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22729 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
22730 assert!(matches!(
22731 receiver.try_recv(),
22732 Err(broadcast::error::TryRecvError::Empty)
22733 ));
22734
22735 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
22736 let advanced = receiver.try_recv().unwrap();
22737 assert_eq!(advanced[0].sequence, 2);
22738
22739 let resumed = terminal_address(2);
22740 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
22741 let reset = receiver.try_recv().unwrap();
22742 assert_eq!(reset[0].sequence, 3);
22743 assert!(matches!(
22744 &reset[0].event,
22745 NodeEvent::TerminalFrame { address, frame }
22746 if address == &resumed && frame.sequence == 1
22747 ));
22748 shared.clear_terminal_frame_watermark(&resumed);
22749 assert!(!shared
22750 .terminal_frame_watermarks
22751 .lock()
22752 .unwrap_or_else(|poisoned| poisoned.into_inner())
22753 .contains_key(&resumed.session.instance_id));
22754 }
22755
22756 #[test]
22757 fn terminal_frame_batches_replace_without_entering_node_event_history() {
22758 let shared = terminal_test_shared();
22759 let mut receiver = shared.terminal_event_tx.subscribe();
22760 let address = terminal_address(1);
22761 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
22762
22763 for sequence in 1..=count {
22764 shared.publish_terminal_frame_candidates(vec![(
22765 address.clone(),
22766 terminal_frame(sequence),
22767 )]);
22768 }
22769
22770 let history = shared
22771 .history
22772 .lock()
22773 .unwrap_or_else(|poisoned| poisoned.into_inner());
22774 assert!(history.events.is_empty());
22775 assert_eq!(history.last_sequence, count);
22776 assert_eq!(history.replay_floor_sequence, 1);
22777 drop(history);
22778 let NodeResponse::Resync {
22779 event_sequence,
22780 oldest_available_sequence,
22781 events,
22782 ..
22783 } = shared.resync(0) else {
22784 unreachable!("resync helper returned another response")
22785 };
22786 assert_eq!(event_sequence, count);
22787 assert_eq!(oldest_available_sequence, 1);
22788 assert!(events.is_empty());
22789 assert!(matches!(
22790 resync_required_event(&shared).event,
22791 NodeEvent::ResyncRequired {
22792 oldest_available_sequence: 1,
22793 }
22794 ));
22795 assert!(matches!(
22796 receiver.try_recv(),
22797 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
22798 ));
22799 let latest = receiver.try_recv().unwrap();
22800 assert_eq!(latest.len(), 1);
22801 assert_eq!(latest[0].sequence, count);
22802 }
22803
22804 #[test]
22805 fn real_durable_eviction_advances_authoritative_replay_floor() {
22806 let shared = terminal_test_shared();
22807 for _ in 0..=NODE_EVENT_HISTORY_MAX {
22808 shared.publish(NodeEvent::ControllerChanged { controller: None });
22809 }
22810
22811 let NodeResponse::Resync {
22812 event_sequence,
22813 oldest_available_sequence,
22814 events,
22815 ..
22816 } = shared.resync(0) else {
22817 unreachable!("resync helper returned another response")
22818 };
22819 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
22820 assert_eq!(oldest_available_sequence, 2);
22821 assert_eq!(events.first().map(|event| event.sequence), Some(2));
22822 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
22823 assert!(matches!(
22824 resync_required_event(&shared).event,
22825 NodeEvent::ResyncRequired {
22826 oldest_available_sequence: 2,
22827 }
22828 ));
22829 }
22830
22831 #[test]
22832 fn legacy_terminal_frame_connection_has_no_live_subscription() {
22833 let shared = terminal_test_shared();
22834 let without_capability = terminal_event_subscription(&shared, false);
22835 let with_capability = terminal_event_subscription(&shared, true);
22836 assert!(without_capability.is_none());
22837 assert!(with_capability.is_some());
22838 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22839 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
22840 }));
22841 }
22842
22843 #[test]
22844 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
22845 let mut pending = Vec::new();
22846 let mut event_sequence = 0_u64;
22847 for frame_sequence in 1..=4 {
22848 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
22849 event_sequence += 1;
22850 let mut address = terminal_address(1);
22851 address.session.instance_id = AgentInstanceId(instance);
22852 queue_connection_event(
22853 &mut pending,
22854 NodeEventEnvelope {
22855 sequence: event_sequence,
22856 event: NodeEvent::TerminalFrame {
22857 address,
22858 frame: terminal_frame(frame_sequence),
22859 },
22860 },
22861 0,
22862 );
22863 }
22864 }
22865 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
22866 pending.sort_unstable_by_key(|event| event.sequence);
22867
22868 let mut uninterrupted = pending.clone();
22869 let first_burst = connection_event_burst_len(uninterrupted.len());
22870 uninterrupted.drain(..first_burst);
22871 let second_burst = connection_event_burst_len(uninterrupted.len());
22872 uninterrupted.drain(..second_burst);
22873 assert!(uninterrupted.is_empty());
22874
22875 let (frame_tx, mut frame_rx) =
22876 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
22877 frame_tx
22878 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
22879 request_id: 77,
22880 request: NodeRequest::Snapshot,
22881 })))
22882 .unwrap();
22883 let burst_len = connection_event_burst_len(pending.len());
22884 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
22885 pending.drain(..burst_len);
22886 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
22887 assert!(matches!(
22888 frame_rx.try_recv(),
22889 Ok(Ok(ClientFrame::Request(RequestEnvelope {
22890 request_id: 77,
22891 request: NodeRequest::Snapshot,
22892 })))
22893 ));
22894 }
22895
22896 #[test]
22897 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
22898 let mut pending = Vec::new();
22899 for sequence in [7_u64, 8] {
22900 let mut address = terminal_address(1);
22901 address.session.instance_id = AgentInstanceId(sequence);
22902 queue_connection_event(
22903 &mut pending,
22904 NodeEventEnvelope {
22905 sequence,
22906 event: NodeEvent::TerminalFrame {
22907 address,
22908 frame: terminal_frame(sequence),
22909 },
22910 },
22911 0,
22912 );
22913 }
22914 let mut discard_through = 0;
22915 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
22916 assert!(pending.is_empty());
22917
22918 let replayed = NodeEventEnvelope {
22919 sequence: 8,
22920 event: NodeEvent::TerminalFrame {
22921 address: terminal_address(1),
22922 frame: terminal_frame(8),
22923 },
22924 };
22925 let advanced = NodeEventEnvelope {
22926 sequence: 9,
22927 event: NodeEvent::TerminalFrame {
22928 address: terminal_address(2),
22929 frame: terminal_frame(1),
22930 },
22931 };
22932 queue_connection_event_batch(
22933 &mut pending,
22934 &[replayed, advanced.clone()],
22935 discard_through,
22936 );
22937 assert_eq!(pending, vec![advanced]);
22938 }
22939
22940 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
22941 ManagedSessionRecord {
22942 record_id: SessionRecordId::new(record_id).unwrap(),
22943 display_name: format!("{provider} record"),
22944 provider: agent(provider),
22945 mode: SessionMode::Pty,
22946 state: ManagedSessionState::Unavailable,
22947 workspace_id: WorkspaceId::new("primary").unwrap(),
22948 canonical_root: opaque_windows_path(
22949 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22950 ),
22951 provider_session: None,
22952 active_session: None,
22953 environment_profile: None,
22954 bundle: None,
22955 context_id: None,
22956 context: None,
22957 exported_context: None,
22958 task_binding: None,
22959 created_at_unix_ms: 1,
22960 updated_at_unix_ms: 1,
22961 last_error: None,
22962 }
22963 }
22964
22965 fn retention_test_record(
22966 workspace_id: &str,
22967 record_id: &str,
22968 state: ManagedSessionState,
22969 updated_at_unix_ms: u64,
22970 ) -> ManagedSessionRecord {
22971 ManagedSessionRecord {
22972 record_id: SessionRecordId::new(record_id).unwrap(),
22973 display_name: format!("{record_id} display"),
22974 provider: agent("claude"),
22975 mode: SessionMode::Pty,
22976 state,
22977 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
22978 canonical_root: opaque_windows_path(
22979 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22980 ),
22981 provider_session: None,
22982 active_session: None,
22983 environment_profile: None,
22984 bundle: None,
22985 context_id: None,
22986 context: None,
22987 exported_context: None,
22988 task_binding: None,
22989 created_at_unix_ms: updated_at_unix_ms,
22990 updated_at_unix_ms,
22991 last_error: None,
22992 }
22993 }
22994
22995 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23003 vec![
23004 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23005 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23006 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23007 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23008 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23009 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23010 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23011 retention_test_record(
23012 "workspace-a",
23013 "decoy-pending",
23014 ManagedSessionState::IdentityPending,
23015 now - 500_000,
23016 ),
23017 retention_test_record(
23018 "workspace-a",
23019 "decoy-dormant",
23020 ManagedSessionState::Dormant,
23021 now - 500_000,
23022 ),
23023 ]
23024 }
23025
23026 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23027 let root = temporary_workspace_root("session-record-retention");
23028 std::fs::create_dir_all(&root).unwrap();
23029 let catalog = active_registry().unwrap();
23030 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23031 let workspace_a =
23032 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23033 let workspace_b =
23034 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23035 let mut shared = NodeShared::new(
23036 handle,
23037 "fixture-token".to_owned(),
23038 NodeId::new("node-retention-test").unwrap(),
23039 vec![workspace_a, workspace_b],
23040 vec![agent("claude")],
23041 );
23042 shared.state_path = Some(root.join("node-state-v10.json"));
23043 let now = unix_time_ms();
23044 for record in retention_fixture_records(now) {
23045 shared.insert_record(record).unwrap();
23046 }
23047 shared
23048 .persist_state_locked()
23049 .expect("fixture-only, empty-history persist against a fresh temp state path");
23050 (shared, runtime, root, now)
23051 }
23052
23053 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23054 shared
23055 .history
23056 .lock()
23057 .unwrap_or_else(|poisoned| poisoned.into_inner())
23058 .events
23059 .iter()
23060 .filter_map(|envelope| match &envelope.event {
23061 NodeEvent::SessionRecordRemoved { record_id } => {
23062 Some(record_id.as_str().to_owned())
23063 }
23064 _ => None,
23065 })
23066 .collect()
23067 }
23068
23069 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23070 shared
23071 .session_records
23072 .lock()
23073 .unwrap_or_else(|poisoned| poisoned.into_inner())
23074 .records
23075 .keys()
23076 .map(|record_id| record_id.as_str().to_owned())
23077 .collect()
23078 }
23079
23080 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23081 values.iter().map(|value| (*value).to_owned()).collect()
23082 }
23083
23084 #[tokio::test]
23085 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23086 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23087 shared.session_record_retention = SessionRecordRetentionConfig {
23088 age_ms: 0,
23089 keep_per_workspace: 1,
23090 };
23091 shared.retire_unavailable_session_records().await;
23092
23093 assert_eq!(
23094 retention_remaining_record_ids(&shared),
23095 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23096 );
23097 assert_eq!(
23098 retention_removed_record_ids(&shared),
23099 owned(&["a1", "a2", "b1", "b2"]),
23100 );
23101 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23102
23103 drop(shared);
23104 drop(runtime);
23105 std::fs::remove_dir_all(root).unwrap();
23106 }
23107
23108 #[tokio::test]
23114 async fn session_record_retention_both_set_retires_only_the_intersection() {
23115 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23116 shared.session_record_retention = SessionRecordRetentionConfig {
23117 age_ms: 250_000,
23118 keep_per_workspace: 1,
23119 };
23120 shared.retire_unavailable_session_records().await;
23121
23122 assert_eq!(
23123 retention_remaining_record_ids(&shared),
23124 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23125 );
23126 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23127 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23128
23129 drop(shared);
23130 drop(runtime);
23131 std::fs::remove_dir_all(root).unwrap();
23132 }
23133
23134 #[tokio::test]
23135 async fn session_record_retention_both_disabled_is_a_no_op() {
23136 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23137 shared.session_record_retention = SessionRecordRetentionConfig::default();
23138 shared.retire_unavailable_session_records().await;
23139
23140 assert_eq!(
23141 retention_remaining_record_ids(&shared),
23142 owned(&[
23143 "a1", "a2", "a3", "b1", "b2", "b3",
23144 "decoy-live", "decoy-pending", "decoy-dormant",
23145 ]),
23146 );
23147 assert!(retention_removed_record_ids(&shared).is_empty());
23148 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23149
23150 drop(shared);
23151 drop(runtime);
23152 std::fs::remove_dir_all(root).unwrap();
23153 }
23154
23155 #[test]
23156 fn session_record_context_export_selects_one_exact_history_identity() {
23157 let candidates = vec![
23158 HistoryCandidateSummary {
23159 id: "candidate-other".to_owned(),
23160 session_id_hint: "provider-other".to_owned(),
23161 modified_at_unix_ms: Some(1),
23162 },
23163 HistoryCandidateSummary {
23164 id: "candidate-exact".to_owned(),
23165 session_id_hint: "provider-exact".to_owned(),
23166 modified_at_unix_ms: Some(2),
23167 },
23168 ];
23169 assert_eq!(
23170 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23171 "candidate-exact",
23172 );
23173 }
23174
23175 #[test]
23176 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23177 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23178 id: id.to_owned(),
23179 session_id_hint: hint.to_owned(),
23180 modified_at_unix_ms: None,
23181 };
23182 let missing = select_history_candidate(
23183 &[candidate("candidate-other", "provider-other")],
23184 Some("provider-exact"),
23185 )
23186 .unwrap_err();
23187 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23188 let ambiguous = select_history_candidate(
23189 &[
23190 candidate("candidate-a", "provider-exact"),
23191 candidate("candidate-b", "provider-exact"),
23192 ],
23193 Some("provider-exact"),
23194 )
23195 .unwrap_err();
23196 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23197 }
23198
23199 #[test]
23207 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23208 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23209 id: id.to_owned(),
23210 session_id_hint: hint.to_owned(),
23211 modified_at_unix_ms: None,
23212 };
23213 assert_eq!(
23214 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23215 .unwrap(),
23216 "candidate-sole",
23217 );
23218 let missing = select_history_candidate(&[], None).unwrap_err();
23219 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23220 let ambiguous = select_history_candidate(
23221 &[
23222 candidate("candidate-a", "sidecar-session-a"),
23223 candidate("candidate-b", "sidecar-session-b"),
23224 ],
23225 None,
23226 )
23227 .unwrap_err();
23228 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23229 }
23230
23231 #[test]
23232 fn session_record_context_export_revalidation_rejects_record_change() {
23233 let session = terminal_address(1);
23234 let identity = ProviderSessionIdentity {
23235 key: ProviderSessionKey::SessionId,
23236 id: "provider-exact".to_owned(),
23237 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23238 };
23239 let mut expected = record("claude", "record-context-export");
23240 expected.state = ManagedSessionState::Live;
23241 expected.provider_session = Some(identity.clone());
23242 expected.active_session = Some(session.clone());
23243 let binding = SessionBinding {
23244 workspace_id: session.workspace_id.clone(),
23245 generation: session.session.generation,
23246 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23247 pending_resume: None,
23248 record_id: Some(expected.record_id.clone()),
23249 managed_worktree_lease_id: None,
23250 environment_profile: None,
23251 bundle: None,
23252 context: None,
23253 materialization_id: None,
23254 };
23255 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23256 assert!(session_record_context_export_binding_is_exact(
23257 &expected,
23258 &expected,
23259 Some(&identity),
23260 &session,
23261 &binding,
23262 &expected.provider,
23263 ¤t_root,
23264 false,
23265 ));
23266 let mut changed = expected.clone();
23267 changed.updated_at_unix_ms += 1;
23268 assert!(!session_record_context_export_binding_is_exact(
23269 &expected,
23270 &changed,
23271 Some(&identity),
23272 &session,
23273 &binding,
23274 &expected.provider,
23275 ¤t_root,
23276 false,
23277 ));
23278 assert!(!session_record_context_export_binding_is_exact(
23283 &expected,
23284 &changed,
23285 Some(&identity),
23286 &session,
23287 &binding,
23288 &expected.provider,
23289 ¤t_root,
23290 true,
23291 ));
23292 let mut detached = expected.clone();
23293 detached.state = ManagedSessionState::Dormant;
23294 detached.active_session = None;
23295 detached.updated_at_unix_ms += 1;
23296 assert!(!session_record_context_export_binding_is_exact(
23297 &expected,
23298 &detached,
23299 Some(&identity),
23300 &session,
23301 &binding,
23302 &expected.provider,
23303 ¤t_root,
23304 false,
23305 ));
23306 assert!(session_record_context_export_binding_is_exact(
23307 &expected,
23308 &detached,
23309 Some(&identity),
23310 &session,
23311 &binding,
23312 &expected.provider,
23313 ¤t_root,
23314 true,
23315 ));
23316 }
23317
23318 #[test]
23325 fn session_record_context_export_binding_is_exact_without_identity() {
23326 let session = terminal_address(1);
23327 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23328 expected.state = ManagedSessionState::Live;
23329 expected.active_session = Some(session.clone());
23330 let binding = SessionBinding {
23331 workspace_id: session.workspace_id.clone(),
23332 generation: session.session.generation,
23333 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23334 pending_resume: None,
23335 record_id: Some(expected.record_id.clone()),
23336 managed_worktree_lease_id: None,
23337 environment_profile: None,
23338 bundle: None,
23339 context: None,
23340 materialization_id: None,
23341 };
23342 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23343 assert!(session_record_context_export_binding_is_exact(
23344 &expected,
23345 &expected,
23346 None,
23347 &session,
23348 &binding,
23349 &expected.provider,
23350 ¤t_root,
23351 false,
23352 ));
23353 let mut drifted = expected.clone();
23357 drifted.provider_session = Some(ProviderSessionIdentity {
23358 key: ProviderSessionKey::SessionId,
23359 id: "unexpected-identity".to_owned(),
23360 transcript_path: None,
23361 });
23362 assert!(!session_record_context_export_binding_is_exact(
23363 &expected,
23364 &drifted,
23365 None,
23366 &session,
23367 &binding,
23368 &expected.provider,
23369 ¤t_root,
23370 false,
23371 ));
23372 }
23373
23374 #[test]
23375 fn session_record_context_export_preflight_requires_live_usable_source() {
23376 let mut source = record("claude", "record-context-export-preflight");
23377 source.state = ManagedSessionState::Live;
23378 assert!(session_record_context_export_source_is_usable(
23379 &source,
23380 &SessionStatus::Running,
23381 false,
23382 ));
23383 assert!(session_record_context_export_source_is_usable(
23384 &source,
23385 &SessionStatus::Exited { exit_code: Some(0) },
23386 false,
23387 ));
23388
23389 source.state = ManagedSessionState::Dormant;
23390 assert!(!session_record_context_export_source_is_usable(
23391 &source,
23392 &SessionStatus::Running,
23393 false,
23394 ));
23395 assert!(session_record_context_export_source_is_usable(
23399 &source,
23400 &SessionStatus::Exited { exit_code: Some(0) },
23401 true,
23402 ));
23403 assert!(!session_record_context_export_source_is_usable(
23404 &source,
23405 &SessionStatus::Exited { exit_code: Some(0) },
23406 false,
23407 ));
23408 source.state = ManagedSessionState::Live;
23409 assert!(!session_record_context_export_source_is_usable(
23410 &source,
23411 &SessionStatus::Starting,
23412 false,
23413 ));
23414 }
23415
23416 #[test]
23417 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23418 let catalog = active_registry().unwrap();
23419 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23420 let workspace = WorkspaceConfig::new(
23421 WorkspaceId::new("primary").unwrap(),
23422 std::env::current_dir().unwrap(),
23423 )
23424 .unwrap();
23425 let shared = NodeShared::new(
23426 handle,
23427 "fixture-token".to_owned(),
23428 NodeId::new("node-context-export-conflict").unwrap(),
23429 vec![workspace],
23430 vec![agent("claude")],
23431 );
23432 let history = HistorySessionRecord {
23433 session_id: "provider-exact".to_owned(),
23434 title: None,
23435 cwd: None,
23436 model: None,
23437 message_count: 1,
23438 completed_turn_count: None,
23439 total_tokens: 0,
23440 messages: vec![gate4agent_types::HistoryMessageRecord {
23441 role: gate4agent_types::HistoryMessageRole::User,
23442 text: "bounded context".to_owned(),
23443 }],
23444 };
23445 let pack = NodeContextPack::export(
23446 ContextPackLineageReceipt {
23447 source_node_id: shared.node_id.clone(),
23448 source_session: terminal_address(1),
23449 source_provider: agent("claude"),
23450 },
23451 &history,
23452 )
23453 .unwrap();
23454 let context_id = pack.receipt().id.clone();
23455 let session = terminal_address(1);
23456 let identity = ProviderSessionIdentity {
23457 key: ProviderSessionKey::SessionId,
23458 id: history.session_id.clone(),
23459 transcript_path: None,
23460 };
23461 let mut expected = record("claude", "record-context-export-final-conflict");
23462 expected.state = ManagedSessionState::Live;
23463 expected.provider_session = Some(identity.clone());
23464 expected.active_session = Some(session.clone());
23465 let mut changed = expected.clone();
23466 changed.updated_at_unix_ms += 1;
23467 shared.insert_record(changed).unwrap();
23468
23469 let error = shared
23470 .commit_context_pack_for_session_record(
23471 &expected,
23472 Some(&identity),
23473 &session,
23474 "candidate-exact",
23475 &history,
23476 pack,
23477 false,
23478 )
23479 .unwrap_err();
23480
23481 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23482 assert!(shared
23483 .context_catalog
23484 .read()
23485 .unwrap_or_else(|poisoned| poisoned.into_inner())
23486 .get(&context_id)
23487 .is_none());
23488 }
23489
23490 #[tokio::test]
23491 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23492 let root = temporary_workspace_root("session-task-binding");
23493 std::fs::create_dir_all(&root).unwrap();
23494 let state_path = root.join("node-state.json");
23495 let catalog = active_registry().unwrap();
23496 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23497 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23498 let node_id = NodeId::new("node-1").unwrap();
23499 let mut shared = NodeShared::new(
23500 handle,
23501 "fixture-token".to_owned(),
23502 node_id.clone(),
23503 vec![workspace],
23504 vec![agent("claude")],
23505 );
23506 shared.state_path = Some(state_path.clone());
23507 let record = record("claude", "sr-task");
23508 let record_id = record.record_id.clone();
23509 shared.insert_record(record).unwrap();
23510 shared.persist_state().unwrap();
23511 shared
23512 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23513 .unwrap();
23514
23515 let minted = process_request_inner(
23516 &shared,
23517 77,
23518 ClientRole::Operator,
23519 NodeRequest::SetSessionTask {
23520 record_id: record_id.clone(),
23521 expected_revision: 0,
23522 target: SessionTaskTargetV1::New,
23523 },
23524 )
23525 .await
23526 .unwrap();
23527 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23528 panic!("session task mutation returned another response");
23529 };
23530 let minted_binding = minted.task_binding.clone().unwrap();
23531 let minted_task_id = minted_binding.task_id.clone().unwrap();
23532 assert_eq!(minted_binding.revision, 1);
23533 assert!(minted_task_id.as_str().starts_with("task-"));
23534 assert_eq!(minted_task_id.as_str().len(), 29);
23535
23536 let event_count = shared.current_sequence();
23537 let repeated = process_request_inner(
23538 &shared,
23539 77,
23540 ClientRole::Operator,
23541 NodeRequest::SetSessionTask {
23542 record_id: record_id.clone(),
23543 expected_revision: 1,
23544 target: SessionTaskTargetV1::Existing {
23545 task_id: minted_task_id.clone(),
23546 },
23547 },
23548 )
23549 .await
23550 .unwrap();
23551 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23552 assert_eq!(shared.current_sequence(), event_count);
23553
23554 let stale = process_request_inner(
23555 &shared,
23556 77,
23557 ClientRole::Operator,
23558 NodeRequest::SetSessionTask {
23559 record_id: record_id.clone(),
23560 expected_revision: 0,
23561 target: SessionTaskTargetV1::Existing {
23562 task_id: minted_task_id,
23563 },
23564 },
23565 )
23566 .await
23567 .unwrap_err();
23568 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23569
23570 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23571 let assigned = process_request_inner(
23572 &shared,
23573 77,
23574 ClientRole::Operator,
23575 NodeRequest::SetSessionTask {
23576 record_id: record_id.clone(),
23577 expected_revision: 1,
23578 target: SessionTaskTargetV1::Existing {
23579 task_id: assigned_id.clone(),
23580 },
23581 },
23582 )
23583 .await
23584 .unwrap();
23585 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23586 panic!("session task assignment returned another response");
23587 };
23588 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23589 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23590
23591 let cleared = process_request_inner(
23592 &shared,
23593 77,
23594 ClientRole::Operator,
23595 NodeRequest::SetSessionTask {
23596 record_id: record_id.clone(),
23597 expected_revision: 2,
23598 target: SessionTaskTargetV1::Clear,
23599 },
23600 )
23601 .await
23602 .unwrap();
23603 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23604 panic!("session task clear returned another response");
23605 };
23606 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23607 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23608 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23609 assert_eq!(
23610 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23611 cleared.updated_at_unix_ms,
23612 );
23613
23614 let persisted = std::fs::read(&state_path).unwrap();
23615 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23616 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23617 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23618 std::fs::remove_dir_all(root).unwrap();
23619 }
23620
23621 #[test]
23622 fn session_task_capability_strips_snapshots_events_and_replies() {
23623 let mut bound = record("claude", "sr-task-projection");
23624 bound.updated_at_unix_ms = 2;
23625 bound.task_binding = Some(SessionTaskBindingV1 {
23626 revision: 1,
23627 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23628 changed_at_unix_ms: 2,
23629 });
23630 let request = NodeRequest::SetSessionTask {
23631 record_id: bound.record_id.clone(),
23632 expected_revision: 1,
23633 target: SessionTaskTargetV1::Clear,
23634 };
23635 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23636 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23637 }));
23638 assert!(!request_uses_unnegotiated_capability(
23639 &request,
23640 &baseline_capabilities().unwrap(),
23641 ));
23642 assert!(request_uses_unnegotiated_capability(&request, &[]));
23643
23644 let mut snapshot = terminal_test_shared().snapshot();
23645 snapshot.session_records.push(bound.clone());
23646 let mut reply = ResponseEnvelope {
23647 request_id: 1,
23648 result: Ok(NodeResponse::Resync {
23649 event_sequence: 1,
23650 oldest_available_sequence: 1,
23651 snapshot,
23652 events: vec![NodeEventEnvelope {
23653 sequence: 1,
23654 event: NodeEvent::SessionRecordUpserted {
23655 record: bound.clone(),
23656 },
23657 }],
23658 }),
23659 };
23660 project_response_without_session_task_binding(&mut reply);
23661 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23662 panic!("task projection changed the response kind");
23663 };
23664 assert!(snapshot.session_records[0].task_binding.is_none());
23665 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
23666 panic!("task projection changed the event kind");
23667 };
23668 assert!(record.task_binding.is_none());
23669
23670 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
23671 sequence: 2,
23672 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
23673 });
23674 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
23675 panic!("task event projection changed the event kind");
23676 };
23677 assert!(record.task_binding.is_none());
23678
23679 let mut direct = ResponseEnvelope {
23680 request_id: 2,
23681 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
23682 };
23683 project_response_without_session_task_binding(&mut direct);
23684 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
23685 panic!("task reply projection changed the response kind");
23686 };
23687 assert!(record.task_binding.is_none());
23688 }
23689
23690 #[test]
23691 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
23692 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
23693 let workspace = WorkspaceConfig::new(
23694 WorkspaceId::new("primary").unwrap(),
23695 std::env::current_dir().unwrap(),
23696 )
23697 .unwrap();
23698 let config = NodeServerConfig::new(
23699 DEFAULT_NODE_ENDPOINT,
23700 "fixture-token",
23701 NodeId::new("node-1").unwrap(),
23702 [workspace],
23703 )
23704 .unwrap();
23705 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
23706 }
23707
23708 #[cfg(all(feature = "fixture", windows))]
23709 #[test]
23710 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
23711 let root = temporary_workspace_root("provider-bundle-argv-hold");
23712 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
23713 std::fs::create_dir_all(&root).unwrap();
23714 std::fs::create_dir_all(&outside).unwrap();
23715 let proof_path = root.join("provider.proof");
23716 let release_signal = root.join("release.signal");
23717 let config = NodeServerConfig::new(
23718 format!(
23719 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
23720 std::process::id(),
23721 ),
23722 "fixture-token",
23723 NodeId::new("provider-bundle-hold-node").unwrap(),
23724 [WorkspaceConfig::new(
23725 WorkspaceId::new("primary").unwrap(),
23726 std::env::current_dir().unwrap(),
23727 )
23728 .unwrap()],
23729 )
23730 .unwrap()
23731 .with_spawn_profiles(SpawnProfileRegistry::new([
23732 crate::protocol::SpawnProfileDefaults {
23733 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
23734 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
23735 .unwrap(),
23736 provider: agent("kimi"),
23737 mode: SessionMode::Pty,
23738 terminal_size: gate4agent_types::TerminalSize {
23739 rows: 24,
23740 columns: 80,
23741 },
23742 prompt: None,
23743 bundle_id: None,
23744 context_id: None,
23745 environment_profile_id: None,
23746 },
23747 ])
23748 .unwrap());
23749 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
23750 config,
23751 agent("kimi"),
23752 proof_path.clone(),
23753 release_signal.clone(),
23754 )
23755 .unwrap();
23756 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
23757
23758 let spec = NodeServer::provider_bundle_argv_fixture_spec(
23759 agent("kimi"),
23760 proof_path.clone(),
23761 Some(release_signal.clone()),
23762 )
23763 .unwrap();
23764 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
23765 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
23766 let release_wait = wrapper
23767 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
23768 .unwrap();
23769 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
23770 assert!(proof_write < release_wait);
23771 assert!(release_wait < fixture_ready);
23772 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
23773 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
23774 assert_eq!(
23775 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
23776 &[
23777 proof_path.to_string_lossy().into_owned(),
23778 release_signal.to_string_lossy().into_owned(),
23779 ],
23780 );
23781
23782 let error = NodeServer::provider_bundle_argv_fixture_spec(
23783 agent("kimi"),
23784 proof_path,
23785 Some(outside.join("release.signal")),
23786 )
23787 .unwrap_err();
23788 assert!(matches!(
23789 error,
23790 NodeServerError::Registry(message)
23791 if message == "provider bundle argv release path must share the proof parent"
23792 ));
23793 drop(server);
23794 std::fs::remove_dir_all(root).unwrap();
23795 std::fs::remove_dir_all(outside).unwrap();
23796 }
23797
23798 #[cfg(feature = "fixture")]
23799 #[test]
23800 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
23801 let root = temporary_workspace_root("context-fixture-registry");
23802 std::fs::create_dir_all(&root).unwrap();
23803 let proof_path = root.join("context.proof");
23804 let config = NodeServerConfig::new(
23805 format!(
23806 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
23807 std::process::id(),
23808 ),
23809 "fixture-token",
23810 NodeId::new("context-fixture-node").unwrap(),
23811 [WorkspaceConfig::new(
23812 WorkspaceId::new("primary").unwrap(),
23813 std::env::current_dir().unwrap(),
23814 )
23815 .unwrap()],
23816 )
23817 .unwrap();
23818 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
23819 let mut enabled = server
23820 .shared
23821 .enabled_providers
23822 .iter()
23823 .map(AgentId::as_str)
23824 .collect::<Vec<_>>();
23825 enabled.sort_unstable();
23826 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
23827 for provider in &server.shared.enabled_providers {
23828 assert!(server
23829 .shared
23830 .provider_adapter_contracts
23831 .iter()
23832 .any(|contract| {
23833 &contract.provider == provider
23834 && contract.family == AdapterFamily::History
23835 }));
23836 }
23837 drop(server);
23838 std::fs::remove_dir_all(root).unwrap();
23839 }
23840
23841 #[cfg(feature = "fixture")]
23842 #[test]
23843 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
23844 let root = temporary_workspace_root("monitoring-context-fixture");
23845 std::fs::create_dir_all(&root).unwrap();
23846 let proof_path = root.join("context.proof");
23847 let standard = NodeServer::context_pack_fixture_catalog(
23848 proof_path.clone(),
23849 None,
23850 false,
23851 ).unwrap();
23852 let monitoring = NodeServer::context_pack_fixture_catalog(
23853 proof_path.clone(),
23854 None,
23855 true,
23856 ).unwrap();
23857 let claude = AgentId::new("claude").unwrap();
23858 let standard_claude = standard.get(&claude).unwrap();
23859 let monitoring_claude = monitoring.get(&claude).unwrap();
23860 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
23864 assert_eq!(
23865 monitoring_claude.capabilities.adapters.history,
23866 standard_claude.capabilities.adapters.history,
23867 );
23868 assert_ne!(monitoring_claude.launch, standard_claude.launch);
23869 let codex = AgentId::new("codex").unwrap();
23870 let standard_codex = standard.get(&codex).unwrap();
23871 let monitoring_codex = monitoring.get(&codex).unwrap();
23872 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
23873 assert_eq!(
23874 monitoring_codex.capabilities.adapters.history,
23875 standard_codex.capabilities.adapters.history,
23876 );
23877 assert_ne!(monitoring_codex.launch, standard_codex.launch);
23878 let codex_validation = monitoring_codex.launch.fixed_args.iter()
23879 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
23880 .unwrap();
23881 for required in [
23882 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
23883 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
23884 "@('role', 'text')",
23885 "g4a-private-provider-session-canary",
23886 "private-provider-login-identity",
23887 "$rawProof",
23888 "$env:GATE4AGENT_HOOK_URL",
23889 "g4a-private-provider-session-canary",
23890 ] {
23891 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
23892 }
23893 for provider in ["grok", "kimi"] {
23894 let provider = AgentId::new(provider).unwrap();
23895 assert_eq!(monitoring.get(&provider), standard.get(&provider));
23896 }
23897
23898 let config = NodeServerConfig::new(
23899 format!(
23900 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
23901 std::process::id(),
23902 ),
23903 "fixture-token",
23904 NodeId::new("monitoring-context-fixture-node").unwrap(),
23905 [WorkspaceConfig::new(
23906 WorkspaceId::new("primary").unwrap(),
23907 std::env::current_dir().unwrap(),
23908 )
23909 .unwrap()],
23910 )
23911 .unwrap();
23912 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
23913 assert!(server.shared.fixture_semantic_hook_policy);
23914 drop(server);
23915 std::fs::remove_dir_all(root).unwrap();
23916 }
23917
23918 #[cfg(feature = "fixture")]
23919 #[test]
23920 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
23921 let root = temporary_workspace_root("kimi-context-only-fixture");
23922 std::fs::create_dir_all(&root).unwrap();
23923 let proof_path = root.join("context.proof");
23924 let standard = NodeServer::context_pack_fixture_catalog(
23925 proof_path.clone(),
23926 None,
23927 false,
23928 ).unwrap();
23929 let kimi = AgentId::new("kimi").unwrap();
23930 let context_only = NodeServer::context_pack_fixture_catalog(
23931 proof_path,
23932 Some(&kimi),
23933 false,
23934 )
23935 .unwrap();
23936
23937 let codex = AgentId::new("codex").unwrap();
23938 assert_eq!(
23939 standard.get(&codex).unwrap().launch.fixed_args,
23940 context_only.get(&codex).unwrap().launch.fixed_args,
23941 );
23942 let script = context_only
23943 .get(&kimi)
23944 .unwrap()
23945 .launch
23946 .fixed_args
23947 .iter()
23948 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
23949 .expect("Kimi context-only validation script is missing");
23950 for required in [
23951 "$bundleArgs.Count -ne 0",
23952 "GATE4AGENT_CONTEXT_ROOT",
23953 "context-pack.json",
23954 "$entries.Count -ne 1",
23955 "g4a-context-pack-v1",
23956 "$document.PSObject.Properties['cwd']",
23957 "'claude', 'codex', 'grok', 'kimi'",
23958 "$hasUser",
23959 "$hasAssistant",
23960 "SHA256",
23961 "context-only",
23962 ] {
23963 assert!(script.contains(required), "missing validation: {required}");
23964 }
23965 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
23966 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
23967 }
23968 assert!(!standard
23969 .get(&kimi)
23970 .unwrap()
23971 .launch
23972 .fixed_args
23973 .iter()
23974 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
23975
23976 std::fs::remove_dir_all(root).unwrap();
23977 }
23978
23979 #[cfg(all(feature = "fixture", windows))]
23980 #[tokio::test]
23981 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
23982 let root = temporary_workspace_root("kimi-context-only-spawn");
23983 let workspace_root = root.join("workspace");
23984 std::fs::create_dir_all(&workspace_root).unwrap();
23985 let materialization_root = root.join("private-materializations");
23986 let proof_path = root.join("context.proof");
23987 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
23988 let workspace_id = WorkspaceId::new("primary").unwrap();
23989 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
23990 let endpoint = format!(
23991 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
23992 std::process::id(),
23993 unix_time_ms(),
23994 );
23995 let spawn_profiles = SpawnProfileRegistry::new([
23996 crate::protocol::SpawnProfileDefaults {
23997 profile_id: profile_id.clone(),
23998 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
23999 provider: agent("kimi"),
24000 mode: SessionMode::Pty,
24001 terminal_size: gate4agent_types::TerminalSize {
24002 rows: 30,
24003 columns: 120,
24004 },
24005 prompt: None,
24006 bundle_id: None,
24007 context_id: None,
24008 environment_profile_id: None,
24009 },
24010 ])
24011 .unwrap();
24012 let mut config = NodeServerConfig::new(
24013 &endpoint,
24014 "fixture-token",
24015 node_id.clone(),
24016 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24017 )
24018 .unwrap()
24019 .with_spawn_profiles(spawn_profiles)
24020 .with_session_environment_materialization(
24021 materialization_root,
24022 Arc::new(FixtureSecretResolver {
24023 deny: Arc::new(AtomicBool::new(false)),
24024 }),
24025 )
24026 .unwrap();
24027 config.fixture_raw_pty_runtime = true;
24028 let server = NodeServer::new_context_only_proof_fixture(
24029 config,
24030 agent("kimi"),
24031 proof_path.clone(),
24032 )
24033 .unwrap();
24034 let shared = Arc::clone(&server.shared);
24035 let shutdown = server.shutdown_handle();
24036
24037 let history = HistorySessionRecord {
24038 session_id: "context-only-source-history".to_owned(),
24039 title: None,
24040 cwd: None,
24041 model: None,
24042 message_count: 2,
24043 completed_turn_count: None,
24044 total_tokens: 0,
24045 messages: vec![
24046 gate4agent_types::HistoryMessageRecord {
24047 role: gate4agent_types::HistoryMessageRole::User,
24048 text: "continue the bounded parent run through native C2".to_owned(),
24049 },
24050 gate4agent_types::HistoryMessageRecord {
24051 role: gate4agent_types::HistoryMessageRole::Assistant,
24052 text: "the exact parent context is ready for the child run".to_owned(),
24053 },
24054 ],
24055 };
24056 let pack = NodeContextPack::export(
24057 ContextPackLineageReceipt {
24058 source_node_id: node_id.clone(),
24059 source_session: SessionAddress {
24060 workspace_id: workspace_id.clone(),
24061 session: SessionKey {
24062 instance_id: AgentInstanceId(71),
24063 generation: SessionGeneration(1),
24064 },
24065 },
24066 source_provider: agent("codex"),
24067 },
24068 &history,
24069 )
24070 .unwrap();
24071 let expected_context_hash = digest(&SHA256, pack.bytes())
24072 .as_ref()
24073 .iter()
24074 .map(|byte| format!("{byte:02x}"))
24075 .collect::<String>();
24076 let context = shared
24077 .context_catalog
24078 .write()
24079 .unwrap_or_else(|poisoned| poisoned.into_inner())
24080 .insert(pack)
24081 .unwrap();
24082 let spec = SpawnSpec {
24083 target: crate::protocol::SpawnTarget {
24084 node_id,
24085 workspace_id: workspace_id.clone(),
24086 worktree_id: None,
24087 },
24088 profile_id,
24089 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24090 "target-kimi-r1",
24091 ).unwrap(),
24092 overrides: crate::protocol::SpawnOverrides {
24093 context_id: crate::protocol::SpawnOverride::Set {
24094 value: context.id.clone(),
24095 },
24096 ..crate::protocol::SpawnOverrides::default()
24097 },
24098 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24099 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24100 required_capabilities: SpawnRequiredCapabilities::default(),
24101 };
24102 let resolved = match shared.resolve_spawn_spec(&spec) {
24103 Ok(resolved) => resolved,
24104 Err(error) => panic!(
24105 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24106 error.code,
24107 error.message,
24108 ),
24109 };
24110 let resolved_context = match shared.resolve_context(&resolved) {
24111 Ok(Some(resolved_context)) => resolved_context,
24112 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24113 Err(error) => panic!(
24114 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24115 error.code,
24116 error.message,
24117 ),
24118 };
24119 assert_eq!(resolved_context, context);
24120 let runtime_policy = match shared
24121 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24122 .await
24123 {
24124 Ok(runtime_policy) => runtime_policy,
24125 Err(error) => panic!(
24126 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24127 error.code,
24128 error.message,
24129 ),
24130 };
24131 assert!(runtime_policy.raw_pty_lifecycle);
24132
24133 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24134 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24135 agent("kimi"),
24136 TransportKind::Pty,
24137 vec![EnvMutation {
24138 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24139 value: Some(OsString::from("must-remain-rejected")),
24140 }],
24141 )
24142 .unwrap();
24143 assert_eq!(
24144 launch_control
24145 .install_native_launch_environment_overlay(
24146 AgentInstanceId(72),
24147 generic_overlay,
24148 )
24149 .unwrap_err(),
24150 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24151 );
24152
24153 let diagnostic_address = SessionAddress {
24154 workspace_id: workspace_id.clone(),
24155 session: SessionKey {
24156 instance_id: AgentInstanceId(73),
24157 generation: SessionGeneration(1),
24158 },
24159 };
24160 let (diagnostic_overlay, diagnostic_materialization) = match shared
24161 .prepare_session_materialization(
24162 &diagnostic_address,
24163 &agent("kimi"),
24164 SessionMode::Pty,
24165 None,
24166 None,
24167 Some(&resolved_context),
24168 None,
24169 )
24170 {
24171 Ok(Some(prepared)) => prepared,
24172 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24173 Err(error) => panic!(
24174 "Kimi context-only materialization rejected: exact_code={:?} message={}",
24175 error.code,
24176 error.message,
24177 ),
24178 };
24179 let diagnostic_overlay = diagnostic_overlay
24180 .expect("Kimi context-only materialization omitted its environment overlay");
24181 assert!(matches!(
24182 &diagnostic_overlay,
24183 PreparedNativeLaunchOverlay::Environment(_),
24184 ));
24185 match shared.install_prepared_launch_overlay(
24186 diagnostic_address.session.instance_id,
24187 diagnostic_overlay,
24188 ) {
24189 Ok(None) => {}
24190 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24191 Err(error) => panic!(
24192 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24193 error.code,
24194 error.message,
24195 ),
24196 }
24197 assert!(launch_control.clear_native_instance_launch_overlay(
24198 diagnostic_address.session.instance_id,
24199 ));
24200 drop(diagnostic_materialization);
24201 assert!(shared
24202 .materializations
24203 .lock()
24204 .unwrap_or_else(|poisoned| poisoned.into_inner())
24205 .is_empty());
24206
24207 let node_task = tokio::spawn(server.run());
24208 let ready_client = timeout(Duration::from_secs(5), async {
24209 loop {
24210 match gate4agent_node_wire::LocalNodeClient::connect(
24211 &endpoint,
24212 &shared.node_id,
24213 ClientRole::Observer,
24214 "fixture-token",
24215 )
24216 .await
24217 {
24218 Ok(client) => break client,
24219 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24220 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24221 }
24222 }
24223 })
24224 .await
24225 .expect("Kimi context-only Node did not become ready");
24226 assert_eq!(
24227 ready_client
24228 .hello()
24229 .snapshot
24230 .provider_runtime_statuses
24231 .iter()
24232 .find(|status| status.provider().as_str() == "kimi")
24233 .expect("Kimi runtime status is missing")
24234 .mode(),
24235 crate::protocol::ProviderRuntimeMode::RawPassthrough,
24236 );
24237 drop(ready_client);
24238
24239 let receipt = match shared.spawn_from_spec(spec).await {
24240 Ok(receipt) => receipt,
24241 Err(error) => panic!(
24242 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24243 error.code,
24244 error.message,
24245 ),
24246 };
24247 assert_eq!(receipt.provider, agent("kimi"));
24248 assert_eq!(receipt.context.as_ref(), Some(&context));
24249 assert!(receipt.context_binding_is_valid());
24250 timeout(Duration::from_secs(5), async {
24251 while !proof_path.is_file() {
24252 sleep(Duration::from_millis(10)).await;
24253 }
24254 })
24255 .await
24256 .expect("Kimi context-only fixture did not write its proof");
24257
24258 let proof = std::fs::read_to_string(&proof_path).unwrap();
24259 let lines = proof.lines().collect::<Vec<_>>();
24260 assert_eq!(lines.len(), 7);
24261 assert_eq!(lines[0], "context-only");
24262 assert!(Path::new(lines[1]).is_absolute());
24263 assert_ne!(
24264 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24265 std::fs::canonicalize(&workspace_root).unwrap(),
24266 );
24267 assert_eq!(
24268 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24269 std::fs::canonicalize(&workspace_root).unwrap(),
24270 );
24271 assert_eq!(lines[3], expected_context_hash);
24272 assert_eq!(lines[4], "g4a-context-pack-v1");
24273 assert_eq!(lines[5], "codex");
24274 assert_eq!(lines[6], "2");
24275 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24276 session.instance_id == receipt.session.session.instance_id
24277 && session.agent_id.as_str() == "kimi"
24278 }));
24279
24280 shutdown.request_shutdown().await.unwrap();
24281 timeout(Duration::from_secs(5), node_task)
24282 .await
24283 .unwrap()
24284 .unwrap()
24285 .unwrap();
24286 drop(shutdown);
24287 drop(shared);
24288 std::fs::remove_dir_all(root).unwrap();
24289 }
24290
24291 #[cfg(feature = "fixture")]
24292 #[test]
24293 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24294 let root = temporary_workspace_root("clean-exit-constructor");
24295 std::fs::create_dir_all(&root).unwrap();
24296 let config = construction_test_config("clean-exit-constructor")
24297 .with_state_path(root.join("node-state.json"))
24298 .unwrap();
24299 let server = NodeServer::new_clean_exit_fixture(
24300 config,
24301 root.clone(),
24302 root.join("started.marker"),
24303 root.join("release.signal"),
24304 )
24305 .unwrap();
24306 assert_eq!(
24307 server
24308 .shared
24309 .enabled_providers
24310 .iter()
24311 .map(AgentId::as_str)
24312 .collect::<Vec<_>>(),
24313 ["claude"],
24314 );
24315 drop(server);
24316 std::fs::remove_dir_all(root).unwrap();
24317 }
24318
24319 #[test]
24320 fn production_registry_provider_contract_manifest_is_exact() {
24321 let registry = active_registry().unwrap();
24322 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24323 assert_eq!(
24324 providers
24325 .iter()
24326 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24327 .collect::<Vec<_>>(),
24328 vec![
24329 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24330 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24331 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24332 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24333 ]
24334 );
24335 assert_eq!(
24336 adapters
24337 .iter()
24338 .map(|contract| (
24339 contract.provider.as_str(),
24340 contract.family,
24341 contract.adapter_id.as_str(),
24342 contract.revision.as_str(),
24343 ))
24344 .collect::<Vec<_>>(),
24345 vec![
24346 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24347 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24348 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24349 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24350 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24351 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24352 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24353 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24354 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24355 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24356 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24357 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24358 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24359 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24360 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24361 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24362 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24363 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24364 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24365 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24366 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24367 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24368 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24369 ]
24370 );
24371 }
24372
24373 fn construction_test_config(name: &str) -> NodeServerConfig {
24374 let workspace = WorkspaceConfig::new(
24375 WorkspaceId::new("primary").unwrap(),
24376 std::env::current_dir().unwrap(),
24377 )
24378 .unwrap();
24379 NodeServerConfig::new(
24380 format!(r"\\.\pipe\gate4agent-{name}"),
24381 "fixture-token",
24382 NodeId::new(name).unwrap(),
24383 [workspace],
24384 )
24385 .unwrap()
24386 }
24387
24388 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24389 let adapter = AdapterBinding::new(
24390 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24391 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24392 gate4agent_types::AdapterVerification::SyntheticFixture,
24393 )
24394 .unwrap();
24395 let agent_ids = ["claude", "codex", "kimi"]
24396 .into_iter()
24397 .map(|agent_id| AgentId::new(agent_id).unwrap())
24398 .collect::<Vec<_>>();
24399 let adapters = gate4agent_catalog::AdapterRegistry::new(
24400 [
24401 AdapterFamily::PtySemantic,
24402 AdapterFamily::Pipe,
24403 AdapterFamily::OneShot,
24404 AdapterFamily::Acp,
24405 AdapterFamily::Hook,
24406 AdapterFamily::ManagedHook,
24407 AdapterFamily::History,
24408 AdapterFamily::Resume,
24409 AdapterFamily::SessionOptions,
24410 AdapterFamily::CapabilityProbe,
24411 ]
24412 .into_iter()
24413 .map(|family| gate4agent_catalog::AdapterDescriptor {
24414 family,
24415 binding: adapter.clone(),
24416 agents: agent_ids.clone(),
24417 }),
24418 )
24419 .unwrap();
24420 let specs = agent_ids
24421 .iter()
24422 .map(|agent_id| {
24423 let agent_id = agent_id.as_str();
24424 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24425 spec.revision =
24426 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24427 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24428 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24429 adapter: adapter.clone(),
24430 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24431 launch_override: None,
24432 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24433 });
24434 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24435 adapter: adapter.clone(),
24436 launch_override: None,
24437 });
24438 spec.capabilities.adapters.hook = Some(adapter.clone());
24439 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24440 spec.capabilities.adapters.one_shot = Some(adapter.clone());
24441 spec.capabilities.adapters.history = Some(adapter.clone());
24442 spec.capabilities.adapters.resume = Some(adapter.clone());
24443 spec.capabilities.adapters.session_options = Some(adapter.clone());
24444 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24445 spec
24446 })
24447 .collect::<Vec<_>>();
24448 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24449 }
24450
24451 #[test]
24459 fn node_server_construction_accepts_the_production_manifest() {
24460 let registry = active_registry().unwrap();
24461 let (providers, _) = provider_contract_manifest(®istry).unwrap();
24462 assert_eq!(providers.len(), registry.iter().count());
24463 assert!(!providers.is_empty());
24464 let server = NodeServer::new_with_registry(
24465 construction_test_config("manifest-production-bound"),
24466 registry,
24467 )
24468 .unwrap();
24469 drop(server);
24470 }
24471
24472 #[test]
24473 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24474 let registry = maximum_size_provider_manifest_registry();
24475 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24476 assert_eq!(providers.len(), 3);
24477 assert_eq!(adapters.len(), 30);
24478 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24479 let error = match NodeServer::new_with_registry(
24480 construction_test_config("manifest-handshake-overflow"),
24481 registry,
24482 ) {
24483 Ok(_) => panic!("oversized negotiated manifest was accepted"),
24484 Err(error) => error,
24485 };
24486 assert!(matches!(
24487 error,
24488 NodeServerError::ProviderContractManifest(message)
24489 if message.contains("authentication binding length")
24490 && message.contains("8192-byte limit")
24491 ));
24492 }
24493
24494 #[test]
24495 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24496 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24497 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24498 let registry = AgentRegistry::new([spec]).unwrap();
24499 assert!(matches!(
24500 provider_contract_manifest(®istry),
24501 Err(NodeServerError::ProviderContractManifest(message))
24502 if message.contains("exceeds the 128-byte limit")
24503 ));
24504 }
24505
24506 #[test]
24507 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24508 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24509 spec.revision = "invalid revision".to_owned();
24510 let registry = AgentRegistry::new([spec]).unwrap();
24511 assert!(matches!(
24512 provider_contract_manifest(®istry),
24513 Err(NodeServerError::ProviderContractManifest(message))
24514 if message.contains("bounded lowercase ASCII")
24515 ));
24516 }
24517
24518 #[test]
24519 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24520 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24521 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24522 let conflicting_binding = builtin_registry()
24523 .get_by_id("codex")
24524 .unwrap()
24525 .capabilities
24526 .adapters
24527 .one_shot
24528 .clone()
24529 .unwrap();
24530 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24531 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24532 pipe.adapter = transport_binding;
24533 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24534 let registry = AgentRegistry::new([spec]).unwrap();
24535 assert!(matches!(
24536 provider_contract_manifest(®istry),
24537 Err(NodeServerError::ProviderContractManifest(message))
24538 if message.contains("conflicting OneShot adapter bindings")
24539 ));
24540 }
24541
24542 #[test]
24543 fn public_node_failures_expose_only_fixed_categories() {
24544 let provider = failure(
24545 NodeFailureCode::BackendOperationFailed,
24546 r"provider bearer-secret failed at C:\private\session.jsonl",
24547 );
24548 assert_eq!(provider.message, "backend-operation-failed");
24549 assert!(!provider.message.contains("bearer-secret"));
24550 assert!(!provider.message.contains(r"C:\private"));
24551 let persistence = persistence_failure(io::Error::new(
24552 io::ErrorKind::Other,
24553 r"state write failed at C:\private\state-v1.json",
24554 ));
24555 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24556 assert!(!persistence.message.contains(r"C:\private"));
24557
24558 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24559 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24560 assert_eq!(
24561 generic.to_string(),
24562 "node durable state failed: durable-state-load-failed",
24563 );
24564 }
24565 }
24566
24567 #[test]
24568 fn terminal_byte_sequences_are_bounded_before_dispatch() {
24569 let sequence = b"\x1b[1;5D".to_vec();
24570 assert_eq!(
24571 terminal_bytes_action(sequence.clone()).unwrap(),
24572 InputAction::TerminalBytes(sequence),
24573 );
24574 assert_eq!(
24575 terminal_bytes_action(Vec::new()).unwrap_err().code,
24576 NodeFailureCode::InvalidRequest,
24577 );
24578 assert_eq!(
24579 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24580 .unwrap_err()
24581 .code,
24582 NodeFailureCode::InvalidRequest,
24583 );
24584 }
24585
24586 #[test]
24587 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24588 let root = temporary_workspace_root("exclusive-state-owner");
24589 std::fs::create_dir_all(&root).unwrap();
24590 let workspace = WorkspaceConfig::new(
24591 WorkspaceId::new("primary").unwrap(),
24592 &root,
24593 )
24594 .unwrap();
24595 let state_path = root.join("state-v1.json");
24596 let endpoint = format!(
24597 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24598 std::process::id(),
24599 std::time::SystemTime::now()
24600 .duration_since(std::time::UNIX_EPOCH)
24601 .unwrap()
24602 .as_nanos(),
24603 );
24604 let config = NodeServerConfig::new(
24605 endpoint,
24606 "fixture-token",
24607 NodeId::new("node-1").unwrap(),
24608 [workspace],
24609 )
24610 .unwrap()
24611 .with_state_path(&state_path)
24612 .unwrap();
24613
24614 let first = NodeServer::new(config.clone()).unwrap();
24615 let error = match NodeServer::new(config.clone()) {
24616 Ok(_) => panic!("a second node server acquired the same durable state path"),
24617 Err(error) => error,
24618 };
24619 assert!(matches!(
24620 &error,
24621 NodeServerError::DurableState(error)
24622 if error.kind() == io::ErrorKind::WouldBlock
24623 ));
24624 assert_eq!(
24625 error.to_string(),
24626 "node durable state failed: durable-state-lock-failed",
24627 );
24628 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24629 drop(first);
24630 let second = NodeServer::new(config).unwrap();
24631 drop(second);
24632 std::fs::remove_dir_all(root).unwrap();
24633 }
24634
24635 #[test]
24636 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24637 let catalog = active_registry().unwrap();
24638 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24639 let workspace_id = WorkspaceId::new("primary").unwrap();
24640 let workspace = WorkspaceConfig::new(
24641 workspace_id.clone(),
24642 std::env::current_dir().unwrap(),
24643 )
24644 .unwrap();
24645 let shared = NodeShared::new(
24646 handle,
24647 "fixture-token".to_owned(),
24648 NodeId::new("node-1").unwrap(),
24649 vec![workspace.clone()],
24650 vec![agent("claude"), agent("codex"), agent("kimi")],
24651 );
24652 let instance_id = AgentInstanceId(17);
24653 let generation = SessionGeneration(3);
24654 assert!(shared.address_for(instance_id, generation).is_none());
24655 shared.bind_session(instance_id, workspace_id.clone(), generation);
24656 let address = SessionAddress {
24657 workspace_id,
24658 session: SessionKey { instance_id, generation },
24659 };
24660 assert_eq!(
24661 shared.workspace_root(&address.workspace_id).unwrap(),
24662 workspace.canonical_root,
24663 );
24664 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
24665 shared.remove_binding(&address);
24666 assert!(shared.address_for(instance_id, generation).is_none());
24667 }
24668
24669 #[test]
24670 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
24671 let address = SessionAddress {
24672 workspace_id: WorkspaceId::new("primary").unwrap(),
24673 session: SessionKey {
24674 instance_id: AgentInstanceId(7),
24675 generation: SessionGeneration(2),
24676 },
24677 };
24678 let record_id = SessionRecordId::new("record-a").unwrap();
24679 let size = gate4agent_types::TerminalSize {
24680 rows: 24,
24681 columns: 80,
24682 };
24683 let address_requests = [
24684 NodeRequest::Resume {
24685 session: address.clone(),
24686 terminal_size: size,
24687 initial_prompt: None,
24688 },
24689 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
24690 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
24691 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
24692 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
24693 NodeRequest::TerminalControl {
24694 session: address.clone(),
24695 control: TerminalControl::Enter,
24696 },
24697 NodeRequest::Resize { session: address.clone(), size },
24698 NodeRequest::Interrupt { session: address.clone() },
24699 NodeRequest::Stop { session: address.clone(), force: false },
24700 NodeRequest::Remove { session: address },
24701 ];
24702 for request in &address_requests {
24703 assert!(request_requires_open_provider_ids_with(
24704 request,
24705 |_| Some(agent("third-party-agent")),
24706 |_| Some(agent("claude")),
24707 ));
24708 assert!(!request_requires_open_provider_ids_with(
24709 request,
24710 |_| Some(agent("claude")),
24711 |_| Some(agent("claude")),
24712 ));
24713 assert!(request_requires_open_provider_ids_with(
24714 request,
24715 |_| None,
24716 |_| Some(agent("claude")),
24717 ));
24718 }
24719
24720 let record_requests = [
24721 NodeRequest::RenameSessionRecord {
24722 record_id: record_id.clone(),
24723 display_name: "renamed".to_owned(),
24724 },
24725 NodeRequest::ResumeSessionRecord {
24726 record_id: record_id.clone(),
24727 terminal_size: size,
24728 initial_prompt: None,
24729 },
24730 NodeRequest::ForgetSessionRecord { record_id },
24731 ];
24732 for request in &record_requests {
24733 assert!(request_requires_open_provider_ids_with(
24734 request,
24735 |_| Some(agent("claude")),
24736 |_| Some(agent("grok")),
24737 ));
24738 assert!(!request_requires_open_provider_ids_with(
24739 request,
24740 |_| Some(agent("claude")),
24741 |_| Some(agent("kimi")),
24742 ));
24743 assert!(request_requires_open_provider_ids_with(
24744 request,
24745 |_| Some(agent("claude")),
24746 |_| None,
24747 ));
24748 }
24749
24750 assert!(request_requires_open_provider_ids_with(
24751 &NodeRequest::Spawn {
24752 workspace_id: WorkspaceId::new("primary").unwrap(),
24753 provider: agent("third-party-agent"),
24754 mode: SessionMode::Pty,
24755 terminal_size: size,
24756 initial_prompt: None,
24757 },
24758 |_| None,
24759 |_| None,
24760 ));
24761 assert!(!request_requires_open_provider_ids_with(
24762 &NodeRequest::Snapshot,
24763 |_| None,
24764 |_| None,
24765 ));
24766 assert!(request_requires_open_provider_ids_with(
24767 &NodeRequest::ForgetContextPack {
24768 context_id: SpawnContextId::new("ctx-forget").unwrap(),
24769 },
24770 |_| None,
24771 |_| None,
24772 ));
24773 }
24774
24775 #[test]
24776 fn legacy_projection_filters_open_record_replies_and_event_history() {
24777 let catalog = active_registry().unwrap();
24778 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24779 let workspace = WorkspaceConfig::new(
24780 WorkspaceId::new("primary").unwrap(),
24781 std::env::current_dir().unwrap(),
24782 )
24783 .unwrap();
24784 let shared = NodeShared::new(
24785 handle,
24786 "fixture-token".to_owned(),
24787 NodeId::new("node-1").unwrap(),
24788 vec![workspace],
24789 vec![agent("claude"), agent("grok")],
24790 );
24791 let legacy = record("claude", "legacy-record");
24792 let open = record("grok", "open-record");
24793 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24794 record: legacy.clone(),
24795 });
24796 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24797 record: open.clone(),
24798 });
24799 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24800 record_id: legacy.record_id.clone(),
24801 });
24802 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24803 record_id: open.record_id.clone(),
24804 });
24805 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
24806 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
24807 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
24808 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
24809
24810 let mut record_reply = ResponseEnvelope {
24811 request_id: 1,
24812 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
24813 };
24814 project_response_legacy_provider_ids(&shared, &mut record_reply);
24815 assert!(matches!(
24816 record_reply.result,
24817 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
24818 ));
24819
24820 let mut resync = ResponseEnvelope {
24821 request_id: 2,
24822 result: Ok(NodeResponse::Resync {
24823 event_sequence: open_removed.sequence,
24824 oldest_available_sequence: 1,
24825 snapshot: shared.snapshot(),
24826 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
24827 }),
24828 };
24829 project_response_legacy_provider_ids(&shared, &mut resync);
24830 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
24831 panic!("legacy projection changed the resync response kind");
24832 };
24833 assert!(snapshot.session_records.iter().all(|item| {
24834 provider_id_is_legacy(&item.provider)
24835 }));
24836 assert_eq!(events.len(), 2);
24837 assert!(events.iter().all(|event| matches!(
24838 &event.event,
24839 NodeEvent::SessionRecordUpserted { record }
24840 if provider_id_is_legacy(&record.provider)
24841 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
24842 }
24843
24844 #[test]
24845 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
24846 let catalog = active_registry().unwrap();
24847 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24848 let workspace_id = WorkspaceId::new("primary").unwrap();
24849 let workspace = WorkspaceConfig::new(
24850 workspace_id.clone(),
24851 std::env::current_dir().unwrap(),
24852 )
24853 .unwrap();
24854 let shared = NodeShared::new(
24855 handle,
24856 "fixture-token".to_owned(),
24857 NodeId::new("node-1").unwrap(),
24858 vec![workspace],
24859 vec![agent("claude")],
24860 );
24861 for index in 0..CONTROL_SESSIONS_MAX {
24862 shared.bind_session(
24863 AgentInstanceId((index + 1) as u64),
24864 workspace_id.clone(),
24865 SessionGeneration(1),
24866 );
24867 }
24868 let error = shared.ensure_binding_capacity().unwrap_err();
24869 assert_eq!(error.code, NodeFailureCode::BackendBusy);
24870 }
24871
24872 #[tokio::test]
24873 async fn provider_spawn_admission_rejects_before_session_mutation() {
24874 let catalog = active_registry().unwrap();
24875 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24876 let workspace = WorkspaceConfig::new(
24877 WorkspaceId::new("primary").unwrap(),
24878 std::env::current_dir().unwrap(),
24879 )
24880 .unwrap();
24881 let mut shared = NodeShared::new(
24882 handle,
24883 "fixture-token".to_owned(),
24884 NodeId::new("node-provider-admission").unwrap(),
24885 vec![workspace],
24886 vec![agent("claude")],
24887 );
24888 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24889 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
24890 ])
24891 .unwrap();
24892 let terminal_size = gate4agent_types::TerminalSize {
24893 rows: 24,
24894 columns: 80,
24895 };
24896
24897 let unavailable = shared
24898 .spawn_session(
24899 WorkspaceId::new("primary").unwrap(),
24900 agent("claude"),
24901 SessionMode::Pty,
24902 terminal_size,
24903 None,
24904 )
24905 .await
24906 .unwrap_err();
24907 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
24908 assert_eq!(unavailable.message, "backend-operation-failed");
24909
24910 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24911 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
24912 agent("claude"),
24913 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
24914 ),
24915 ])
24916 .unwrap();
24917 let semantic = shared
24918 .spawn_session(
24919 WorkspaceId::new("primary").unwrap(),
24920 agent("claude"),
24921 SessionMode::Inline,
24922 terminal_size,
24923 None,
24924 )
24925 .await
24926 .unwrap_err();
24927 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
24928 assert_eq!(semantic.message, "unsupported-capability");
24929
24930 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
24931 assert!(shared
24932 .session_bindings
24933 .lock()
24934 .unwrap_or_else(|poisoned| poisoned.into_inner())
24935 .is_empty());
24936 assert!(shared
24937 .session_records
24938 .lock()
24939 .unwrap_or_else(|poisoned| poisoned.into_inner())
24940 .records
24941 .is_empty());
24942 assert!(shared.handle.snapshot().sessions.is_empty());
24943 }
24944
24945 #[tokio::test]
24946 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
24947 let catalog = active_registry().unwrap();
24948 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24949 let workspace_id = WorkspaceId::new("primary").unwrap();
24950 let workspace = WorkspaceConfig::new(
24951 workspace_id.clone(),
24952 std::env::current_dir().unwrap(),
24953 )
24954 .unwrap();
24955 let shared = NodeShared::new(
24956 handle,
24957 "fixture-token".to_owned(),
24958 NodeId::new("node-raw-spawn-record").unwrap(),
24959 vec![workspace],
24960 vec![agent("claude")],
24961 );
24962 let address = SessionAddress {
24963 workspace_id,
24964 session: SessionKey {
24965 instance_id: AgentInstanceId(71),
24966 generation: SessionGeneration::default(),
24967 },
24968 };
24969
24970 let record_id = shared
24971 .bind_spawn_session(
24972 &address,
24973 agent("claude"),
24974 SessionMode::Pty,
24975 ProviderRuntimePolicy::raw_pty(),
24976 SpawnRecordPolicy::Always,
24977 None,
24978 )
24979 .unwrap()
24980 .unwrap();
24981 shared
24982 .handle
24983 .dispatch(shared.prepare_command(ControlCommand::Register {
24984 instance_id: address.session.instance_id,
24985 agent_id: agent("claude"),
24986 transport: TransportKind::Pty,
24987 }))
24988 .unwrap();
24989 runtime.tick().await;
24990
24991 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24992 session.instance_id == address.session.instance_id
24993 && session.generation == address.session.generation
24994 }));
24995 let record = shared.record(&record_id).unwrap();
24996 assert_eq!(record.display_name, "claude #1");
24997 assert_eq!(record.state, ManagedSessionState::Live);
24998 assert_eq!(record.active_session.as_ref(), Some(&address));
24999 assert!(record.provider_session.is_none());
25000 let binding_record_id = shared
25001 .session_bindings
25002 .lock()
25003 .unwrap_or_else(|poisoned| poisoned.into_inner())
25004 .get(&address.session.instance_id)
25005 .and_then(|binding| binding.record_id.clone());
25006 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25007
25008 let renamed = shared
25009 .rename_session_record(&record_id, "raw review".to_owned())
25010 .unwrap();
25011 assert_eq!(renamed.record_id, record_id);
25012 assert_eq!(renamed.display_name, "raw review");
25013 shared.reconcile_managed_record(
25014 &record_id,
25015 &address,
25016 &ControlEvent {
25017 sequence: 1,
25018 command_id: None,
25019 instance_id: address.session.instance_id,
25020 generation: address.session.generation,
25021 event: ControlEventKind::Running { process_id: None },
25022 },
25023 false,
25024 false,
25025 );
25026 let running = shared.record(&record_id).unwrap();
25027 assert_eq!(running.state, ManagedSessionState::Live);
25028 assert_eq!(running.active_session.as_ref(), Some(&address));
25029 assert_eq!(running.display_name, "raw review");
25030
25031 shared.reconcile_managed_record(
25032 &record_id,
25033 &address,
25034 &ControlEvent {
25035 sequence: 2,
25036 command_id: None,
25037 instance_id: address.session.instance_id,
25038 generation: address.session.generation,
25039 event: ControlEventKind::Exited {
25040 exit_code: Some(0),
25041 forced: false,
25042 },
25043 },
25044 false,
25045 false,
25046 );
25047 let detached = shared.record(&record_id).unwrap();
25048 assert_eq!(detached.state, ManagedSessionState::Unavailable);
25049 assert!(detached.active_session.is_none());
25050 assert_eq!(detached.display_name, "raw review");
25051 let renamed_detached = shared
25052 .rename_session_record(&record_id, "raw review detached".to_owned())
25053 .unwrap();
25054 assert_eq!(renamed_detached.record_id, record_id);
25055 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25056 assert!(renamed_detached.active_session.is_none());
25057 assert_eq!(renamed_detached.display_name, "raw review detached");
25058 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25059 }
25060
25061 #[test]
25062 fn legacy_raw_spawn_binding_remains_untracked() {
25063 let catalog = active_registry().unwrap();
25064 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25065 let workspace_id = WorkspaceId::new("primary").unwrap();
25066 let workspace = WorkspaceConfig::new(
25067 workspace_id.clone(),
25068 std::env::current_dir().unwrap(),
25069 )
25070 .unwrap();
25071 let shared = NodeShared::new(
25072 handle,
25073 "fixture-token".to_owned(),
25074 NodeId::new("node-legacy-raw-spawn").unwrap(),
25075 vec![workspace],
25076 vec![agent("claude")],
25077 );
25078 let address = SessionAddress {
25079 workspace_id,
25080 session: SessionKey {
25081 instance_id: AgentInstanceId(72),
25082 generation: SessionGeneration::default(),
25083 },
25084 };
25085
25086 let record_id = shared
25087 .bind_spawn_session(
25088 &address,
25089 agent("claude"),
25090 SessionMode::Pty,
25091 ProviderRuntimePolicy::raw_pty(),
25092 SpawnRecordPolicy::ProviderIdentityOnly,
25093 None,
25094 )
25095 .unwrap();
25096
25097 assert!(record_id.is_none());
25098 let binding_record_id = shared
25099 .session_bindings
25100 .lock()
25101 .unwrap_or_else(|poisoned| poisoned.into_inner())
25102 .get(&address.session.instance_id)
25103 .and_then(|binding| binding.record_id.clone());
25104 assert!(binding_record_id.is_none());
25105 assert!(shared.snapshot().session_records.is_empty());
25106 }
25107
25108 #[tokio::test]
25109 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25110 let catalog = active_registry().unwrap();
25111 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25112 let workspace_id = WorkspaceId::new("primary").unwrap();
25113 let workspace = WorkspaceConfig::new(
25114 workspace_id.clone(),
25115 std::env::current_dir().unwrap(),
25116 )
25117 .unwrap();
25118 let mut shared = NodeShared::new(
25119 handle,
25120 "fixture-token".to_owned(),
25121 NodeId::new("node-raw-semantic-gate").unwrap(),
25122 vec![workspace],
25123 vec![agent("claude")],
25124 );
25125 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25126 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25127 ])
25128 .unwrap();
25129 shared
25130 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25131 .unwrap();
25132 let address = SessionAddress {
25133 workspace_id,
25134 session: SessionKey {
25135 instance_id: AgentInstanceId(72),
25136 generation: SessionGeneration::default(),
25137 },
25138 };
25139 shared.bind_session_with_policy(
25140 &address,
25141 ProviderRuntimePolicy::raw_pty(),
25142 None,
25143 );
25144 shared
25145 .handle
25146 .dispatch(shared.prepare_command(ControlCommand::Register {
25147 instance_id: address.session.instance_id,
25148 agent_id: agent("claude"),
25149 transport: TransportKind::Pty,
25150 }))
25151 .unwrap();
25152 runtime.tick().await;
25153 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25154 let revision_before = shared.handle.snapshot().revision;
25155 let history_sequence_before = shared.current_sequence();
25156 let terminal_size = gate4agent_types::TerminalSize {
25157 rows: 24,
25158 columns: 80,
25159 };
25160
25161 for request in [
25162 NodeRequest::Prompt {
25163 session: address.clone(),
25164 text: "prompt".to_owned(),
25165 },
25166 NodeRequest::Paste {
25167 session: address.clone(),
25168 text: "paste".to_owned(),
25169 },
25170 NodeRequest::Resume {
25176 session: address.clone(),
25177 terminal_size,
25178 initial_prompt: Some("resume".to_owned()),
25179 },
25180 ] {
25181 let error = process_request_inner(
25182 &shared,
25183 77,
25184 ClientRole::Operator,
25185 request,
25186 )
25187 .await
25188 .unwrap_err();
25189 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25190 }
25191
25192 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25193 assert_eq!(shared.handle.snapshot().revision, revision_before);
25194 assert_eq!(shared.current_sequence(), history_sequence_before);
25195 let bindings = shared
25196 .session_bindings
25197 .lock()
25198 .unwrap_or_else(|poisoned| poisoned.into_inner());
25199 let binding = bindings.get(&address.session.instance_id).unwrap();
25200 assert_eq!(binding.generation, address.session.generation);
25201 assert!(binding.pending_resume.is_none());
25202 assert!(binding.record_id.is_none());
25203 }
25204
25205 #[tokio::test]
25206 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25207 let catalog = active_registry().unwrap();
25208 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25209 let primary = WorkspaceConfig::new(
25210 WorkspaceId::new("primary").unwrap(),
25211 std::env::current_dir().unwrap(),
25212 )
25213 .unwrap();
25214 let shared = NodeShared::new(
25215 handle,
25216 "fixture-token".to_owned(),
25217 NodeId::new("node-1").unwrap(),
25218 vec![primary],
25219 vec![agent("claude")],
25220 );
25221 shared
25222 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25223 .unwrap();
25224 let before = shared.snapshot();
25225 let error = process_request_inner(
25226 &shared,
25227 77,
25228 ClientRole::Operator,
25229 NodeRequest::RegisterWorkspace {
25230 workspace_id: WorkspaceId::new("foreign").unwrap(),
25231 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25232 },
25233 )
25234 .await
25235 .unwrap_err();
25236 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25237 assert_eq!(error.message, "invalid-request");
25238 assert_eq!(shared.snapshot().workspaces, before.workspaces);
25239 }
25240
25241 #[tokio::test]
25242 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25243 let catalog = active_registry().unwrap();
25244 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25245 let primary_id = WorkspaceId::new("primary").unwrap();
25246 let primary = WorkspaceConfig::new(
25247 primary_id.clone(),
25248 std::env::current_dir().unwrap(),
25249 )
25250 .unwrap();
25251 let shared = NodeShared::new(
25252 handle,
25253 "fixture-token".to_owned(),
25254 NodeId::new("node-1").unwrap(),
25255 vec![primary.clone()],
25256 vec![agent("claude")],
25257 );
25258 let secondary_id = WorkspaceId::new("secondary").unwrap();
25259 let secondary = shared
25260 .register_workspace(
25261 secondary_id.clone(),
25262 std::env::temp_dir().to_string_lossy().into_owned(),
25263 )
25264 .await
25265 .unwrap();
25266 assert_eq!(secondary.workspace_id, secondary_id);
25267 assert!(secondary.sessions.is_empty());
25268 assert_eq!(shared.snapshot().workspaces.len(), 2);
25269
25270 let duplicate_id = shared
25271 .register_workspace(
25272 secondary_id.clone(),
25273 primary.canonical_root().to_owned(),
25274 )
25275 .await
25276 .unwrap_err();
25277 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25278 let duplicate_root = shared
25279 .register_workspace(
25280 WorkspaceId::new("duplicate-root").unwrap(),
25281 windows_path_text(&secondary.canonical_root).to_owned(),
25282 )
25283 .await
25284 .unwrap_err();
25285 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25286
25287 let address = SessionAddress {
25288 workspace_id: secondary_id.clone(),
25289 session: SessionKey {
25290 instance_id: AgentInstanceId(41),
25291 generation: SessionGeneration(1),
25292 },
25293 };
25294 shared.bind_session(
25295 address.session.instance_id,
25296 secondary_id.clone(),
25297 address.session.generation,
25298 );
25299 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25300 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25301 shared.remove_binding(&address);
25302 shared.unregister_workspace(&secondary_id).unwrap();
25303 assert_eq!(shared.snapshot().workspaces.len(), 1);
25304
25305 let last = shared.unregister_workspace(&primary_id).unwrap_err();
25306 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25307 let history = shared
25308 .history
25309 .lock()
25310 .unwrap_or_else(|poisoned| poisoned.into_inner());
25311 assert!(matches!(
25312 &history.events[0],
25313 NodeEventEnvelope {
25314 sequence: 1,
25315 event: NodeEvent::WorkspaceAdded { workspace },
25316 } if workspace.workspace_id == secondary_id
25317 ));
25318 assert!(matches!(
25319 &history.events[1],
25320 NodeEventEnvelope {
25321 sequence: 2,
25322 event: NodeEvent::WorkspaceRemoved { workspace_id },
25323 } if workspace_id == &secondary_id
25324 ));
25325 }
25326
25327 #[tokio::test]
25328 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25329 let secondary_root = temporary_workspace_root("provider-session-reference-index");
25330 std::fs::create_dir_all(&secondary_root).unwrap();
25331 let catalog = active_registry().unwrap();
25332 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25333 let primary_id = WorkspaceId::new("primary").unwrap();
25334 let secondary_id = WorkspaceId::new("secondary").unwrap();
25335 let shared = NodeShared::new(
25336 handle,
25337 "fixture-token".to_owned(),
25338 NodeId::new("node-session-reference-index").unwrap(),
25339 vec![
25340 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25341 .unwrap(),
25342 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25343 ],
25344 vec![agent("claude"), agent("codex")],
25345 );
25346 let identity = ProviderSessionIdentity {
25347 key: gate4agent_types::ProviderSessionKey::SessionId,
25348 id: "provider-session-42".to_owned(),
25349 transcript_path: None,
25350 };
25351 let index_request = |workspace_id: WorkspaceId,
25352 provider: AgentId,
25353 identity: ProviderSessionIdentity,
25354 display_name: &str| {
25355 NodeRequest::IndexProviderSession {
25356 workspace_id,
25357 provider,
25358 identity,
25359 display_name: display_name.to_owned(),
25360 }
25361 };
25362 let request = index_request(
25363 primary_id.clone(),
25364 agent("claude"),
25365 identity.clone(),
25366 "release shepherd",
25367 );
25368 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25369 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25370 }));
25371 let observer = process_request_inner(
25372 &shared,
25373 77,
25374 ClientRole::Observer,
25375 request.clone(),
25376 )
25377 .await
25378 .unwrap_err();
25379 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25380 let uncontrolled = process_request_inner(
25381 &shared,
25382 77,
25383 ClientRole::Operator,
25384 request.clone(),
25385 )
25386 .await
25387 .unwrap_err();
25388 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25389 shared
25390 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25391 .unwrap();
25392
25393 let indexed = process_request_inner(
25394 &shared,
25395 77,
25396 ClientRole::Operator,
25397 request.clone(),
25398 )
25399 .await
25400 .unwrap();
25401 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25402 panic!("provider session reference index returned another response");
25403 };
25404 assert_eq!(record.state, ManagedSessionState::Dormant);
25405 assert_eq!(record.mode, SessionMode::Pty);
25406 assert_eq!(record.workspace_id, primary_id);
25407 assert_eq!(record.provider_session.as_ref(), Some(&identity));
25408 assert!(record.active_session.is_none());
25409 assert!(record.environment_profile.is_none());
25410 assert!(record.bundle.is_none());
25411 assert!(record.context.is_none());
25412
25413 let repeated = process_request_inner(
25414 &shared,
25415 77,
25416 ClientRole::Operator,
25417 index_request(
25418 primary_id.clone(),
25419 agent("claude"),
25420 identity.clone(),
25421 "ignored replacement",
25422 ),
25423 )
25424 .await
25425 .unwrap();
25426 assert!(matches!(
25427 repeated,
25428 NodeResponse::ProviderSessionIndexed { record: ref existing }
25429 if existing.record_id == record.record_id
25430 && existing.display_name == "release shepherd"
25431 ));
25432 let conflicting_workspace = process_request_inner(
25433 &shared,
25434 77,
25435 ClientRole::Operator,
25436 index_request(
25437 secondary_id,
25438 agent("claude"),
25439 identity.clone(),
25440 "release shepherd",
25441 ),
25442 )
25443 .await
25444 .unwrap_err();
25445 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25446 let conflicting_provider = process_request_inner(
25447 &shared,
25448 77,
25449 ClientRole::Operator,
25450 index_request(
25451 primary_id.clone(),
25452 agent("codex"),
25453 identity.clone(),
25454 "release shepherd",
25455 ),
25456 )
25457 .await
25458 .unwrap_err();
25459 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25460 let transcript_rejected = process_request_inner(
25461 &shared,
25462 77,
25463 ClientRole::Operator,
25464 index_request(
25465 primary_id,
25466 agent("claude"),
25467 ProviderSessionIdentity {
25468 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25469 ..identity
25470 },
25471 "release shepherd",
25472 ),
25473 )
25474 .await
25475 .unwrap_err();
25476 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25477 let upserts = shared
25478 .history
25479 .lock()
25480 .unwrap_or_else(|poisoned| poisoned.into_inner())
25481 .events
25482 .iter()
25483 .filter(|event| matches!(
25484 &event.event,
25485 NodeEvent::SessionRecordUpserted { record: published }
25486 if published.record_id == record.record_id
25487 ))
25488 .count();
25489 assert_eq!(upserts, 1);
25490 std::fs::remove_dir_all(secondary_root).unwrap();
25491 }
25492
25493 #[tokio::test]
25494 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25495 let fixture_root = temporary_workspace_root("standalone-independent");
25496 let target = fixture_root.join("independent");
25497 std::fs::create_dir_all(&fixture_root).unwrap();
25498 let catalog = active_registry().unwrap();
25499 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25500 let selected = WorkspaceConfig::new(
25501 WorkspaceId::new("primary").unwrap(),
25502 std::env::current_dir().unwrap(),
25503 )
25504 .unwrap();
25505 let selected_root = selected.canonical_root().to_owned();
25506 let shared = NodeShared::new(
25507 handle,
25508 "fixture-token".to_owned(),
25509 NodeId::new("node-standalone").unwrap(),
25510 vec![selected],
25511 vec![agent("claude")],
25512 );
25513 let workspace_id = WorkspaceId::new("independent").unwrap();
25514 let request = NodeRequest::CreateStandaloneWorkspace {
25515 workspace_id: workspace_id.clone(),
25516 root: opaque_windows_path(target.to_string_lossy().into_owned()),
25517 initial_branch: Some("main".to_owned()),
25518 };
25519 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25520 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25521 }));
25522 assert!(!request_uses_unnegotiated_capability(
25523 &request,
25524 &baseline_capabilities().unwrap(),
25525 ));
25526 assert!(request_uses_unnegotiated_capability(&request, &[]));
25527
25528 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25529 .await
25530 .unwrap_err();
25531 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25532 assert!(!target.exists());
25533 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25534 .await
25535 .unwrap_err();
25536 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25537 assert!(!target.exists());
25538 shared
25539 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25540 .unwrap();
25541
25542 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25543 .await
25544 .unwrap();
25545 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25546 panic!("standalone workspace creation returned another response");
25547 };
25548 assert_eq!(workspace.workspace_id, workspace_id);
25549 assert!(target.join(".git").is_dir());
25550 let branch = run_git_bounded(
25551 target.to_str().unwrap(),
25552 &["branch", "--show-current"],
25553 GIT_OUTPUT_MAX_BYTES,
25554 )
25555 .await
25556 .unwrap();
25557 assert!(branch.success);
25558 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25559 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25560 assert_eq!(worktrees.len(), 1);
25561 let listed_root = WorkspaceConfig::new(
25562 WorkspaceId::new("listed-independent").unwrap(),
25563 &worktrees[0].path,
25564 )
25565 .unwrap();
25566 assert!(platform::roots_equal(
25567 listed_root.canonical_root(),
25568 windows_path_text(&workspace.canonical_root),
25569 ));
25570 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25571 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25572 snapshot.workspace_id == workspace_id
25573 && snapshot.canonical_root == workspace.canonical_root
25574 }));
25575 assert!(shared
25576 .history
25577 .lock()
25578 .unwrap_or_else(|poisoned| poisoned.into_inner())
25579 .events
25580 .iter()
25581 .any(|event| matches!(
25582 &event.event,
25583 NodeEvent::WorkspaceAdded { workspace }
25584 if workspace.workspace_id == workspace_id
25585 )));
25586 std::fs::remove_dir_all(fixture_root).unwrap();
25587 }
25588
25589 #[tokio::test]
25590 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25591 let fixture_root = temporary_workspace_root("standalone-compensation");
25592 let created_target = fixture_root.join("created-target");
25593 let existing_target = fixture_root.join("existing-target");
25594 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25595 let invalid_state_path = invalid_state_parent.join("state.json");
25596 std::fs::create_dir_all(&existing_target).unwrap();
25597 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25598 let catalog = active_registry().unwrap();
25599 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25600 let primary = WorkspaceConfig::new(
25601 WorkspaceId::new("primary").unwrap(),
25602 std::env::current_dir().unwrap(),
25603 )
25604 .unwrap();
25605 let mut shared = NodeShared::new(
25606 handle,
25607 "fixture-token".to_owned(),
25608 NodeId::new("node-standalone-compensation").unwrap(),
25609 vec![primary],
25610 vec![agent("claude")],
25611 );
25612 shared.state_path = Some(invalid_state_path);
25613
25614 let created_recovery = shared
25615 .create_standalone_workspace(
25616 WorkspaceId::new("compensated").unwrap(),
25617 created_target.to_string_lossy().into_owned(),
25618 Some("main".to_owned()),
25619 )
25620 .await
25621 .unwrap_err();
25622 assert_eq!(
25623 created_recovery.code,
25624 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25625 );
25626 assert!(created_target.join(".git").is_dir());
25627 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25628 workspace.workspace_id.as_str() != "compensated"
25629 }));
25630
25631 let existing_recovery = shared
25632 .create_standalone_workspace(
25633 WorkspaceId::new("restored").unwrap(),
25634 existing_target.to_string_lossy().into_owned(),
25635 None,
25636 )
25637 .await
25638 .unwrap_err();
25639 assert_eq!(
25640 existing_recovery.code,
25641 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25642 );
25643 assert!(existing_target.is_dir());
25644 assert!(existing_target.join(".git").is_dir());
25645 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25646 workspace.workspace_id.as_str() != "restored"
25647 }));
25648
25649 let registered_empty = fixture_root.join("registered-empty");
25650 std::fs::create_dir(®istered_empty).unwrap();
25651 let catalog = active_registry().unwrap();
25652 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25653 let registered = WorkspaceConfig::new(
25654 WorkspaceId::new("registered-empty").unwrap(),
25655 ®istered_empty,
25656 )
25657 .unwrap();
25658 let duplicate_shared = NodeShared::new(
25659 handle,
25660 "fixture-token".to_owned(),
25661 NodeId::new("node-standalone-duplicate-root").unwrap(),
25662 vec![registered],
25663 vec![agent("claude")],
25664 );
25665 let duplicate = duplicate_shared
25666 .create_standalone_workspace(
25667 WorkspaceId::new("duplicate-root").unwrap(),
25668 registered_empty.to_string_lossy().into_owned(),
25669 None,
25670 )
25671 .await
25672 .unwrap_err();
25673 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
25674 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
25675 std::fs::remove_dir_all(fixture_root).unwrap();
25676 }
25677
25678 #[test]
25679 fn managed_record_id_collision_preserves_the_existing_record() {
25680 let catalog = active_registry().unwrap();
25681 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25682 let workspace = WorkspaceConfig::new(
25683 WorkspaceId::new("primary").unwrap(),
25684 std::env::current_dir().unwrap(),
25685 )
25686 .unwrap();
25687 let shared = NodeShared::new(
25688 handle,
25689 "fixture-token".to_owned(),
25690 NodeId::new("node-1").unwrap(),
25691 vec![workspace.clone()],
25692 vec![agent("claude")],
25693 );
25694 let record_id = SessionRecordId::new("sr-collision").unwrap();
25695 let original = ManagedSessionRecord {
25696 record_id: record_id.clone(),
25697 display_name: "original".to_owned(),
25698 provider: agent("claude"),
25699 mode: SessionMode::Pty,
25700 state: ManagedSessionState::Dormant,
25701 workspace_id: workspace.workspace_id,
25702 canonical_root: opaque_windows_path(workspace.canonical_root),
25703 provider_session: None,
25704 active_session: None,
25705 environment_profile: None,
25706 bundle: None,
25707 context_id: None,
25708 context: None,
25709 exported_context: None,
25710 task_binding: None,
25711 created_at_unix_ms: 1,
25712 updated_at_unix_ms: 1,
25713 last_error: None,
25714 };
25715 shared.insert_record(original.clone()).unwrap();
25716 let mut collision = original.clone();
25717 collision.display_name = "replacement".to_owned();
25718 let error = shared.insert_record(collision).unwrap_err();
25719 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
25720 assert_eq!(shared.record(&record_id).unwrap(), original);
25721 }
25722
25723 #[test]
25724 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
25725 let catalog = active_registry().unwrap();
25726 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25727 let workspace = WorkspaceConfig::new(
25728 WorkspaceId::new("primary").unwrap(),
25729 std::env::current_dir().unwrap(),
25730 )
25731 .unwrap();
25732 let shared = NodeShared::new(
25733 handle,
25734 "fixture-token".to_owned(),
25735 NodeId::new("node-1").unwrap(),
25736 vec![workspace.clone()],
25737 vec![agent("claude")],
25738 );
25739 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
25740 shared
25741 .insert_record(ManagedSessionRecord {
25742 record_id: record_id.clone(),
25743 display_name: "runtime error".to_owned(),
25744 provider: agent("claude"),
25745 mode: SessionMode::Pty,
25746 state: ManagedSessionState::IdentityPending,
25747 workspace_id: workspace.workspace_id,
25748 canonical_root: opaque_windows_path(workspace.canonical_root),
25749 provider_session: None,
25750 active_session: None,
25751 environment_profile: None,
25752 bundle: None,
25753 context_id: None,
25754 context: None,
25755 exported_context: None,
25756 task_binding: None,
25757 created_at_unix_ms: 1,
25758 updated_at_unix_ms: 1,
25759 last_error: None,
25760 })
25761 .unwrap();
25762
25763 shared
25764 .mark_record_error(
25765 &record_id,
25766 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
25767 )
25768 .unwrap();
25769 let record = shared.record(&record_id).unwrap();
25770 assert_eq!(record.state, ManagedSessionState::Unavailable);
25771 assert_eq!(
25772 record.last_error.as_deref(),
25773 Some("provider-runtime-failed"),
25774 );
25775 let snapshot = shared.snapshot();
25776 assert_eq!(snapshot.session_records, vec![record]);
25777 }
25778
25779 #[test]
25788 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
25789 let catalog = active_registry().unwrap();
25790 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25791 let workspace = WorkspaceConfig::new(
25792 WorkspaceId::new("primary").unwrap(),
25793 std::env::current_dir().unwrap(),
25794 )
25795 .unwrap();
25796 let shared = NodeShared::new(
25797 handle,
25798 "fixture-token".to_owned(),
25799 NodeId::new("node-1").unwrap(),
25800 vec![workspace],
25801 vec![agent("claude")],
25802 );
25803
25804 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25805
25806 shared.record_rejected_command(
25807 CommandId(1),
25808 "agent 'claude' does not support transport Acp".to_owned(),
25809 true,
25810 );
25811 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
25813
25814 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
25815 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
25816 assert!(reason.unsupported_transport);
25817
25818 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25820
25821 shared.record_rejected_command(
25826 CommandId(3),
25827 "backend busy".to_owned(),
25828 false,
25829 );
25830 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
25831 assert!(!unclassified.unsupported_transport);
25832 }
25833
25834 #[test]
25835 fn provider_identity_update_is_ignored_without_exact_session_policy() {
25836 let catalog = active_registry().unwrap();
25837 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25838 let workspace = WorkspaceConfig::new(
25839 WorkspaceId::new("primary").unwrap(),
25840 std::env::current_dir().unwrap(),
25841 )
25842 .unwrap();
25843 let shared = NodeShared::new(
25844 handle,
25845 "fixture-token".to_owned(),
25846 NodeId::new("node-identity-policy").unwrap(),
25847 vec![workspace.clone()],
25848 vec![agent("claude")],
25849 );
25850 let address = SessionAddress {
25851 workspace_id: workspace.workspace_id.clone(),
25852 session: SessionKey {
25853 instance_id: AgentInstanceId(90),
25854 generation: SessionGeneration(1),
25855 },
25856 };
25857 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
25858 shared
25859 .insert_record(ManagedSessionRecord {
25860 record_id: record_id.clone(),
25861 display_name: "policy denied".to_owned(),
25862 provider: agent("claude"),
25863 mode: SessionMode::Pty,
25864 state: ManagedSessionState::IdentityPending,
25865 workspace_id: workspace.workspace_id,
25866 canonical_root: opaque_windows_path(workspace.canonical_root),
25867 provider_session: None,
25868 active_session: Some(address.clone()),
25869 environment_profile: None,
25870 bundle: None,
25871 context_id: None,
25872 context: None,
25873 exported_context: None,
25874 task_binding: None,
25875 created_at_unix_ms: 1,
25876 updated_at_unix_ms: 1,
25877 last_error: None,
25878 })
25879 .unwrap();
25880 let event = ControlEvent {
25881 sequence: 1,
25882 command_id: None,
25883 instance_id: address.session.instance_id,
25884 generation: address.session.generation,
25885 event: ControlEventKind::ProviderEvent {
25886 sequence: 1,
25887 source: gate4agent_types::ProviderSource {
25888 family: AdapterFamily::PtySemantic,
25889 binding: AdapterBinding::new(
25890 gate4agent_types::AdapterId::new("claude").unwrap(),
25891 "fixture/v1",
25892 gate4agent_types::AdapterVerification::SyntheticFixture,
25893 )
25894 .unwrap(),
25895 },
25896 source_sequence: 1,
25897 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25898 identity: gate4agent_types::ProviderSessionIdentity {
25899 key: gate4agent_types::ProviderSessionKey::SessionId,
25900 id: "provider-session".to_owned(),
25901 transcript_path: None,
25902 },
25903 },
25904 },
25905 };
25906
25907 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
25908
25909 let record = shared.record(&record_id).unwrap();
25910 assert!(record.provider_session.is_none());
25911 assert_eq!(record.state, ManagedSessionState::IdentityPending);
25912 assert_eq!(record.active_session.as_ref(), Some(&address));
25913 }
25914
25915 #[test]
25916 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
25917 let secondary_root = temporary_workspace_root("identity-scope");
25918 std::fs::create_dir_all(&secondary_root).unwrap();
25919 let catalog = active_registry().unwrap();
25920 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25921 let primary = WorkspaceConfig::new(
25922 WorkspaceId::new("primary").unwrap(),
25923 std::env::current_dir().unwrap(),
25924 )
25925 .unwrap();
25926 let secondary = WorkspaceConfig::new(
25927 WorkspaceId::new("secondary").unwrap(),
25928 &secondary_root,
25929 )
25930 .unwrap();
25931 let shared = NodeShared::new(
25932 handle,
25933 "fixture-token".to_owned(),
25934 NodeId::new("node-1").unwrap(),
25935 vec![primary.clone(), secondary.clone()],
25936 vec![agent("claude")],
25937 );
25938 let identity = gate4agent_types::ProviderSessionIdentity {
25939 key: gate4agent_types::ProviderSessionKey::SessionId,
25940 id: "provider-session".to_owned(),
25941 transcript_path: None,
25942 };
25943 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
25944 shared
25945 .insert_record(ManagedSessionRecord {
25946 record_id: dormant_id.clone(),
25947 display_name: "dormant".to_owned(),
25948 provider: agent("claude"),
25949 mode: SessionMode::Pty,
25950 state: ManagedSessionState::Dormant,
25951 workspace_id: primary.workspace_id,
25952 canonical_root: opaque_windows_path(primary.canonical_root),
25953 provider_session: Some(identity.clone()),
25954 active_session: None,
25955 environment_profile: None,
25956 bundle: None,
25957 context_id: None,
25958 context: None,
25959 exported_context: None,
25960 task_binding: None,
25961 created_at_unix_ms: 1,
25962 updated_at_unix_ms: 1,
25963 last_error: None,
25964 })
25965 .unwrap();
25966 let address = SessionAddress {
25967 workspace_id: secondary.workspace_id.clone(),
25968 session: SessionKey {
25969 instance_id: AgentInstanceId(91),
25970 generation: SessionGeneration(1),
25971 },
25972 };
25973 let current_id = SessionRecordId::new("sr-current").unwrap();
25974 shared
25975 .insert_record(ManagedSessionRecord {
25976 record_id: current_id.clone(),
25977 display_name: "current".to_owned(),
25978 provider: agent("claude"),
25979 mode: SessionMode::Pty,
25980 state: ManagedSessionState::IdentityPending,
25981 workspace_id: secondary.workspace_id,
25982 canonical_root: opaque_windows_path(secondary.canonical_root),
25983 provider_session: None,
25984 active_session: Some(address.clone()),
25985 environment_profile: None,
25986 bundle: None,
25987 context_id: None,
25988 context: None,
25989 exported_context: None,
25990 task_binding: None,
25991 created_at_unix_ms: 2,
25992 updated_at_unix_ms: 2,
25993 last_error: None,
25994 })
25995 .unwrap();
25996 shared.bind_managed_session(
25997 &address,
25998 current_id.clone(),
25999 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26000 None,
26001 );
26002 let event = ControlEvent {
26003 sequence: 1,
26004 command_id: None,
26005 instance_id: address.session.instance_id,
26006 generation: address.session.generation,
26007 event: ControlEventKind::ProviderEvent {
26008 sequence: 1,
26009 source: gate4agent_types::ProviderSource {
26010 family: gate4agent_types::AdapterFamily::PtySemantic,
26011 binding: gate4agent_types::AdapterBinding::new(
26012 gate4agent_types::AdapterId::new("claude").unwrap(),
26013 "fixture/v1",
26014 gate4agent_types::AdapterVerification::SyntheticFixture,
26015 )
26016 .unwrap(),
26017 },
26018 source_sequence: 1,
26019 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26020 identity,
26021 },
26022 },
26023 };
26024 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
26025
26026 let binding = shared
26027 .session_bindings
26028 .lock()
26029 .unwrap_or_else(|poisoned| poisoned.into_inner())
26030 .get(&address.session.instance_id)
26031 .cloned()
26032 .unwrap();
26033 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
26034 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26035 let current = shared.record(¤t_id).unwrap();
26036 assert_eq!(current.state, ManagedSessionState::Unavailable);
26037 assert_eq!(current.active_session.as_ref(), Some(&address));
26038 assert_eq!(
26039 current.last_error.as_deref(),
26040 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26041 );
26042 std::fs::remove_dir_all(secondary_root).unwrap();
26043 }
26044
26045 #[tokio::test]
26046 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26047 let root = temporary_workspace_root("semantic-provider-session");
26048 std::fs::create_dir_all(&root).unwrap();
26049 let state_path = root.join("state-v1.json");
26050 let catalog = active_registry().unwrap();
26051 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26052 let workspace = WorkspaceConfig::new(
26053 WorkspaceId::new("primary").unwrap(),
26054 &root,
26055 )
26056 .unwrap();
26057 let node_id = NodeId::new("node-semantic-identity").unwrap();
26058 let shared = NodeShared::new_with_incarnation(
26059 handle,
26060 "fixture-token".to_owned(),
26061 node_id.clone(),
26062 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26063 vec![workspace.clone()],
26064 vec![agent("claude")],
26065 ProviderRuntimeStatuses::default(),
26066 None,
26067 Vec::new(),
26068 Vec::new(),
26069 SpawnProfileRegistry::default(),
26070 None,
26071 None,
26072 None,
26073 Some(state_path.clone()),
26074 Vec::new(),
26075 Vec::new(),
26076 Vec::new(),
26077 Vec::new(),
26078 None,
26079 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26080 );
26081 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26082 key: gate4agent_types::ProviderSessionKey::SessionId,
26083 id: "provider-session".to_owned(),
26084 transcript_path: None,
26085 };
26086 let durable_id = SessionRecordId::new("sr-durable").unwrap();
26087 shared
26088 .insert_record(ManagedSessionRecord {
26089 record_id: durable_id.clone(),
26090 display_name: "durable".to_owned(),
26091 provider: agent("claude"),
26092 mode: SessionMode::Pty,
26093 state: ManagedSessionState::Dormant,
26094 workspace_id: workspace.workspace_id.clone(),
26095 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26096 provider_session: Some(persisted_identity.clone()),
26097 active_session: None,
26098 environment_profile: None,
26099 bundle: None,
26100 context_id: None,
26101 context: None,
26102 exported_context: None,
26103 task_binding: None,
26104 created_at_unix_ms: 1,
26105 updated_at_unix_ms: 1,
26106 last_error: None,
26107 })
26108 .unwrap();
26109 let address = SessionAddress {
26110 workspace_id: workspace.workspace_id.clone(),
26111 session: SessionKey {
26112 instance_id: AgentInstanceId(92),
26113 generation: SessionGeneration(2),
26114 },
26115 };
26116 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26117 shared
26118 .insert_record(ManagedSessionRecord {
26119 record_id: pending_id.clone(),
26120 display_name: "pending".to_owned(),
26121 provider: agent("claude"),
26122 mode: SessionMode::Pty,
26123 state: ManagedSessionState::IdentityPending,
26124 workspace_id: workspace.workspace_id.clone(),
26125 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26126 provider_session: None,
26127 active_session: Some(address.clone()),
26128 environment_profile: None,
26129 bundle: None,
26130 context_id: None,
26131 context: None,
26132 exported_context: None,
26133 task_binding: None,
26134 created_at_unix_ms: 2,
26135 updated_at_unix_ms: 2,
26136 last_error: None,
26137 })
26138 .unwrap();
26139 shared.bind_managed_session(
26140 &address,
26141 pending_id.clone(),
26142 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26143 None,
26144 );
26145 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26146 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26147 ..persisted_identity
26148 };
26149 let event = ControlEvent {
26150 sequence: 1,
26151 command_id: None,
26152 instance_id: address.session.instance_id,
26153 generation: address.session.generation,
26154 event: ControlEventKind::ProviderEvent {
26155 sequence: 1,
26156 source: gate4agent_types::ProviderSource {
26157 family: gate4agent_types::AdapterFamily::PtySemantic,
26158 binding: gate4agent_types::AdapterBinding::new(
26159 gate4agent_types::AdapterId::new("claude").unwrap(),
26160 "fixture/v1",
26161 gate4agent_types::AdapterVerification::SyntheticFixture,
26162 )
26163 .unwrap(),
26164 },
26165 source_sequence: 1,
26166 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26167 identity: observed_identity.clone(),
26168 },
26169 },
26170 };
26171 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26172
26173 let snapshot = shared.snapshot();
26174 assert_eq!(snapshot.session_records.len(), 1);
26175 let live = &snapshot.session_records[0];
26176 assert_eq!(live.record_id, durable_id);
26177 assert_eq!(live.state, ManagedSessionState::Live);
26178 assert_eq!(live.active_session.as_ref(), Some(&address));
26179 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26180 let binding = shared
26181 .session_bindings
26182 .lock()
26183 .unwrap_or_else(|poisoned| poisoned.into_inner())
26184 .get(&address.session.instance_id)
26185 .cloned()
26186 .unwrap();
26187 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26188
26189 let mut duplicate = live.clone();
26190 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26191 duplicate.display_name = "duplicate".to_owned();
26192 duplicate.state = ManagedSessionState::Dormant;
26193 duplicate.active_session = None;
26194 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26195 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26196 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26197 assert_eq!(shared.snapshot().session_records.len(), 1);
26198
26199 let terminal_size = gate4agent_types::TerminalSize {
26200 rows: 24,
26201 columns: 80,
26202 };
26203 let (first_busy, second_busy) = tokio::join!(
26204 shared.resume_session_record(&durable_id, terminal_size, None),
26205 shared.resume_session_record(&durable_id, terminal_size, None),
26206 );
26207 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26208 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26209 assert_eq!(busy.message, "session-record-busy");
26210 }
26211
26212 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26213 assert_eq!(loaded.records.len(), 1);
26214 assert_eq!(loaded.records[0].record_id, durable_id);
26215 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26216 assert_eq!(
26217 loaded.records[0]
26218 .provider_session
26219 .as_ref()
26220 .unwrap()
26221 .transcript_path,
26222 None,
26223 );
26224 let catalog = active_registry().unwrap();
26225 let (reloaded_handle, _reloaded_runtime) =
26226 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26227 let reloaded = NodeShared::new_with_incarnation(
26228 reloaded_handle,
26229 "fixture-token".to_owned(),
26230 node_id,
26231 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26232 vec![workspace],
26233 vec![agent("claude")],
26234 ProviderRuntimeStatuses::default(),
26235 None,
26236 Vec::new(),
26237 Vec::new(),
26238 SpawnProfileRegistry::default(),
26239 None,
26240 None,
26241 None,
26242 None,
26243 loaded.records,
26244 loaded.managed_worktrees,
26245 loaded.managed_worktree_tombstones,
26246 loaded.materializations,
26247 loaded.warning,
26248 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26249 );
26250 assert_eq!(reloaded.snapshot().session_records.len(), 1);
26251 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26252 std::fs::remove_dir_all(root).unwrap();
26253 }
26254
26255 #[tokio::test]
26256 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26257 let secondary_root = temporary_workspace_root("workspace-restore");
26258 let other_root = temporary_workspace_root("workspace-conflict");
26259 std::fs::create_dir_all(&secondary_root).unwrap();
26260 std::fs::create_dir_all(&other_root).unwrap();
26261 let catalog = active_registry().unwrap();
26262 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26263 let primary = WorkspaceConfig::new(
26264 WorkspaceId::new("primary").unwrap(),
26265 std::env::current_dir().unwrap(),
26266 )
26267 .unwrap();
26268 let secondary = WorkspaceConfig::new(
26269 WorkspaceId::new("secondary").unwrap(),
26270 &secondary_root,
26271 )
26272 .unwrap();
26273 let shared = NodeShared::new(
26274 handle,
26275 "fixture-token".to_owned(),
26276 NodeId::new("node-1").unwrap(),
26277 vec![primary, secondary.clone()],
26278 vec![agent("claude")],
26279 );
26280 let record_id = SessionRecordId::new("sr-workspace").unwrap();
26281 shared
26282 .insert_record(ManagedSessionRecord {
26283 record_id: record_id.clone(),
26284 display_name: "workspace record".to_owned(),
26285 provider: agent("claude"),
26286 mode: SessionMode::Pty,
26287 state: ManagedSessionState::Dormant,
26288 workspace_id: secondary.workspace_id.clone(),
26289 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26290 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26291 key: gate4agent_types::ProviderSessionKey::SessionId,
26292 id: "provider-session".to_owned(),
26293 transcript_path: None,
26294 }),
26295 active_session: None,
26296 environment_profile: None,
26297 bundle: None,
26298 context_id: None,
26299 context: None,
26300 exported_context: None,
26301 task_binding: None,
26302 created_at_unix_ms: 1,
26303 updated_at_unix_ms: 1,
26304 last_error: None,
26305 })
26306 .unwrap();
26307 shared.unregister_workspace(&secondary.workspace_id).unwrap();
26308 let unavailable = shared.record(&record_id).unwrap();
26309 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26310 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26311
26312 let conflict = shared
26313 .register_workspace(
26314 secondary.workspace_id.clone(),
26315 other_root.to_string_lossy().into_owned(),
26316 )
26317 .await
26318 .unwrap_err();
26319 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26320 shared
26321 .register_workspace(
26322 secondary.workspace_id,
26323 secondary_root.to_string_lossy().into_owned(),
26324 )
26325 .await
26326 .unwrap();
26327 let restored = shared.record(&record_id).unwrap();
26328 assert_eq!(restored.state, ManagedSessionState::Dormant);
26329 assert!(restored.last_error.is_none());
26330 std::fs::remove_dir_all(secondary_root).unwrap();
26331 std::fs::remove_dir_all(other_root).unwrap();
26332 }
26333
26334 #[test]
26335 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26336 let error = validate_workspace_request_root(
26337 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26338 )
26339 .unwrap_err();
26340 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26341 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26342 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26343 }
26344
26345 #[test]
26346 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26347 let catalog = active_registry().unwrap();
26348 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26349 let workspace = WorkspaceConfig::new(
26350 WorkspaceId::new("primary").unwrap(),
26351 std::env::current_dir().unwrap(),
26352 )
26353 .unwrap();
26354 let shared = NodeShared::new(
26355 handle,
26356 "fixture-token".to_owned(),
26357 NodeId::new("node-1").unwrap(),
26358 vec![workspace],
26359 vec![agent("claude")],
26360 );
26361 let publishers = 4_u64;
26362 let events_per_publisher = 256_u64;
26363 let start = std::sync::Barrier::new(publishers as usize + 1);
26364 let finished = AtomicU64::new(0);
26365
26366 std::thread::scope(|scope| {
26367 for _ in 0..publishers {
26368 scope.spawn(|| {
26369 start.wait();
26370 for _ in 0..events_per_publisher {
26371 shared.publish(NodeEvent::ControllerChanged { controller: None });
26372 }
26373 finished.fetch_add(1, Ordering::Release);
26374 });
26375 }
26376 start.wait();
26377 while finished.load(Ordering::Acquire) != publishers {
26378 assert_resync_history_is_linearized(shared.resync(0));
26379 std::thread::yield_now();
26380 }
26381 });
26382 let response = shared.resync(0);
26383 assert_resync_history_is_linearized(response.clone());
26384 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26385 unreachable!("resync helper returned another response");
26386 };
26387 assert_eq!(event_sequence, publishers * events_per_publisher);
26388 assert_eq!(events.len() as u64, event_sequence);
26389 }
26390
26391 fn assert_resync_history_is_linearized(response: NodeResponse) {
26392 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26393 unreachable!("resync helper returned another response");
26394 };
26395 assert!(events.windows(2).all(|pair| {
26396 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26397 }));
26398 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26399 }
26400
26401 #[test]
26402 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26403 assert_eq!(
26404 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26405 r"C:\repo\worktree",
26406 );
26407 assert_eq!(
26408 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26409 r"\\server\share\repo",
26410 );
26411 assert!(validate_workspace_root(
26412 &WorkspaceId::new("network").unwrap(),
26413 r"\\server\share\repo",
26414 )
26415 .is_err());
26416 let workspace = WorkspaceConfig::new(
26417 WorkspaceId::new("primary").unwrap(),
26418 std::env::current_dir().unwrap(),
26419 )
26420 .unwrap();
26421 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26422 }
26423
26424 #[test]
26425 fn resume_rebinds_only_on_the_expected_authorized_generation() {
26426 let catalog = active_registry().unwrap();
26427 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26428 let workspace_id = WorkspaceId::new("primary").unwrap();
26429 let workspace = WorkspaceConfig::new(
26430 workspace_id.clone(),
26431 std::env::current_dir().unwrap(),
26432 )
26433 .unwrap();
26434 let shared = NodeShared::new(
26435 handle,
26436 "fixture-token".to_owned(),
26437 NodeId::new("node-1").unwrap(),
26438 vec![workspace],
26439 vec![agent("claude")],
26440 );
26441 let instance_id = AgentInstanceId(17);
26442 let original = SessionAddress {
26443 workspace_id,
26444 session: SessionKey {
26445 instance_id,
26446 generation: SessionGeneration(3),
26447 },
26448 };
26449 shared.bind_session(
26450 instance_id,
26451 original.workspace_id.clone(),
26452 original.session.generation,
26453 );
26454 shared
26455 .arm_resume(
26456 &original,
26457 CommandId(41),
26458 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26459 )
26460 .unwrap();
26461
26462 shared.publish_control(ControlEvent {
26463 sequence: 1,
26464 command_id: None,
26465 instance_id,
26466 generation: SessionGeneration(4),
26467 event: ControlEventKind::Running { process_id: None },
26468 });
26469 assert_eq!(
26470 shared.address_for(instance_id, SessionGeneration(3)),
26471 Some(original.clone()),
26472 );
26473 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26474
26475 shared.publish_control(ControlEvent {
26476 sequence: 2,
26477 command_id: Some(CommandId(42)),
26478 instance_id,
26479 generation: SessionGeneration(3),
26480 event: ControlEventKind::CommandRejected {
26481 message: "unrelated command".to_owned(),
26482 },
26483 });
26484
26485 shared.publish_control(ControlEvent {
26486 sequence: 3,
26487 command_id: None,
26488 instance_id,
26489 generation: SessionGeneration(4),
26490 event: ControlEventKind::ResumeAuthorized {
26491 session: gate4agent_types::ResumeSessionSummary {
26492 key: gate4agent_types::ProviderSessionKey::SessionId,
26493 id: "fixture-session".to_owned(),
26494 },
26495 },
26496 });
26497 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26498 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26499 assert_eq!(rebound.workspace_id, original.workspace_id);
26500 let history = shared
26501 .history
26502 .lock()
26503 .unwrap_or_else(|poisoned| poisoned.into_inner());
26504 assert!(history.events.iter().any(|envelope| matches!(
26505 &envelope.event,
26506 NodeEvent::Control { address, event }
26507 if address == &rebound
26508 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26509 )));
26510 }
26511
26512 #[test]
26513 fn all_node_pty_prompts_use_bracketed_paste_framing() {
26514 for agent in ["claude", "codex", "kimi"] {
26515 assert_eq!(
26516 prompt_framing(&AgentId::new(agent).unwrap()),
26517 PromptFraming::BracketedPaste
26518 );
26519 }
26520 }
26521
26522 fn generous_walk_deadline() -> Instant {
26523 Instant::now() + Duration::from_secs(30)
26524 }
26525
26526 #[test]
26527 fn workspace_tree_is_relative_and_skips_heavy_directories() {
26528 let root = temporary_workspace_root("tree");
26529 std::fs::create_dir_all(root.join("src")).unwrap();
26530 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26531 std::fs::create_dir_all(root.join("target/debug")).unwrap();
26532 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26533 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26534 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26535 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26541 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26542 std::fs::create_dir_all(root.join("target-notes")).unwrap();
26546 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26547
26548 let (entries, truncated, _budget) =
26549 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26550 assert!(!truncated);
26551 assert!(entries.iter().any(|entry| {
26552 entry.relative_path.as_utf8() == Some("src")
26553 && entry.kind == WorkspaceEntryKind::Directory
26554 }));
26555 assert!(entries.iter().any(|entry| {
26556 entry.relative_path.as_utf8() == Some("src/lib.rs")
26557 && entry.kind == WorkspaceEntryKind::File
26558 }));
26559 assert!(entries.iter().any(|entry| {
26560 entry.relative_path.as_utf8() == Some("target-notes")
26561 && entry.kind == WorkspaceEntryKind::Directory
26562 }));
26563 assert!(entries.iter().any(|entry| {
26564 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26565 && entry.kind == WorkspaceEntryKind::File
26566 }));
26567 assert!(entries.iter().all(|entry| {
26568 let path = entry.relative_path.as_utf8().unwrap();
26569 !path.starts_with(".git")
26570 && !(path == "target" || path.starts_with("target/"))
26571 && !(path == "target-a2" || path.starts_with("target-a2/"))
26572 && !path.starts_with("node_modules")
26573 }));
26574
26575 std::fs::remove_dir_all(&root).unwrap();
26576 }
26577
26578 #[test]
26579 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26580 let root = temporary_workspace_root("tree-entry-cap");
26581 std::fs::create_dir_all(&root).unwrap();
26582 for index in 0..20 {
26583 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26584 }
26585
26586 let (entries, truncated, budget) =
26587 collect_workspace_entries(&root, generous_walk_deadline(), 5);
26588 assert!(truncated);
26589 assert!(budget.entry_cap_exceeded);
26590 assert!(!budget.time_budget_exceeded);
26591 assert!(entries.len() <= 5);
26592
26593 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26594 .expect("an entry-cap-truncated walk must report a truncation marker");
26595 assert!(inspection_truncation.walk_entry_cap_exceeded);
26596 assert!(!inspection_truncation.walk_time_budget_exceeded);
26597 assert!(!inspection_truncation.git_time_budget_exceeded);
26598 assert!(inspection_truncation.entries_visited >= 5);
26599
26600 std::fs::remove_dir_all(&root).unwrap();
26601 }
26602
26603 #[test]
26604 fn workspace_walk_within_budget_reports_no_truncation() {
26605 let root = temporary_workspace_root("tree-no-truncation");
26606 std::fs::create_dir_all(&root).unwrap();
26607 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26608
26609 let (_entries, truncated, budget) =
26610 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26611 assert!(!truncated);
26612 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26613
26614 std::fs::remove_dir_all(&root).unwrap();
26615 }
26616
26617 #[test]
26618 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26619 let root = temporary_workspace_root("tree-prefix-siblings");
26620 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26621 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26622 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26623 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26624
26625 let (entries, truncated, _budget) =
26626 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26627 assert!(!truncated);
26628 let paths = entries
26629 .iter()
26630 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26631 .collect::<Vec<_>>();
26632 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26633 assert_eq!(
26634 paths,
26635 vec![
26636 "gate4agent-c2".to_owned(),
26637 "gate4agent-c2-client".to_owned(),
26638 "gate4agent-c2-client/lib.rs".to_owned(),
26639 "gate4agent-c2/src".to_owned(),
26640 "gate4agent-c2/src/lib.rs".to_owned(),
26641 ],
26642 );
26643
26644 std::fs::remove_dir_all(&root).unwrap();
26645 }
26646
26647 #[test]
26648 fn git_parsers_cap_status_and_commit_results() {
26649 let mut status_output = Vec::new();
26650 for index in 0..=GIT_STATUS_MAX_ENTRIES {
26651 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26652 }
26653 let mut snapshot = git_snapshot_for_parser();
26654 parse_git_status(&status_output, &mut snapshot);
26655 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26656 assert!(snapshot.truncated);
26657 assert_eq!(snapshot.status[0].index_status, " ");
26658 assert_eq!(snapshot.status[0].worktree_status, "M");
26659
26660 snapshot.truncated = false;
26661 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26662 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
26663 .collect::<String>();
26664 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
26665 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
26666 assert!(snapshot.truncated);
26667 }
26668
26669 #[test]
26670 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
26671 let mut snapshot = git_snapshot_for_parser();
26672 parse_git_status(
26673 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
26674 &mut snapshot,
26675 );
26676
26677 assert!(!snapshot.truncated);
26678 assert_eq!(snapshot.status.len(), 3);
26679 assert_eq!(snapshot.status[0].index_status, "R");
26680 assert_eq!(snapshot.status[0].worktree_status, " ");
26681 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
26682 assert_eq!(
26683 snapshot.status[0]
26684 .previous_path
26685 .as_ref()
26686 .and_then(RepositoryPath::as_utf8),
26687 Some("src/previous name.rs"),
26688 );
26689 assert_eq!(snapshot.status[1].index_status, " ");
26690 assert_eq!(snapshot.status[1].worktree_status, "C");
26691 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
26692 assert_eq!(
26693 snapshot.status[1]
26694 .previous_path
26695 .as_ref()
26696 .and_then(RepositoryPath::as_utf8),
26697 Some("src/original.rs"),
26698 );
26699 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
26700 assert!(snapshot.status[2].previous_path.is_none());
26701 }
26702
26703 #[test]
26704 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
26705 let mut snapshot = git_snapshot_for_parser();
26706 parse_git_status(
26707 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
26708 &mut snapshot,
26709 );
26710
26711 assert!(snapshot.truncated);
26712 assert_eq!(snapshot.status.len(), 1);
26713 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
26714 assert!(snapshot.status[0].previous_path.is_none());
26715 }
26716
26717 #[tokio::test]
26718 async fn observer_can_browse_host_directories_without_controller() {
26719 let root = temporary_workspace_root("host-directory-observer");
26720 std::fs::create_dir_all(root.join("directory-b")).unwrap();
26721 std::fs::create_dir_all(root.join("directory-a")).unwrap();
26722 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
26723 let workspace_id = WorkspaceId::new("primary").unwrap();
26724 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
26725 let catalog = active_registry().unwrap();
26726 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26727 let shared = NodeShared::new(
26728 handle,
26729 "fixture-token".to_owned(),
26730 NodeId::new("node-1").unwrap(),
26731 vec![workspace],
26732 vec![agent("claude")],
26733 );
26734 let request = NodeRequest::BrowseHostDirectories {
26735 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
26736 after: None,
26737 };
26738 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26739 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
26740 }));
26741 assert!(!request_uses_unnegotiated_capability(
26742 &request,
26743 &baseline_capabilities().unwrap(),
26744 ));
26745 assert!(request_uses_unnegotiated_capability(&request, &[]));
26746
26747 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
26748 .await
26749 .unwrap();
26750 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
26751 panic!("host directory browse returned another response");
26752 };
26753 assert_eq!(
26754 listing
26755 .entries
26756 .iter()
26757 .map(|entry| entry.display_name.as_str())
26758 .collect::<Vec<_>>(),
26759 vec!["directory-a", "directory-b"],
26760 );
26761 assert!(listing.entries.iter().all(|entry| !entry.is_link));
26762 assert!(!listing.incomplete);
26763 std::fs::remove_dir_all(root).unwrap();
26764 }
26765
26766 #[tokio::test]
26767 async fn observer_can_inspect_only_a_registered_workspace() {
26768 let root = temporary_workspace_root("observer");
26769 std::fs::create_dir_all(root.join("src")).unwrap();
26770 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
26771 let workspace_id = WorkspaceId::new("primary").unwrap();
26772 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26773 let catalog = active_registry().unwrap();
26774 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26775 let shared = NodeShared::new(
26776 handle,
26777 "fixture-token".to_owned(),
26778 NodeId::new("node-1").unwrap(),
26779 vec![workspace],
26780 vec![agent("claude")],
26781 );
26782
26783 let response = process_request_inner(
26784 &shared,
26785 77,
26786 ClientRole::Observer,
26787 NodeRequest::InspectWorkspace {
26788 workspace_id: workspace_id.clone(),
26789 },
26790 )
26791 .await
26792 .unwrap();
26793 let NodeResponse::WorkspaceInspected { inspection } = response else {
26794 panic!("workspace inspection returned another response");
26795 };
26796 assert_eq!(inspection.workspace_id, workspace_id);
26797 assert!(inspection.entries.iter().any(|entry| {
26798 entry.relative_path.as_utf8() == Some("src/main.rs")
26799 }));
26800
26801 let unknown = process_request_inner(
26802 &shared,
26803 77,
26804 ClientRole::Observer,
26805 NodeRequest::InspectWorkspace {
26806 workspace_id: WorkspaceId::new("unknown").unwrap(),
26807 },
26808 )
26809 .await
26810 .unwrap_err();
26811 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
26812 std::fs::remove_dir_all(&root).unwrap();
26813 }
26814
26815 #[test]
26816 fn context_pack_source_accepts_only_running_or_successful_exit() {
26817 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
26818 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
26819 exit_code: Some(0),
26820 }));
26821 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26822 exit_code: Some(1),
26823 }));
26824 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26825 exit_code: None,
26826 }));
26827 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
26828 message: "provider failed".to_owned(),
26829 }));
26830 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
26831 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
26832 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
26833 }
26834
26835 #[test]
26836 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
26837 let git = git_snapshot_for_parser();
26838 let first = ContextPackRepositoryObservation {
26839 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26840 git: git.clone(),
26841 files: Vec::new(),
26842 };
26843 let second = ContextPackRepositoryObservation {
26844 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
26845 git,
26846 files: Vec::new(),
26847 };
26848
26849 let error = stable_context_pack_repository(first, second).unwrap_err();
26850
26851 assert_eq!(
26852 error.code,
26853 NodeFailureCode::ContextPackMaterializationFailed,
26854 );
26855 }
26856
26857 #[test]
26858 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
26859 let git = git_snapshot_for_parser();
26860 let first = ContextPackRepositoryObservation {
26861 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26862 git: git.clone(),
26863 files: vec![ContextPackRepositoryFileSource::utf8(
26864 "README.md",
26865 "first\n".to_owned(),
26866 6,
26867 )],
26868 };
26869 let second = ContextPackRepositoryObservation {
26870 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26871 git,
26872 files: vec![ContextPackRepositoryFileSource::utf8(
26873 "README.md",
26874 "other\n".to_owned(),
26875 6,
26876 )],
26877 };
26878
26879 let error = stable_context_pack_repository(first, second).unwrap_err();
26880
26881 assert_eq!(
26882 error.code,
26883 NodeFailureCode::ContextPackMaterializationFailed,
26884 );
26885 }
26886
26887 #[tokio::test]
26888 async fn native_session_catalog_zero_active_session_fixture() {
26889 let workspace_root = temporary_workspace_root("native-catalog-workspace");
26890 let history_root = temporary_workspace_root("native-catalog-history");
26891 let outside_root = temporary_workspace_root("native-catalog-outside");
26892 std::fs::create_dir_all(&workspace_root).unwrap();
26893 std::fs::create_dir_all(&history_root).unwrap();
26894 std::fs::create_dir_all(&outside_root).unwrap();
26895 let transcript = |session_id: &str, cwd: &Path, title: &str| {
26896 [
26897 serde_json::json!({
26898 "type": "user",
26899 "sessionId": session_id,
26900 "cwd": cwd.to_str().unwrap(),
26901 "message": { "content": "raw user text must not cross the catalog wire" }
26902 }),
26903 serde_json::json!({
26904 "type": "assistant",
26905 "sessionId": session_id,
26906 "cwd": cwd.to_str().unwrap(),
26907 "message": { "model": "claude-sonnet", "content": "raw answer" }
26908 }),
26909 serde_json::json!({
26910 "type": "ai-title",
26911 "sessionId": session_id,
26912 "aiTitle": title
26913 }),
26914 ]
26915 .into_iter()
26916 .map(|record| record.to_string())
26917 .collect::<Vec<_>>()
26918 .join("\n")
26919 };
26920 std::fs::write(
26921 history_root.join("stable-session.jsonl"),
26922 transcript("stable-session", &workspace_root, "Workspace review"),
26923 )
26924 .unwrap();
26925 std::fs::write(
26926 history_root.join("outside-session.jsonl"),
26927 transcript("outside-session", &outside_root, "Outside review"),
26928 )
26929 .unwrap();
26930 let history = NativeHistoryConfig::new(vec![
26931 NativeHistoryRoot::new(
26932 gate4agent_types::AdapterId::new("claude-code").unwrap(),
26933 HistorySourceLayout::SingleNdjson,
26934 history_root.clone(),
26935 )
26936 .unwrap(),
26937 ])
26938 .unwrap();
26939 let catalog = active_registry().unwrap();
26940 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26941 assert!(handle.snapshot().sessions.is_empty());
26942 let workspace_id = WorkspaceId::new("primary").unwrap();
26943 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
26944 let mut shared = NodeShared::new(
26945 handle,
26946 "fixture-token".to_owned(),
26947 NodeId::new("node-native-catalog").unwrap(),
26948 vec![workspace],
26949 vec![agent("claude")],
26950 );
26951 shared.native_session_catalog = Some(Arc::new(Mutex::new(
26952 NativeSessionCatalogAuthority::new(history),
26953 )));
26954 let route = NativeSessionCatalogRoute::workspace(
26955 workspace_id.clone(),
26956 agent("claude"),
26957 );
26958 let request = NodeRequest::CatalogNativeSessions {
26959 route: route.clone(),
26960 limit: 2,
26961 };
26962 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
26963 .await
26964 .unwrap_err();
26965 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26966 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
26967 .await
26968 .unwrap();
26969 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
26970 panic!("native catalog returned a different response");
26971 };
26972 let summary = summary.expect("node must emit native session catalog summary metadata");
26973 assert_eq!(entries.len(), 1);
26974 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
26975 assert_eq!(entries[0].external_group, None);
26976 assert_eq!(entries[0].record_id, None);
26977 assert_eq!(entries[0].title, None);
26978 assert_eq!(entries[0].model, None);
26979 assert_eq!(entries[0].message_count, 0);
26980 let encoded_entries = serde_json::to_string(&entries).unwrap();
26981 assert!(!encoded_entries.contains("stable-session"));
26982 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
26983 let page_request = NodeRequest::PageNativeSessions {
26984 route: route.clone(),
26985 window: crate::protocol::NativeSessionCatalogWindow::Recent,
26986 catalog_revision: summary.catalog_revision,
26987 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26988 after_selection_id: None,
26989 limit: 2,
26990 };
26991 let observer = process_request_inner(
26992 &shared,
26993 7,
26994 ClientRole::Observer,
26995 page_request.clone(),
26996 )
26997 .await
26998 .unwrap_err();
26999 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27000 let page_response = process_request_inner(
27001 &shared,
27002 7,
27003 ClientRole::Operator,
27004 page_request.clone(),
27005 )
27006 .await
27007 .unwrap();
27008 assert!(matches!(
27009 page_response,
27010 NodeResponse::NativeSessionsPaged { page, .. }
27011 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27012 ));
27013 let stale = process_request_inner(
27014 &shared,
27015 7,
27016 ClientRole::Operator,
27017 NodeRequest::PageNativeSessions {
27018 route: route.clone(),
27019 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27020 catalog_revision: summary.catalog_revision.wrapping_add(1),
27021 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27022 after_selection_id: None,
27023 limit: 2,
27024 },
27025 )
27026 .await
27027 .unwrap_err();
27028 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27029 let selection = NativeSessionSelection {
27030 route: route.clone(),
27031 catalog_revision: summary.catalog_revision,
27032 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27033 selection_id: entries[0].selection_id.clone(),
27034 };
27035 let preview_response = process_request_inner(
27036 &shared,
27037 7,
27038 ClientRole::Operator,
27039 NodeRequest::PreviewNativeSession {
27040 selection: selection.clone(),
27041 message_limit: 1,
27042 },
27043 )
27044 .await
27045 .unwrap();
27046 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27047 panic!("native preview returned a different response");
27048 };
27049 assert_eq!(preview.messages.len(), 1);
27050 assert!(preview.message_count_exact);
27051 assert_eq!(preview.messages[0].text, "raw answer");
27052 assert!(preview.truncated);
27053
27054 let index_request = NodeRequest::IndexNativeSession {
27055 selection: selection.clone(),
27056 display_name: "Saved Claude".to_owned(),
27057 };
27058 let observer = process_request_inner(
27059 &shared,
27060 7,
27061 ClientRole::Observer,
27062 index_request.clone(),
27063 )
27064 .await
27065 .unwrap_err();
27066 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27067 let without_controller = process_request_inner(
27068 &shared,
27069 7,
27070 ClientRole::Operator,
27071 index_request.clone(),
27072 )
27073 .await
27074 .unwrap_err();
27075 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27076 let acquired = process_request_inner(
27077 &shared,
27078 7,
27079 ClientRole::Operator,
27080 NodeRequest::AcquireController { lease_ms: 5_000 },
27081 )
27082 .await
27083 .unwrap();
27084 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27085 let indexed = process_request_inner(
27086 &shared,
27087 7,
27088 ClientRole::Operator,
27089 index_request,
27090 )
27091 .await
27092 .unwrap();
27093 assert!(indexed.native_session_index_contract_is_valid());
27094 let NodeResponse::NativeSessionIndexed {
27095 selection: echoed_selection,
27096 record,
27097 } = indexed else {
27098 panic!("native session index returned a different response");
27099 };
27100 assert_eq!(echoed_selection, selection);
27101 let recataloged = process_request_inner(
27102 &shared,
27103 7,
27104 ClientRole::Operator,
27105 NodeRequest::CatalogNativeSessions {
27106 route,
27107 limit: 2,
27108 },
27109 )
27110 .await
27111 .unwrap();
27112 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27113 panic!("native catalog returned a different response after indexing");
27114 };
27115 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27116
27117 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27118 let external_catalog = process_request_inner(
27119 &shared,
27120 7,
27121 ClientRole::Operator,
27122 NodeRequest::CatalogNativeSessions {
27123 route: unregistered_route.clone(),
27124 limit: 2,
27125 },
27126 )
27127 .await
27128 .unwrap();
27129 let external_json = serde_json::to_string(&external_catalog).unwrap();
27130 assert!(!external_json.contains("outside-session"));
27131 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27132 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27133 assert!(!external_json.contains("\"session_id\""));
27134 assert!(!external_json.contains("\"cwd\""));
27135 assert!(!external_json.contains("\"path\""));
27136 let NodeResponse::NativeSessionsCataloged {
27137 route: echoed_route,
27138 entries: external_entries,
27139 summary: Some(external_summary),
27140 } = external_catalog
27141 else {
27142 panic!("unregistered native catalog returned a different response");
27143 };
27144 assert_eq!(echoed_route, unregistered_route);
27145 assert_eq!(external_entries.len(), 1);
27146 assert_eq!(external_entries[0].record_id, None);
27147 let external_group = external_entries[0]
27148 .external_group
27149 .as_ref()
27150 .expect("unregistered session must have an external group");
27151 assert_eq!(
27152 external_group.kind,
27153 gate4agent_types::NativeSessionExternalGroupKind::Project,
27154 );
27155 assert!(external_group.group_id.starts_with("external-"));
27156 assert_eq!(
27157 external_group.display_name,
27158 outside_root.file_name().unwrap().to_string_lossy(),
27159 );
27160 let external_selection = NativeSessionSelection {
27161 route: unregistered_route,
27162 catalog_revision: external_summary.catalog_revision,
27163 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27164 selection_id: external_entries[0].selection_id.clone(),
27165 };
27166 let external_preview = process_request_inner(
27167 &shared,
27168 7,
27169 ClientRole::Operator,
27170 NodeRequest::PreviewNativeSession {
27171 selection: external_selection.clone(),
27172 message_limit: 1,
27173 },
27174 )
27175 .await
27176 .unwrap();
27177 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27178 assert!(!external_preview_json.contains("outside-session"));
27179 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27180 assert!(!external_preview_json.contains("\"session_id\""));
27181 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27182 panic!("unregistered native preview returned a different response");
27183 };
27184 assert_eq!(preview.messages.len(), 1);
27185 let external_index = process_request_inner(
27186 &shared,
27187 7,
27188 ClientRole::Operator,
27189 NodeRequest::IndexNativeSession {
27190 selection: external_selection,
27191 display_name: "External Claude".to_owned(),
27192 },
27193 )
27194 .await
27195 .unwrap_err();
27196 assert_eq!(
27197 external_index.code,
27198 NodeFailureCode::WorkspaceRegistrationRequired,
27199 );
27200 let record_response = process_request_inner(
27201 &shared,
27202 7,
27203 ClientRole::Operator,
27204 NodeRequest::PreviewSessionRecord {
27205 record_id: record.record_id.clone(),
27206 message_limit: 2,
27207 },
27208 )
27209 .await
27210 .unwrap();
27211 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27212 panic!("record preview returned a different response");
27213 };
27214 assert_eq!(record_id, record.record_id);
27215 assert_eq!(preview.messages.len(), 2);
27216 let encoded = serde_json::to_string(&preview).unwrap();
27217 assert!(!encoded.contains("stable-session"));
27218 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27219 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27220 panic!("record preview history summary returned a different response");
27221 };
27222 let summaries = events
27223 .iter()
27224 .filter_map(|envelope| match &envelope.event {
27225 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27226 if record_id == &record.record_id =>
27227 {
27228 Some(*summary)
27229 }
27230 _ => None,
27231 })
27232 .collect::<Vec<_>>();
27233 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27234 let summary_json = serde_json::to_string(&summaries).unwrap();
27235 for private in [
27236 "stable-session",
27237 "raw user text must not cross the catalog wire",
27238 "raw answer",
27239 "Workspace review",
27240 "claude-sonnet",
27241 ] {
27242 assert!(!summary_json.contains(private), "{private}");
27243 }
27244 let identity = record.provider_session.clone().unwrap();
27245 assert_eq!(
27246 shared.revalidate_session_record_preview(&record, &identity),
27247 Ok(())
27248 );
27249 shared.remove_record_memory(&record.record_id);
27250 assert_eq!(
27251 shared
27252 .revalidate_session_record_preview(&record, &identity)
27253 .unwrap_err()
27254 .code,
27255 NodeFailureCode::UnknownSessionRecord
27256 );
27257 let released = process_request_inner(
27258 &shared,
27259 7,
27260 ClientRole::Operator,
27261 NodeRequest::ReleaseController,
27262 )
27263 .await
27264 .unwrap();
27265 assert!(matches!(released, NodeResponse::Controller { controller: None }));
27266 assert!(shared.controller_state().is_none());
27267 std::fs::remove_dir_all(workspace_root).unwrap();
27268 std::fs::remove_dir_all(history_root).unwrap();
27269 std::fs::remove_dir_all(outside_root).unwrap();
27270 }
27271
27272 #[tokio::test]
27273 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27274 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27275 std::fs::create_dir_all(&history_root).unwrap();
27276 let history = NativeHistoryConfig::new(vec![
27277 NativeHistoryRoot::new(
27278 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27279 HistorySourceLayout::SingleNdjson,
27280 history_root.clone(),
27281 )
27282 .unwrap(),
27283 ])
27284 .unwrap();
27285 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27286 let held = catalog.lock().unwrap();
27287 let result = timeout(
27288 Duration::from_secs(1),
27289 run_native_session_catalog_operation(
27290 Arc::clone(&catalog),
27291 "native session catalog",
27292 |_| Ok::<(), std::convert::Infallible>(()),
27293 ),
27294 )
27295 .await
27296 .expect("concurrent native catalog request must not wait for the operation deadline")
27297 .unwrap_err();
27298 assert_eq!(result.code, NodeFailureCode::BackendBusy);
27299 drop(held);
27300 std::fs::remove_dir_all(history_root).unwrap();
27301 }
27302
27303 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27304 let unique = std::time::SystemTime::now()
27305 .duration_since(std::time::UNIX_EPOCH)
27306 .unwrap()
27307 .as_nanos();
27308 std::env::temp_dir().join(format!(
27309 "gate4agent-node-{label}-{}-{unique}",
27310 std::process::id(),
27311 ))
27312 }
27313
27314 fn git_snapshot_for_parser() -> GitSnapshot {
27315 GitSnapshot {
27316 is_repository: true,
27317 branch: Some("main".to_owned()),
27318 status: Vec::new(),
27319 recent_commits: Vec::new(),
27320 worktrees: Vec::new(),
27321 managed_worktree: None,
27322 truncated: false,
27323 diagnostic: None,
27324 }
27325 }
27326
27327 #[tokio::test]
27328 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27329 let root = temporary_workspace_root("managed-git-scope");
27330 std::fs::create_dir_all(&root).unwrap();
27331 run_test_git(&root, &["init"]);
27332 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27333 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27334 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27335 run_test_git(&root, &["add", "seed.txt"]);
27336 run_test_git(&root, &["commit", "-m", "seed"]);
27337 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27338
27339 let workspace_id = WorkspaceId::new("managed-a").unwrap();
27340 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27341 let catalog = active_registry().unwrap();
27342 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27343 let shared = NodeShared::new(
27344 handle,
27345 "fixture-token".to_owned(),
27346 NodeId::new("node-1").unwrap(),
27347 vec![workspace],
27348 vec![agent("claude")],
27349 );
27350 let mut lease = ManagedWorktreeLeaseRecord {
27351 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27352 source_workspace_id: WorkspaceId::new("primary").unwrap(),
27353 workspace_id: workspace_id.clone(),
27354 profile_id: WorktreeProfileId::new("default").unwrap(),
27355 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27356 target_root: root.to_string_lossy().into_owned(),
27357 branch: "gate4agent/a".to_owned(),
27358 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27359 expected_head: None,
27360 retention: ManagedWorktreeRetention::Retain,
27361 state: ManagedWorktreeLeaseState::InUse,
27362 session_holders: vec![ManagedWorktreeSessionHolder {
27363 incarnation_id: shared.incarnation_id,
27364 instance_id: AgentInstanceId(7),
27365 generation: SessionGeneration(1),
27366 }],
27367 record_holders: Vec::new(),
27368 cleanup_failure: None,
27369 created_at_unix_ms: 1,
27370 updated_at_unix_ms: 1,
27371 };
27372 *shared
27373 .managed_worktrees
27374 .lock()
27375 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27376 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27377
27378 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27379 let scope = inspection.git.managed_worktree.unwrap();
27380 assert_eq!(scope.lease_id, lease.lease_id);
27381 assert_eq!(scope.branch, "gate4agent/a");
27382 assert_eq!(scope.active_session_count, 1);
27383
27384 lease.branch = "gate4agent/b".to_owned();
27385 *shared
27386 .managed_worktrees
27387 .lock()
27388 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27389 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27390 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27391 assert!(mismatch.git.managed_worktree.is_none());
27392 std::fs::remove_dir_all(root).unwrap();
27393 }
27394
27395 #[tokio::test]
27396 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27397 let root = temporary_workspace_root("workspace-file-write-controller");
27398 std::fs::create_dir_all(root.join("src")).unwrap();
27399 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27400 let workspace_id = WorkspaceId::new("primary").unwrap();
27401 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27402 let catalog = active_registry().unwrap();
27403 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27404 let shared = NodeShared::new(
27405 handle,
27406 "fixture-token".to_owned(),
27407 NodeId::new("node-1").unwrap(),
27408 vec![workspace],
27409 vec![agent("claude")],
27410 );
27411 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27412 let expected_revision = workspace_file_revision(b"old\n");
27413 let request = NodeRequest::WriteWorkspaceFile {
27414 workspace_id: workspace_id.clone(),
27415 path: path.clone(),
27416 expected_revision: expected_revision.clone(),
27417 text: "new\n".to_owned(),
27418 };
27419 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27420 .await
27421 .unwrap_err();
27422 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27423 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27424 .await
27425 .unwrap_err();
27426 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27427 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27428 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27429 let NodeResponse::WorkspaceFileWritten { file } = response else {
27430 panic!("workspace file write returned another response");
27431 };
27432 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27433 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27434 let stale = process_request_inner(
27435 &shared,
27436 77,
27437 ClientRole::Operator,
27438 NodeRequest::WriteWorkspaceFile {
27439 workspace_id,
27440 path,
27441 expected_revision,
27442 text: "lost update\n".to_owned(),
27443 },
27444 )
27445 .await
27446 .unwrap_err();
27447 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27448 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27449 std::fs::remove_dir_all(root).unwrap();
27450 }
27451
27452 #[tokio::test]
27453 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27454 let root = temporary_workspace_root("workspace-entry-create-controller");
27455 std::fs::create_dir_all(root.join("src")).unwrap();
27456 let workspace_id = WorkspaceId::new("primary").unwrap();
27457 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27458 let catalog = active_registry().unwrap();
27459 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27460 let shared = NodeShared::new(
27461 handle,
27462 "fixture-token".to_owned(),
27463 NodeId::new("node-1").unwrap(),
27464 vec![workspace],
27465 vec![agent("claude")],
27466 );
27467 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27468 let request = NodeRequest::CreateWorkspaceFile {
27469 workspace_id: workspace_id.clone(),
27470 path: path.clone(),
27471 };
27472 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27473 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27474 }));
27475 assert!(!request_uses_unnegotiated_capability(
27476 &request,
27477 &baseline_capabilities().unwrap(),
27478 ));
27479 assert!(request_uses_unnegotiated_capability(&request, &[]));
27480 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27481 .await
27482 .unwrap_err();
27483 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27484 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27485 .await
27486 .unwrap_err();
27487 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27488 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27489
27490 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27491 .await
27492 .unwrap();
27493 let NodeResponse::WorkspaceFileCreated { file } = response else {
27494 panic!("workspace file create returned another response");
27495 };
27496 assert_eq!(file.workspace_id, workspace_id);
27497 assert_eq!(file.path, path);
27498 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27499 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27500
27501 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27502 .await
27503 .unwrap_err();
27504 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27505
27506 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27507 let response = process_request_inner(
27508 &shared,
27509 77,
27510 ClientRole::Operator,
27511 NodeRequest::CreateWorkspaceDirectory {
27512 workspace_id: workspace_id.clone(),
27513 path: directory_path.clone(),
27514 },
27515 )
27516 .await
27517 .unwrap();
27518 assert_eq!(
27519 response,
27520 NodeResponse::WorkspaceDirectoryCreated {
27521 workspace_id: workspace_id.clone(),
27522 entry: WorkspaceEntry {
27523 relative_path: directory_path,
27524 kind: WorkspaceEntryKind::Directory,
27525 },
27526 },
27527 );
27528 assert!(root.join("src/nested").is_dir());
27529
27530 let missing_parent = process_request_inner(
27531 &shared,
27532 77,
27533 ClientRole::Operator,
27534 NodeRequest::CreateWorkspaceFile {
27535 workspace_id: workspace_id.clone(),
27536 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27537 },
27538 )
27539 .await
27540 .unwrap_err();
27541 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27542 let non_directory_parent = process_request_inner(
27543 &shared,
27544 77,
27545 ClientRole::Operator,
27546 NodeRequest::CreateWorkspaceFile {
27547 workspace_id,
27548 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27549 },
27550 )
27551 .await
27552 .unwrap_err();
27553 assert_eq!(
27554 non_directory_parent.code,
27555 NodeFailureCode::RepositoryParentNotDirectory,
27556 );
27557 std::fs::remove_dir_all(root).unwrap();
27558 }
27559
27560 #[tokio::test]
27561 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27562 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27563 std::fs::create_dir_all(root.join("src")).unwrap();
27564 #[cfg(windows)]
27565 let root = std::fs::canonicalize(root).unwrap();
27566 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27567 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27568 let barrier = Arc::new(std::sync::Barrier::new(2));
27569 let done = Arc::new(AtomicBool::new(false));
27570 let task_commit_state = Arc::clone(&commit_state);
27571 let task_barrier = Arc::clone(&barrier);
27572 let task_done = Arc::clone(&done);
27573 let task_root = root.clone();
27574 let task = tokio::task::spawn_blocking(move || {
27575 task_barrier.wait();
27576 let result = create_workspace_file_on_disk(
27577 &task_root,
27578 &path,
27579 &task_commit_state,
27580 );
27581 task_done.store(true, Ordering::Release);
27582 result
27583 });
27584
27585 let error = settle_workspace_entry_create(
27586 task,
27587 Arc::clone(&commit_state),
27588 Duration::from_millis(10),
27589 "fixture deadline",
27590 "fixture task failed",
27591 )
27592 .await
27593 .unwrap_err();
27594 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27595 assert_eq!(
27596 commit_state.load(Ordering::Acquire),
27597 WORKSPACE_ENTRY_CREATE_CANCELED,
27598 );
27599
27600 barrier.wait();
27601 timeout(Duration::from_secs(1), async {
27602 while !done.load(Ordering::Acquire) {
27603 tokio::task::yield_now().await;
27604 }
27605 })
27606 .await
27607 .expect("canceled blocking create did not settle");
27608 assert!(!root.join("src/late.rs").exists());
27609 std::fs::remove_dir_all(root).unwrap();
27610 }
27611}
27612
27613#[cfg(test)]
27614mod provisional_spawn_tests {
27615 use super::*;
27616 use std::sync::Arc;
27617
27618 fn shared() -> NodeShared {
27619 let catalog = active_registry().unwrap();
27620 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27621 let workspace = WorkspaceConfig::new(
27622 WorkspaceId::new("primary").unwrap(),
27623 std::env::current_dir().unwrap(),
27624 )
27625 .unwrap()
27626 .with_worktree_service_mode(WorktreeServiceMode::Manual);
27627 NodeShared::new(
27628 handle,
27629 "fixture-token".to_owned(),
27630 NodeId::new("node-provisional-test").unwrap(),
27631 vec![workspace],
27632 vec![AgentId::new("claude").unwrap()],
27633 )
27634 }
27635
27636 #[test]
27639 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
27640 let shared = shared();
27641 let ws = WorkspaceId::new("primary").unwrap();
27642 let mut ids = Vec::with_capacity(50);
27643 for i in 1u64..=50 {
27644 let id = AgentInstanceId(i);
27645 ids.push(id);
27646 let address = SessionAddress {
27647 workspace_id: ws.clone(),
27648 session: SessionKey {
27649 instance_id: id,
27650 generation: SessionGeneration(1),
27651 },
27652 };
27653 shared.mark_spawn_provisional(address);
27654 }
27655 assert_eq!(
27656 shared
27657 .provisional_spawns
27658 .lock()
27659 .unwrap_or_else(|p| p.into_inner())
27660 .len(),
27661 50
27662 );
27663 for id in ids.iter().step_by(2) {
27664 shared.sessionize_spawn(*id);
27665 }
27666 assert_eq!(
27667 shared
27668 .provisional_spawns
27669 .lock()
27670 .unwrap_or_else(|p| p.into_inner())
27671 .len(),
27672 25
27673 );
27674 assert!(shared.take_due_provisional_spawns().is_empty());
27675 {
27676 let mut map = shared
27677 .provisional_spawns
27678 .lock()
27679 .unwrap_or_else(|p| p.into_inner());
27680 let past = Instant::now() - Duration::from_secs(30);
27681 for (_, (_, at)) in map.iter_mut() {
27682 *at = past;
27683 }
27684 }
27685 let due = shared.take_due_provisional_spawns();
27686 assert_eq!(due.len(), 25);
27687 let due_ids: std::collections::BTreeSet<_> =
27688 due.iter().map(|a| a.session.instance_id.0).collect();
27689 let expected: std::collections::BTreeSet<_> =
27690 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
27691 assert_eq!(due_ids, expected);
27692 assert!(shared
27693 .provisional_spawns
27694 .lock()
27695 .unwrap_or_else(|p| p.into_inner())
27696 .is_empty());
27697 }
27698
27699 #[test]
27702 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
27703 let shared = Arc::new(shared());
27704 let ws = WorkspaceId::new("primary").unwrap();
27705 let mut handles = Vec::new();
27706 for i in 1u64..=20 {
27707 let shared = Arc::clone(&shared);
27708 let ws = ws.clone();
27709 handles.push(std::thread::spawn(move || {
27710 let id = AgentInstanceId(i);
27711 let address = SessionAddress {
27712 workspace_id: ws,
27713 session: SessionKey {
27714 instance_id: id,
27715 generation: SessionGeneration(1),
27716 },
27717 };
27718 shared.mark_spawn_provisional(address);
27719 id
27720 }));
27721 }
27722 let mut ids = Vec::new();
27723 for h in handles {
27724 ids.push(h.join().unwrap());
27725 }
27726 ids.sort_by_key(|id| id.0);
27727 assert_eq!(ids.len(), 20);
27728 ids.dedup();
27729 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
27730 assert_eq!(
27731 shared
27732 .provisional_spawns
27733 .lock()
27734 .unwrap_or_else(|p| p.into_inner())
27735 .len(),
27736 20
27737 );
27738 shared.sessionize_spawn(AgentInstanceId(7));
27739 let left: Vec<_> = shared
27740 .provisional_spawns
27741 .lock()
27742 .unwrap_or_else(|p| p.into_inner())
27743 .keys()
27744 .map(|id| id.0)
27745 .collect();
27746 assert!(!left.contains(&7));
27747 assert_eq!(left.len(), 19);
27748 }
27749}
27750