1use crate::git_worktree::{
2 create_worktree as create_git_worktree,
3 create_worktree_with_timeout as create_git_worktree_with_timeout,
4 list_worktrees as list_git_worktrees,
5 list_worktrees_with_deadline as list_git_worktrees_with_deadline,
6 paths_equal as worktree_paths_equal, remove_worktree as remove_git_worktree,
7 removal_lookup_path as normalize_worktree_removal_target,
8 resolve_base_commit_with_timeout,
9 run_git_read_bounded, GitCommandOutput, GitWorktreeError, GitWorktreeErrorKind,
10 NativeGitWorktreeSnapshot,
11};
12use crate::host_directory_browser::{
13 browse_host_directories, HostDirectoryBrowseError, HostDirectoryBrowseErrorKind,
14};
15use crate::environment_profiles::{
16 EnvironmentProfileBinding, NodeEnvironmentProfile, MAX_NODE_ENVIRONMENT_PROFILES,
17};
18use crate::bundle_catalog::{BundleCatalog, NodeBundle};
19use self::bundle_delivery::{DeliveryStore, DeliveryStoreError};
20use self::context_pack_store::ContextPackStore;
21use crate::bundle_provider::{
22 bundle_launch_arguments, validate_bundle_binding, BundleProviderLayout,
23};
24use crate::context_pack::{
25 ContextPackCatalog, ContextPackRepository, ContextPackRepositoryFileSource,
26 ContextPackSelectedFileSkipReason, NodeContextPack, CONTEXT_PACK_SELECTED_FILES,
27};
28use crate::harness_mcp_proxy::{
29 HarnessMcpProxyError, HarnessMcpProxyRegistry, PreparedHarnessMcpSpawn,
30 ReviewedHarnessMcpProgram,
31};
32use crate::session_environment::{
33 MaterializationId, MaterializationOwner, MaterializationOwnershipRecord,
34 MaterializationState, NodeSecretResolver, NodeSessionMaterializationProfile,
35 SessionEnvironmentMaterializeError, SessionEnvironmentMaterializer,
36};
37use crate::worktree_service::{
38 exact_created_worktree, exact_owned_worktree, ManagedWorktreeLeaseRecord,
39 ManagedWorktreeProfile, ManagedWorktreeRegistry, ManagedWorktreeSessionHolder,
40};
41use crate::session_registry::{
42 self, validate_display_name, LoadedNodeState, ManagedWorktreeSpawnReplayRecordV10,
43 ManagedWorktreeSpawnReplayStateV10, MAX_MANAGED_SESSION_RECORDS,
44 MAX_MANAGED_WORKTREE_SPAWN_REPLAYS,
45};
46#[cfg(unix)]
47use crate::workspace_file_unix::{
48 create_workspace_directory as create_workspace_directory_on_disk,
49 create_workspace_file as create_workspace_file_on_disk,
50 read_workspace_file as read_workspace_file_from_disk,
51 write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
52 WorkspaceFileReadError, WorkspaceFileReadErrorKind,
53 WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
54 WORKSPACE_ENTRY_CREATE_PENDING,
55};
56#[cfg(windows)]
57use crate::workspace_file_windows::{
58 create_workspace_directory as create_workspace_directory_on_disk,
59 create_workspace_file as create_workspace_file_on_disk,
60 read_workspace_file as read_workspace_file_from_disk,
61 write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
62 WorkspaceFileReadError, WorkspaceFileReadErrorKind,
63 WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
64 WORKSPACE_ENTRY_CREATE_PENDING,
65};
66use crate::platform;
67use crate::provider_runtime::{
68 require_policy, ProviderRuntimeAdmissionError, ProviderRuntimeMonitor,
69 ProviderRuntimeRequirement,
70};
71use crate::spawn_spec::SpawnProfileRegistry;
72use crate::standalone_workspace::{
73 prepare_standalone_workspace, StandaloneWorkspaceError, StandaloneWorkspaceErrorKind,
74};
75use crate::protocol::{
76 read_json_frame_limited_body_timeout, write_json_frame, write_json_frame_limited,
77 validate_node_negotiated_handshake_capacity, validate_provider_contract_manifest,
78 provider_id_is_legacy, AdapterContractRevision, AgentStreamChunkKindV1, AgentStreamChunkV1,
79 AgentStreamInteractionOptionV1, AgentStreamNamedIdV1,
80 CapabilityId, ClientFrame, ClientRole,
81 ControllerState, DeliveryBlobChunkHexV1, DeliveryBlobDigestV1,
82 DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryStageId,
83 AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
84 AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, FrameError,
85 GitCommitDetails, GitDiff, GitDiffMode, GitDiffRequest, GitHistoryPage,
86 GitObjectId, GitCommitSummary, GitSignatureStatus, GitSnapshot, GitStatusEntry,
87 GitWorktreeSnapshot, HarnessMcpActivationDigest, HarnessMcpReservationId,
88 HostDirectoryListing,
89 NodeCompatibilitySupport, NodeEvent,
90 NodeEventEnvelope, NodeFailure, NodeFailureCode, NodeHello, NodeId, NodeIncarnationId,
91 NegotiatedNodeCompatibility, NodeRequest, NodeResponse, NodeSnapshot, OpaqueHostPath,
92 ProtocolRange,
93 ProviderAdapterContractSupport,
94 ProviderContractRevision, ProviderContractSupport, RepositoryPath, RequestEnvelope,
95 ProviderRuntimeStatuses, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
96 ContextPackLineageReceipt, ContextPackBytesRead, ResolvedContextPackReceipt,
97 ResolvedHarnessMcpProxyReceiptV1, ResolvedSpawnReceipt,
98 ResolvedSpawnSpec, ResponseEnvelope,
99 ServerChallenge, ServerFrame, SessionAddress, SessionAgentProgress, SessionKey, SessionMode,
100 ManagedSessionRecord,
101 ManagedSessionState, ManagedWorktreeCleanupFailure, ManagedWorktreeLeaseId,
102 SessionRecordRetentionConfig,
103 ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState, ManagedWorktreeRetention,
104 ManagedWorktreeSpawnReceipt, ManagedWorktreeSpawnRequest,
105 ManagedWorktreeSpawnRequestV2, SessionRecordId,
106 NativeSessionCatalogRoute, NativeSessionSelection, SessionTaskBindingV1,
107 SessionTaskTargetV1, TaskId,
108 BlockAuthorityV1, HarnessMcpLaunchV1, SessionHistorySummaryV1, MAX_ACP_BLOCKED_HELP_BYTES, MAX_ACP_BLOCKED_REASON_BYTES,
109 StateSchemaSupport, WorkspaceEntry, WorkspaceEntryKind, WorktreeProfileId,
110 SpawnContextId, SpawnEnvironmentProfileId, SpawnIdempotencyKey,
111 SpawnProfileDefaults, SpawnRequiredCapabilities, SpawnSpec, SpawnSpecResolveError,
112 WorkspaceFileContent, WorkspaceFileRevision,
113 WorkspaceFileRead, WorkspaceId, WorkspaceInspection, WorkspaceInspectionTruncationV1,
114 WorkspaceSnapshot,
115 WorktreeServiceMode,
116 LaunchInventory, ManagedWorktreeProfileSummary, SpawnProfileSummary,
117 WorktreeProfileInventory, MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
118 DEFAULT_CONTROLLER_LEASE_MS,
119 MAX_CONTROLLER_LEASE_MS, MIN_CONTROLLER_LEASE_MS, NODE_COMPATIBILITY_METADATA_CAPABILITY,
120 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
121 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, NODE_REPOSITORY_PATH_CAPABILITY,
122 NODE_PROVIDER_ID_OPEN_CAPABILITY,
123 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
124 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY, NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
125 NODE_HISTORY_CONTEXT_PACK_CAPABILITY, NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
126 NODE_NATIVE_SESSION_CATALOG_CAPABILITY, NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
127 NODE_NATIVE_SESSION_INDEX_CAPABILITY, NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
128 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
129 NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
130 NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
131 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
132 NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
133 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
134 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
135 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
136 NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
137 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
138 NODE_AGENT_STREAM_EVENTS_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY,
139 NODE_GIT_READ_CAPABILITY, NODE_WORKSPACE_FILE_READ_CAPABILITY,
140 NODE_WORKSPACE_FILE_WRITE_CAPABILITY, NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
141 NODE_WORKTREE_SELECTION_CAPABILITY,
142 BUILD_STAMP, MAX_NODE_CLIENT_FRAME_BYTES, MAX_NODE_HELLO_FRAME_BYTES,
143 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS, MAX_AGENT_PROGRESS_ENTRY_BYTES,
144 MAX_NODE_TERMINAL_BYTES, MAX_NODE_TEXT_BYTES,
145 MAX_REPOSITORY_PATH_BYTES,
146 MAX_GIT_DIFF_BYTES, MAX_GIT_HISTORY_COMMITS, MAX_WORKSPACE_FILE_BYTES,
147 MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE,
148 MAX_WORKSPACE_ROOT_BYTES, NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10,
149 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY, SPAWN_RUNTIME_RAW_PTY_LIFECYCLE,
150 SPAWN_RUNTIME_SEMANTIC_READINESS, SPAWN_RUNTIME_SEMANTIC_RESUME,
151 SPAWN_RUNTIME_STRUCTURED_PROMPT,
152};
153#[cfg(test)]
154use crate::protocol::WorktreeProfileRevision;
155use ring::digest::{digest, SHA256};
156use crate::protocol::correlation::{
157 interaction_correlation, sanitize_progress_tool_label, tool_class_label, tool_correlation,
158 truncate_text,
159};
160use gate4agent_catalog::{builtin_registry, AgentRegistry};
161use gate4agent_handle::{EventSubscription, Gate4AgentHandle, PortDispatchError};
162use gate4agent_runtime_native::{
163 NativeMcpServerLaunchOverlay, NativeInstanceLaunchOverlay,
164 NativeLaunchEnvironmentOverlay,
165 NativeHistoryConfig, NativeLaunchProfileControl, NativeRuntime, NativeRuntimeConfig,
166 NativeSessionCatalogAuthority, NativeSessionCatalogError, NativeSessionPreviewError,
167 ScopedNativeSessionCatalogEntry,
168};
169use gate4agent_runtime_native::shell_efficiency::ShellEfficiencyProfile;
170use std::sync::OnceLock;
171use gate4agent_runtime_native::tick_profile::TickProfileSnapshot;
172use drive_loop_profile::DriveLoopProfiler;
173#[cfg(test)]
174use gate4agent_runtime_native::{HistorySourceLayout, NativeHistoryRoot};
175use gate4agent_node_wire::{
176 auth_proof, negotiated_auth_proof, proofs_match, random_incarnation_id, random_nonce,
177 AuthDirection, OwnerOnlyLocalListener,
178};
179use gate4agent_types::{
180 AdapterBinding, AdapterFamily, AgentId, AgentInstanceId, AgentSpec, ApprovalLevel,
181 CommandEnvelope, CommandId,
182 validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
183 HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
184 HostDecisionAuthority as ProviderHostDecisionAuthority,
185 HostRequestDecision as ProviderHostRequestDecision,
186 InputAction,
187 PromptFraming, PromptPayload, ResumeLaunchRequest,
188 ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
189 ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
190 ProviderSnapshot, ProviderStopReason,
191 ResumeTarget,
192 SessionGeneration, StartRequest,
193 TerminalControl, TerminalText,
194 SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
195 HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
196};
197use std::collections::{BTreeMap, VecDeque};
198use std::ffi::OsString;
199use std::io;
200use std::path::{Path, PathBuf};
201use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
202use std::sync::{Arc, Mutex, RwLock};
203use std::time::{Duration, Instant};
204use thiserror::Error;
205use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
206use tokio::task::{AbortHandle, JoinSet};
207use tokio::time::{sleep, timeout};
208
209mod http_api;
210
211mod browser_bridge;
212
213use gate4agent_node_core::drive_loop_profile;
214
215#[path = "bundle_delivery.rs"]
216mod bundle_delivery;
217
218#[path = "context_pack_store.rs"]
219mod context_pack_store;
220
221#[cfg(windows)]
222pub use crate::platform::DEFAULT_NODE_ENDPOINT;
223
224const NODE_EVENT_HISTORY_MAX: usize = 4_096;
225const NODE_BROADCAST_CAPACITY: usize = 1_024;
226const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
227const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
233const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
240const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
241const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
242const MAX_PREAUTH_CONNECTIONS: usize = 32;
243const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
244const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
245const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
246const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
247const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
248const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
256const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
264const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
271const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
276const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
277const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
278const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
279const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
280const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
281const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
282const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
283const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
284const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
285const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
286const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
287const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
288const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
291const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
298const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
301const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
302const GIT_STATUS_MAX_ENTRIES: usize = 128;
303const GIT_COMMIT_MAX_ENTRIES: usize = 12;
304const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
305const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
306const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
307const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
308const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
309const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
310const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
311const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
312const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
313const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
314 "provider-session-identity-allocation-failed";
315const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
316const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
317const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
318const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
319const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
320const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
321const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
322 "durable-state-path-semantics-unsupported";
323
324fn opaque_windows_path(value: String) -> OpaqueHostPath {
325 OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
326}
327
328fn agent_progress_from_provider_snapshot(
329 address: SessionAddress,
330 provider: &ProviderSnapshot,
331) -> Option<SessionAgentProgress> {
332 let mut truncated = false;
333 let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
334 let mut active_tool_labels = Vec::with_capacity(
335 provider
336 .active_tools
337 .len()
338 .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
339 );
340 for tool in &provider.active_tools {
341 if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
342 truncated = true;
343 break;
344 }
345 if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
346 active_tool_labels.push(label);
347 }
348 }
349 let attention = provider
350 .interactions
351 .iter()
352 .find(|interaction| {
353 matches!(
354 interaction.status,
355 ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
356 )
357 })
358 .map(|interaction| AgentProgressAttentionV1 {
359 kind: match interaction.interaction_kind {
360 ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
361 ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
362 },
363 tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
364 });
365 let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
366 let usage = (provider.completed_turns > 0
367 || provider.usage.input_tokens > 0
368 || provider.usage.output_tokens > 0
369 || provider.usage.cache_read_tokens > 0
370 || provider.usage.cache_write_tokens > 0
371 || provider.usage.reasoning_tokens > 0)
372 .then_some(AgentProgressUsageV1 {
373 input_tokens: provider.usage.input_tokens,
374 output_tokens: provider.usage.output_tokens,
375 cache_read_tokens: provider.usage.cache_read_tokens,
376 cache_write_tokens: provider.usage.cache_write_tokens,
377 reasoning_tokens: provider.usage.reasoning_tokens,
378 });
379 let progress = AgentProgressV1 {
380 provider_sequence: provider.sequence,
381 activity: provider.activity,
382 completed_turns: provider.completed_turns,
383 usage,
384 current: AgentProgressCurrentV1::from(provider.activity),
385 active_tool_labels,
386 active_tool_count,
387 attention,
388 subagent_count,
389 last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
390 gap_count: provider.gap_count,
391 stale: provider.stale,
392 truncated,
393 };
394 let entry = SessionAgentProgress { address, progress };
395 serde_json::to_vec(&entry)
396 .ok()
397 .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
398 .map(|_| entry)
399}
400
401fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
402 match u32::try_from(count) {
403 Ok(count) => count,
404 Err(_) => {
405 *truncated = true;
406 u32::MAX
407 }
408 }
409}
410
411fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
412 if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
413 return None;
414 }
415 Some(match event {
416 ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
417 ProviderEvent::SessionIdentityObserved { .. } => {
418 AgentProgressEventKindV1::SessionIdentityObserved
419 }
420 ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
421 ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
422 ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
423 ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
424 ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
425 ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
426 ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
427 ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
428 ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
429 ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
430 ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
431 ProviderEvent::InteractionRequested { .. } => {
432 AgentProgressEventKindV1::InteractionRequested
433 }
434 ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
435 ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
436 ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
437 ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
438 ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
439 ProviderEvent::UnrecognizedNotification { .. } => {
440 AgentProgressEventKindV1::UnrecognizedNotification
441 }
442 ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
443 ProviderEvent::UserMessage { .. }
454 | ProviderEvent::Plan { .. }
455 | ProviderEvent::AvailableCommandsUpdated { .. }
456 | ProviderEvent::ModeChanged { .. }
457 | ProviderEvent::SessionInfoUpdated { .. }
458 | ProviderEvent::UsageUpdated { .. }
459 | ProviderEvent::ConfigOptionsUpdated { .. } => {
460 AgentProgressEventKindV1::UnrecognizedNotification
461 }
462 })
463}
464
465fn provider_family_is_live(family: AdapterFamily) -> bool {
478 match family {
479 AdapterFamily::PtySemantic
480 | AdapterFamily::Hook
481 | AdapterFamily::ManagedHook
482 | AdapterFamily::Pipe
483 | AdapterFamily::OneShot
484 | AdapterFamily::Acp => true,
485 AdapterFamily::History
486 | AdapterFamily::Resume
487 | AdapterFamily::SessionOptions
488 | AdapterFamily::CapabilityProbe => false,
489 }
490}
491
492fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
493 match by {
494 ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
495 ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
496 ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
497 ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
498 }
499}
500
501fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
513 match authority {
514 BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
515 BlockAuthorityV1::HostPolicy => "denied by host policy",
516 BlockAuthorityV1::HostDeadline => {
517 "denied by host policy after the operator deadline expired"
518 }
519 BlockAuthorityV1::Operator => "declined by the operator",
520 BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
521 BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
522 BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
523 BlockAuthorityV1::ProviderRefusal => "refused by the provider",
524 BlockAuthorityV1::UserRejected => "rejected by the user",
525 BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
526 BlockAuthorityV1::Unknown => "blocked; no reason was reported",
527 }
528}
529
530fn host_request_denied_block(
533 method: &str,
534 by: ProviderHostDecisionAuthority,
535 reason: Option<&str>,
536) -> (String, BlockAuthorityV1, String) {
537 let authority = block_authority(by);
538 let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
539 let (reason_text, _) =
543 truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
544 (tool_class_label(method), authority, reason_text)
545}
546
547fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
566 match kind? {
567 "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
568 "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
569 _ => None,
570 }
571}
572
573fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
585 let trimmed = text.trim();
586 let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
587 return (trimmed.to_owned(), None);
588 };
589 let tail = after_reason.trim_start();
590 let Some((reason, help)) = tail.split_once(". ") else {
591 let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
595 return (reason.to_owned(), None);
596 };
597 let help = help.trim();
598 if help.is_empty() {
599 (reason.trim().to_owned(), None)
600 } else {
601 (reason.trim().to_owned(), Some(help.to_owned()))
602 }
603}
604
605fn tool_class_from_denial_help(help: Option<&str>) -> String {
615 help.and_then(|text| text.split("add a ").nth(1))
616 .and_then(|tail| tail.split(' ').next())
617 .filter(|word| !word.is_empty())
618 .map(str::to_owned)
619 .unwrap_or_else(|| "Tool".to_owned())
620}
621
622fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
630 fn names_quota(s: &str) -> bool {
631 let lower = s.to_ascii_lowercase();
632 lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
633 }
634 vendor_code.is_some_and(names_quota) || names_quota(message)
635}
636
637fn provider_session_end_block(
653 stop_reason: &ProviderStopReason,
654) -> Option<(BlockAuthorityV1, String, String)> {
655 match stop_reason {
656 ProviderStopReason::Refusal => Some((
657 BlockAuthorityV1::ProviderRefusal,
658 "refusal".to_owned(),
659 "the provider ended the turn with stopReason: refusal".to_owned(),
660 )),
661 ProviderStopReason::ProviderError { message, vendor_code, .. }
662 if provider_error_is_quota(vendor_code.as_deref(), message) =>
663 {
664 let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
665 Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
666 }
667 _ => None,
668 }
669}
670
671fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
712 let ControlEventKind::ProviderEvent {
713 sequence: provider_sequence,
714 source,
715 source_sequence,
716 event: provider_event,
717 } = &event.event
718 else {
719 return None;
720 };
721 if !provider_family_is_live(source.family) {
722 return None;
723 }
724 let kind = match provider_event {
725 ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
726 text: text.clone(),
727 is_delta: *is_delta,
728 },
729 ProviderEvent::Thinking { text } => {
730 AgentStreamChunkKindV1::Thinking { text: text.clone() }
731 }
732 ProviderEvent::InteractionRequested {
733 interaction_kind,
734 tool_name,
735 title,
736 prompt,
737 options,
738 ..
739 } => AgentStreamChunkKindV1::InteractionPrompt {
740 correlation_id: interaction_correlation(
741 event.instance_id,
742 event.generation,
743 *provider_sequence,
744 ),
745 interaction_kind: *interaction_kind,
746 tool_name: tool_name.clone(),
747 title: title.clone(),
756 prompt: prompt.clone(),
757 options: options
758 .iter()
759 .map(|option| AgentStreamInteractionOptionV1 {
760 option_id: option.option_id.clone(),
761 name: option.name.clone(),
762 kind: option.kind.clone(),
763 })
764 .collect(),
765 },
766 ProviderEvent::ConfigOptionsUpdated { options } => {
767 AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
768 }
769 ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
780 current: Some(mode_id.clone()),
781 available: available
782 .iter()
783 .map(|mode| AgentStreamNamedIdV1 {
784 id: mode.id.clone(),
785 name: mode.name.clone(),
786 description: mode.description.clone(),
787 })
788 .collect(),
789 },
790 ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
794 let ProviderHostRequestDecision::Denied { by } = decision else {
795 return None;
796 };
797 let (tool_class, authority, reason_text) =
798 host_request_denied_block(method, *by, reason.as_deref());
799 AgentStreamChunkKindV1::Blocked {
800 correlation_id: None,
801 tool_class,
802 authority,
803 reason_kind: None,
804 reason: reason_text,
805 help: None,
806 }
807 }
808 ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
812 let (authority, reason_kind) =
813 provider_tool_block_authority(non_execution_kind.as_deref())?;
814 let (reason, help) = split_provider_denial_text(output);
815 let (reason, _) =
816 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
817 let help = help.map(|help| {
818 truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
819 });
820 AgentStreamChunkKindV1::Blocked {
821 correlation_id: Some(tool_correlation(event, source, id)),
822 tool_class: tool_class_from_denial_help(help.as_deref()),
823 authority,
824 reason_kind: Some(reason_kind.to_owned()),
825 reason,
826 help,
827 }
828 }
829 ProviderEvent::SessionEnded { stop_reason, .. } => {
833 let (authority, reason_kind, reason) =
834 stop_reason.as_ref().and_then(provider_session_end_block)?;
835 let (reason, _) =
836 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
837 AgentStreamChunkKindV1::Blocked {
838 correlation_id: None,
839 tool_class: "Session".to_owned(),
840 authority,
841 reason_kind: Some(reason_kind),
842 reason,
843 help: None,
844 }
845 }
846 ProviderEvent::SessionStarted { .. }
847 | ProviderEvent::SessionIdentityObserved { .. }
848 | ProviderEvent::TurnStarted { .. }
849 | ProviderEvent::WorkingObserved
850 | ProviderEvent::ToolStarted { .. }
851 | ProviderEvent::TurnCompleted { .. }
852 | ProviderEvent::ContextWindowUsage { .. }
853 | ProviderEvent::TurnInterrupted
854 | ProviderEvent::Error { .. }
855 | ProviderEvent::Ready
856 | ProviderEvent::InteractionResolved { .. }
857 | ProviderEvent::SubagentStarted { .. }
858 | ProviderEvent::SubagentStopped { .. }
859 | ProviderEvent::RateLimited { .. }
860 | ProviderEvent::UnrecognizedNotification { .. }
861 | ProviderEvent::UserMessage { .. }
862 | ProviderEvent::Plan { .. }
863 | ProviderEvent::AvailableCommandsUpdated { .. }
864 | ProviderEvent::SessionInfoUpdated { .. }
865 | ProviderEvent::UsageUpdated { .. } => return None,
866 };
867 Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
868}
869
870async fn run_native_session_catalog_operation<T, E, F>(
871 catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
872 operation: &'static str,
873 execute: F,
874) -> Result<T, NodeFailure>
875where
876 T: Send + 'static,
877 E: std::fmt::Display + Send + 'static,
878 F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
879{
880 let task = tokio::task::spawn_blocking(move || {
881 let mut catalog = match catalog.try_lock() {
882 Ok(catalog) => catalog,
883 Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
884 Err(std::sync::TryLockError::WouldBlock) => {
885 return Err(failure(
886 NodeFailureCode::BackendBusy,
887 "native session catalog operation is already running",
888 ));
889 }
890 };
891 execute(&mut catalog).map_err(|error| {
892 failure(
893 NodeFailureCode::BackendOperationFailed,
894 &format!("{operation} failed: {error}"),
895 )
896 })
897 });
898 timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
899 .await
900 .map_err(|_| {
901 failure(
902 NodeFailureCode::BackendBusy,
903 &format!("{operation} exceeded its bounded deadline"),
904 )
905 })?
906 .map_err(|_| {
907 failure(
908 NodeFailureCode::BackendOperationFailed,
909 &format!("{operation} task failed"),
910 )
911 })?
912}
913
914fn windows_path_text(path: &OpaqueHostPath) -> &str {
915 path.as_utf8()
916 .expect("Windows node only creates UTF-8 opaque host paths")
917}
918
919fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
920 path.as_utf8()
921 .map(str::to_owned)
922 .ok_or_else(|| failure(
923 NodeFailureCode::InvalidRequest,
924 "this node requires UTF-8 host paths",
925 ))
926}
927
928fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
929 GitWorktreeSnapshot {
930 path: opaque_windows_path(worktree.path),
931 head: worktree.head,
932 branch: worktree.branch,
933 is_bare: worktree.is_bare,
934 is_main: worktree.is_main,
935 locked: worktree.locked,
936 lock_reason: worktree.lock_reason,
937 prunable: worktree.prunable,
938 prunable_reason: worktree.prunable_reason,
939 workspace_id: worktree.workspace_id.take(),
940 }
941}
942
943#[derive(Clone, Debug, Eq, PartialEq)]
944pub struct WorkspaceConfig {
945 workspace_id: WorkspaceId,
946 canonical_root: String,
947 worktree_service_mode: WorktreeServiceMode,
948 managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
949}
950
951impl WorkspaceConfig {
952 pub fn new(
953 workspace_id: WorkspaceId,
954 root: impl AsRef<Path>,
955 ) -> Result<Self, NodeServerError> {
956 let root = root.as_ref();
957 if !root.is_absolute() {
958 return Err(NodeServerError::InvalidWorkspaceRoot {
959 workspace_id,
960 path: root.to_string_lossy().into_owned(),
961 message: "path is not absolute".to_owned(),
962 });
963 }
964 let canonical = std::fs::canonicalize(root).map_err(|error| {
965 NodeServerError::InvalidWorkspaceRoot {
966 workspace_id: workspace_id.clone(),
967 path: root.to_string_lossy().into_owned(),
968 message: error.to_string(),
969 }
970 })?;
971 if !canonical.is_dir() {
972 return Err(NodeServerError::InvalidWorkspaceRoot {
973 workspace_id,
974 path: root.to_string_lossy().into_owned(),
975 message: "path is not a directory".to_owned(),
976 });
977 }
978 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
979 NodeServerError::InvalidWorkspaceRoot {
980 workspace_id: workspace_id.clone(),
981 path: path.to_string_lossy().into_owned(),
982 message: "canonical path is not valid Unicode".to_owned(),
983 }
984 })?;
985 let canonical_root = platform::normalize_canonical_root(canonical_root);
986 validate_workspace_root(&workspace_id, &canonical_root)?;
987 Ok(Self {
988 workspace_id,
989 canonical_root,
990 worktree_service_mode: WorktreeServiceMode::Manual,
991 managed_worktree_profiles: BTreeMap::new(),
992 })
993 }
994
995 pub fn workspace_id(&self) -> &WorkspaceId {
996 &self.workspace_id
997 }
998
999 pub fn canonical_root(&self) -> &str {
1000 &self.canonical_root
1001 }
1002
1003 pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1004 self.worktree_service_mode = mode;
1005 self
1006 }
1007
1008 pub fn with_managed_worktree_profile(
1009 mut self,
1010 profile: ManagedWorktreeProfile,
1011 ) -> Result<Self, NodeServerError> {
1012 profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1013 NodeServerError::InvalidManagedWorktreeProfile {
1014 workspace_id: self.workspace_id.clone(),
1015 profile_id: profile.profile_id().clone(),
1016 message,
1017 }
1018 })?;
1019 if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1020 return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1021 workspace_id: self.workspace_id,
1022 profile_id: profile.profile_id().clone(),
1023 });
1024 }
1025 if self.managed_worktree_profiles.len()
1026 == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1027 {
1028 return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1029 workspace_id: self.workspace_id,
1030 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1031 });
1032 }
1033 self.managed_worktree_profiles
1034 .insert(profile.profile_id().clone(), profile);
1035 Ok(self)
1036 }
1037}
1038
1039#[derive(Clone)]
1040pub struct NodeServerConfig {
1041 pub endpoint: String,
1042 api_listen: Option<std::net::SocketAddr>,
1043 bridge_listen: Option<std::net::SocketAddr>,
1046 bridge_token: Option<String>,
1049 bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1052 call_home: Option<std::net::SocketAddr>,
1055 pub node_id: NodeId,
1056 pub workspaces: Vec<WorkspaceConfig>,
1057 access_token: String,
1058 pub runtime: NativeRuntimeConfig,
1059 session_record_retention: SessionRecordRetentionConfig,
1060 state_path: Option<PathBuf>,
1061 spawn_profiles: SpawnProfileRegistry,
1062 session_environment: Option<NodeSessionEnvironmentConfig>,
1063 history: Option<NativeHistoryConfig>,
1064 harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1065 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1069 #[cfg(feature = "fixture")]
1070 fixture_raw_pty_runtime: bool,
1071}
1072
1073#[derive(Clone)]
1074struct NodeSessionEnvironmentConfig {
1075 root: PathBuf,
1076 resolver: Arc<dyn NodeSecretResolver>,
1077}
1078
1079impl NodeServerConfig {
1080 pub fn new(
1081 endpoint: impl Into<String>,
1082 access_token: impl Into<String>,
1083 node_id: NodeId,
1084 workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1085 ) -> Result<Self, NodeServerError> {
1086 let endpoint = endpoint.into();
1087 let access_token = access_token.into();
1088 if !platform::validate_endpoint(&endpoint) {
1089 return Err(NodeServerError::InvalidEndpoint);
1090 }
1091 if access_token.is_empty() || access_token.len() > 4_096 {
1092 return Err(NodeServerError::InvalidAccessToken);
1093 }
1094 let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1095 if workspaces.is_empty() {
1096 return Err(NodeServerError::NoWorkspaces);
1097 }
1098 let mut workspace_ids = BTreeMap::new();
1099 let mut workspace_roots = BTreeMap::new();
1100 for workspace in &workspaces {
1101 if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1102 && workspace.managed_worktree_profiles.is_empty()
1103 {
1104 return Err(NodeServerError::ManagedWorktreeProfileRequired(
1105 workspace.workspace_id.clone(),
1106 ));
1107 }
1108 if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1109 && !workspace.managed_worktree_profiles.is_empty()
1110 {
1111 return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1112 workspace.workspace_id.clone(),
1113 ));
1114 }
1115 if workspace_ids
1116 .insert(workspace.workspace_id.clone(), ())
1117 .is_some()
1118 {
1119 return Err(NodeServerError::DuplicateWorkspaceId(
1120 workspace.workspace_id.clone(),
1121 ));
1122 }
1123 let root_key = platform::root_identity(&workspace.canonical_root);
1124 if let Some(existing) = workspace_roots.insert(
1125 root_key,
1126 workspace.workspace_id.clone(),
1127 ) {
1128 return Err(NodeServerError::DuplicateWorkspaceRoot {
1129 first: existing,
1130 second: workspace.workspace_id.clone(),
1131 root: workspace.canonical_root.clone(),
1132 });
1133 }
1134 }
1135 Ok(Self {
1136 endpoint,
1137 api_listen: None,
1138 bridge_listen: None,
1139 bridge_token: None,
1140 bridge_underlay: None,
1141 call_home: None,
1142 node_id,
1143 workspaces,
1144 access_token,
1145 runtime: NativeRuntimeConfig::default(),
1146 session_record_retention: SessionRecordRetentionConfig::default(),
1147 state_path: None,
1148 spawn_profiles: SpawnProfileRegistry::default(),
1149 session_environment: None,
1150 history: None,
1151 harness_mcp_helper: None,
1152 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1153 #[cfg(feature = "fixture")]
1154 fixture_raw_pty_runtime: false,
1155 })
1156 }
1157
1158 pub fn with_api_listen(
1159 mut self,
1160 api_listen: std::net::SocketAddr,
1161 ) -> Result<Self, NodeServerError> {
1162 if !api_listen.ip().is_loopback() {
1163 return Err(NodeServerError::InvalidApiListen(api_listen));
1164 }
1165 self.api_listen = Some(api_listen);
1166 Ok(self)
1167 }
1168
1169 pub fn with_bridge_listen(
1173 mut self,
1174 bridge_listen: std::net::SocketAddr,
1175 ) -> Result<Self, NodeServerError> {
1176 if !bridge_listen.ip().is_loopback() {
1177 return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1178 }
1179 self.bridge_listen = Some(bridge_listen);
1180 Ok(self)
1181 }
1182
1183 pub fn with_bridge_token(
1186 mut self,
1187 bridge_token: impl Into<String>,
1188 ) -> Result<Self, NodeServerError> {
1189 let bridge_token = bridge_token.into();
1190 if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1191 return Err(NodeServerError::InvalidBridgeToken);
1192 }
1193 self.bridge_token = Some(bridge_token);
1194 Ok(self)
1195 }
1196
1197 pub fn with_bridge_underlay(
1202 mut self,
1203 bind: std::net::SocketAddr,
1204 transport_key: [u8; 32],
1205 underlay_token: impl Into<String>,
1206 ) -> Result<Self, NodeServerError> {
1207 if self.bridge_listen.is_none() {
1208 return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1209 }
1210 if self.bridge_token.is_none() {
1211 return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1212 }
1213 let underlay_token = underlay_token.into();
1214 if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1215 return Err(NodeServerError::InvalidBridgeUnderlayToken);
1216 }
1217 self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1219 bind,
1220 transport_key,
1221 underlay_token,
1222 });
1223 Ok(self)
1224 }
1225
1226 pub fn with_call_home(
1247 mut self,
1248 relay: std::net::SocketAddr,
1249 ) -> Result<Self, NodeServerError> {
1250 if relay.port() == 0 || relay.ip().is_unspecified() {
1253 return Err(NodeServerError::InvalidCallHome(relay));
1254 }
1255 self.call_home = Some(relay);
1256 Ok(self)
1257 }
1258
1259 pub fn with_state_path(
1260 mut self,
1261 state_path: impl Into<PathBuf>,
1262 ) -> Result<Self, NodeServerError> {
1263 let state_path = state_path.into();
1264 if !state_path.is_absolute() || state_path.file_name().is_none() {
1265 return Err(NodeServerError::InvalidStatePath(
1266 state_path.to_string_lossy().into_owned(),
1267 ));
1268 }
1269 self.state_path = Some(state_path);
1270 Ok(self)
1271 }
1272
1273 pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1274 self.spawn_profiles = spawn_profiles;
1275 self
1276 }
1277
1278 pub fn with_session_record_retention(
1284 mut self,
1285 retention: SessionRecordRetentionConfig,
1286 ) -> Self {
1287 self.session_record_retention = retention;
1288 self
1289 }
1290
1291 pub fn with_session_environment_materialization(
1292 mut self,
1293 root: impl Into<PathBuf>,
1294 resolver: Arc<dyn NodeSecretResolver>,
1295 ) -> Result<Self, NodeServerError> {
1296 let root = root.into();
1297 if !root.is_absolute() {
1298 return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1299 }
1300 self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1301 Ok(self)
1302 }
1303
1304 pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1307 self.history = Some(history);
1308 self
1309 }
1310
1311 pub fn with_harness_mcp_helper(
1317 mut self,
1318 helper_program: impl Into<PathBuf>,
1319 ) -> Result<Self, NodeServerError> {
1320 self.harness_mcp_helper = Some(
1321 ReviewedHarnessMcpProgram::review(helper_program.into())
1322 .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1323 );
1324 Ok(self)
1325 }
1326
1327 pub fn with_network_allowlist_catalog(
1332 mut self,
1333 catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1334 ) -> Result<Self, NodeServerError> {
1335 if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1336 return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1337 max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1338 });
1339 }
1340 self.network_allowlist_catalog = catalog;
1341 Ok(self)
1342 }
1343
1344 pub fn with_network_allowlist_catalog_file(
1348 self,
1349 path: impl AsRef<std::path::Path>,
1350 ) -> Result<Self, NodeServerError> {
1351 let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1352 .map_err(NodeServerError::from)?;
1353 self.with_network_allowlist_catalog(catalog)
1354 }
1355}
1356
1357pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1358 platform::default_state_path(node_id.as_str())
1359 .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1360}
1361
1362pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1363 platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1364}
1365
1366fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1367 let parent = state_path.parent()?;
1368 let mut name = state_path.file_name()?.to_os_string();
1369 name.push(".delivery-store");
1370 Some(parent.join(name))
1371}
1372
1373fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1374 let parent = state_path.parent()?;
1375 let mut name = state_path.file_name()?.to_os_string();
1376 name.push(".context-pack-store");
1377 Some(parent.join(name))
1378}
1379
1380pub struct NodeServer {
1381 config: NodeServerConfig,
1382 runtime: NativeRuntime,
1383 shared: Arc<NodeShared>,
1384 events: EventSubscription,
1385 state_path_lock: Option<session_registry::StatePathLock>,
1386}
1387
1388#[cfg(feature = "fixture")]
1389#[derive(Clone, Default)]
1390pub struct SpawnManagedWorktreeV2FailureProbe {
1391 latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1392}
1393
1394#[cfg(feature = "fixture")]
1395impl SpawnManagedWorktreeV2FailureProbe {
1396 pub fn latest_code(&self) -> Option<NodeFailureCode> {
1397 *self
1398 .latest_code
1399 .lock()
1400 .unwrap_or_else(|poisoned| poisoned.into_inner())
1401 }
1402
1403 fn record(&self, code: Option<NodeFailureCode>) {
1404 *self
1405 .latest_code
1406 .lock()
1407 .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1408 }
1409}
1410
1411#[cfg(all(feature = "fixture", windows))]
1412struct IsolatedCodexHomeFixtureEnvironment;
1413
1414#[cfg(all(feature = "fixture", windows))]
1415impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1416 for IsolatedCodexHomeFixtureEnvironment
1417{
1418 fn resolve_child_environment(
1419 &self,
1420 ) -> Result<
1421 Vec<gate4agent_catalog::EnvMutation>,
1422 gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1423 > {
1424 Ok(vec![gate4agent_catalog::EnvMutation {
1425 key: OsString::from("USERPROFILE"),
1426 value: None,
1427 }])
1428 }
1429}
1430
1431impl NodeServer {
1432 pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1433 let catalog = active_registry()?;
1434 Self::new_with_registry(config, catalog)
1435 }
1436
1437 #[cfg(feature = "fixture")]
1438 pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1439 let mut spec = gate4agent_testkit::interactive_agent_spec();
1440 spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1441 spec.display_name = "Controlled Claude PTY fixture".to_owned();
1442 Self::new_fixture_with_spec(config, spec)
1443 }
1444
1445 #[cfg(feature = "fixture")]
1446 pub fn new_clean_exit_fixture(
1447 config: NodeServerConfig,
1448 fixture_root: PathBuf,
1449 started_marker: PathBuf,
1450 release_signal: PathBuf,
1451 ) -> Result<Self, NodeServerError> {
1452 let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1453 &fixture_root,
1454 &started_marker,
1455 &release_signal,
1456 )
1457 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1458 spec.id = AgentId::new("claude")
1459 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1460 spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1461 Self::new_fixture_with_spec(config, spec)
1462 }
1463
1464 #[cfg(all(feature = "fixture", windows))]
1474 pub fn new_durable_context_pack_clean_exit_fixture(
1475 config: NodeServerConfig,
1476 fixture_root: PathBuf,
1477 started_marker: PathBuf,
1478 release_signal: PathBuf,
1479 session_id: &str,
1480 ) -> Result<Self, NodeServerError> {
1481 let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1482 &fixture_root,
1483 &started_marker,
1484 &release_signal,
1485 session_id,
1486 )
1487 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1488 let source_id = AgentId::new("claude")
1489 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1490 let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1491 NodeServerError::Registry(
1492 "durable context pack clean-exit fixture source adapter is unavailable"
1493 .to_owned(),
1494 )
1495 })?;
1496 source_spec.capabilities.adapters.history =
1497 source_provider.capabilities.adapters.history.clone();
1498 source_spec.id = source_id;
1499 source_spec.display_name =
1500 "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1501 source_spec.detection.command =
1507 "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1508 source_spec.detection.aliases.clear();
1509
1510 let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1511 target_spec.id = AgentId::new("codex")
1512 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1513 target_spec.display_name =
1514 "Controlled Codex durable context-pack target fixture".to_owned();
1515 target_spec.detection.command =
1516 "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1517 target_spec.detection.aliases.clear();
1518
1519 let catalog = AgentRegistry::new([source_spec, target_spec])
1520 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1521 let mut server = Self::new_with_registry(config, catalog)?;
1522 Arc::get_mut(&mut server.shared)
1523 .expect("fixture Node shared state must remain uniquely owned before run")
1524 .fixture_semantic_hook_policy = true;
1525 Ok(server)
1526 }
1527
1528 #[cfg(feature = "fixture")]
1529 pub fn new_monitoring_hook_fixture(
1530 config: NodeServerConfig,
1531 ) -> Result<Self, NodeServerError> {
1532 let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1533 spec.id = AgentId::new("claude")
1534 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1535 let mut server = Self::new_fixture_with_spec(config, spec)?;
1536 Arc::get_mut(&mut server.shared)
1537 .expect("fixture Node shared state must remain uniquely owned before run")
1538 .fixture_semantic_hook_policy = true;
1539 Ok(server)
1540 }
1541
1542 #[cfg(all(feature = "fixture", windows))]
1543 pub fn new_provider_bundle_argv_fixture(
1544 config: NodeServerConfig,
1545 agent_id: AgentId,
1546 proof_path: PathBuf,
1547 ) -> Result<Self, NodeServerError> {
1548 let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1549 Self::new_fixture_with_spec(config, spec)
1550 }
1551
1552 #[cfg(all(feature = "fixture", windows))]
1553 pub fn new_provider_bundle_argv_hold_fixture(
1554 config: NodeServerConfig,
1555 agent_id: AgentId,
1556 proof_path: PathBuf,
1557 release_signal: PathBuf,
1558 ) -> Result<Self, NodeServerError> {
1559 let spec = Self::provider_bundle_argv_fixture_spec(
1560 agent_id,
1561 proof_path,
1562 Some(release_signal),
1563 )?;
1564 Self::new_fixture_with_spec(config, spec)
1565 }
1566
1567 #[cfg(all(feature = "fixture", windows))]
1568 fn provider_bundle_argv_fixture_spec(
1569 agent_id: AgentId,
1570 proof_path: PathBuf,
1571 release_signal: Option<PathBuf>,
1572 ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1573 if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1574 return Err(NodeServerError::Registry(
1575 "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1576 ));
1577 }
1578 let mut spec = gate4agent_testkit::interactive_agent_spec();
1579 spec.id = agent_id.clone();
1580 spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1581 let provider = builtin_registry()
1582 .get(&agent_id)
1583 .ok_or_else(|| {
1584 NodeServerError::Registry(
1585 "provider bundle argv fixture adapter is unavailable".to_owned(),
1586 )
1587 })?;
1588 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1589 if !proof_path.is_absolute()
1590 || !proof_path
1591 .parent()
1592 .is_some_and(|parent| parent.is_dir())
1593 {
1594 return Err(NodeServerError::Registry(
1595 "provider bundle argv proof path requires an existing absolute parent"
1596 .to_owned(),
1597 ));
1598 }
1599 if let Some(release_signal) = release_signal.as_ref() {
1600 if !release_signal.is_absolute()
1601 || !release_signal
1602 .parent()
1603 .is_some_and(|parent| parent.is_dir())
1604 {
1605 return Err(NodeServerError::Registry(
1606 "provider bundle argv release path requires an existing absolute parent"
1607 .to_owned(),
1608 ));
1609 }
1610 if release_signal.exists() {
1611 return Err(NodeServerError::Registry(
1612 "provider bundle argv release path must not exist".to_owned(),
1613 ));
1614 }
1615 let proof_parent = std::fs::canonicalize(
1616 proof_path
1617 .parent()
1618 .expect("validated provider bundle proof parent"),
1619 )
1620 .map_err(|_| {
1621 NodeServerError::Registry(
1622 "provider bundle argv proof parent is unavailable".to_owned(),
1623 )
1624 })?;
1625 let release_parent = std::fs::canonicalize(
1626 release_signal
1627 .parent()
1628 .expect("validated provider bundle release parent"),
1629 )
1630 .map_err(|_| {
1631 NodeServerError::Registry(
1632 "provider bundle argv release parent is unavailable".to_owned(),
1633 )
1634 })?;
1635 if proof_parent != release_parent {
1636 return Err(NodeServerError::Registry(
1637 "provider bundle argv release path must share the proof parent".to_owned(),
1638 ));
1639 }
1640 }
1641 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1642 NodeServerError::Registry(
1643 "provider bundle argv proof path is not valid Unicode".to_owned(),
1644 )
1645 })?;
1646 let release_signal = release_signal.map(|release_signal| {
1647 release_signal.into_os_string().into_string().map_err(|_| {
1648 NodeServerError::Registry(
1649 "provider bundle argv release path is not valid Unicode".to_owned(),
1650 )
1651 })
1652 }).transpose()?;
1653 let script = spec
1654 .launch
1655 .fixed_args
1656 .pop()
1657 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1658 let bundle_validation = if agent_id.as_str() == "codex" {
1659 "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') }"
1660 } else if agent_id.as_str() == "claude" {
1661 "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--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 }"
1662 } else {
1663 "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--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 }"
1664 };
1665 if let Some(release_signal) = release_signal {
1666 spec.launch.fixed_args.push(format!(
1667 "& {{ 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} }}",
1668 ));
1669 spec.launch.fixed_args.push(proof_path);
1670 spec.launch.fixed_args.push(release_signal);
1671 } else {
1672 spec.launch.fixed_args.push(format!(
1673 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1674 ));
1675 spec.launch.fixed_args.push(proof_path);
1676 }
1677 Ok(spec)
1678 }
1679
1680 #[cfg(all(feature = "fixture", windows))]
1681 pub fn new_context_pack_fixture(
1682 config: NodeServerConfig,
1683 proof_path: PathBuf,
1684 ) -> Result<Self, NodeServerError> {
1685 let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1686 Self::new_with_registry(config, catalog)
1687 }
1688
1689 #[cfg(all(feature = "fixture", windows))]
1690 pub fn new_monitoring_context_pack_fixture(
1691 config: NodeServerConfig,
1692 proof_path: PathBuf,
1693 ) -> Result<Self, NodeServerError> {
1694 let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1695 let mut server = Self::new_with_registry(config, catalog)?;
1696 Arc::get_mut(&mut server.shared)
1697 .expect("fixture Node shared state must remain uniquely owned before run")
1698 .fixture_semantic_hook_policy = true;
1699 Ok(server)
1700 }
1701
1702 #[cfg(all(feature = "fixture", windows))]
1703 pub fn install_isolated_codex_home_environment_fixture(
1704 &mut self,
1705 profile_id: SpawnEnvironmentProfileId,
1706 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1707 ) -> Result<(), NodeServerError> {
1708 let provider = AgentId::new("codex")
1709 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1710 let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1711 gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1712 "{}-pty",
1713 profile_id.as_str(),
1714 ))
1715 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1716 provider.clone(),
1717 TransportKind::Pty,
1718 vec![OsString::from("USERPROFILE")],
1719 Arc::new(IsolatedCodexHomeFixtureEnvironment),
1720 )
1721 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1722 let materialization = NodeSessionMaterializationProfile::new(
1723 Vec::new(),
1724 vec![crate::session_environment::NodeSessionPathBinding::new(
1725 "CODEX_HOME",
1726 crate::session_environment::NodeSessionPathClass::ProviderHome,
1727 )
1728 .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1729 Vec::new(),
1730 )
1731 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1732 self.install_environment_profile(
1733 NodeEnvironmentProfile::new_with_materialization(
1734 profile_id,
1735 profile_revision,
1736 provider,
1737 [native_profile],
1738 Some(materialization),
1739 )
1740 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1741 )
1742 }
1743
1744 #[cfg(all(feature = "fixture", windows))]
1745 pub fn new_context_only_proof_fixture(
1746 config: NodeServerConfig,
1747 target_provider: AgentId,
1748 proof_path: PathBuf,
1749 ) -> Result<Self, NodeServerError> {
1750 if target_provider.as_str() != "kimi" {
1751 return Err(NodeServerError::Registry(
1752 "context-only proof fixture requires Kimi".to_owned(),
1753 ));
1754 }
1755 let catalog = Self::context_pack_fixture_catalog(
1756 proof_path,
1757 Some(&target_provider),
1758 false,
1759 )?;
1760 Self::new_with_registry(config, catalog)
1761 }
1762
1763 #[cfg(all(feature = "fixture", windows))]
1764 fn context_pack_fixture_catalog(
1765 proof_path: PathBuf,
1766 context_only_target: Option<&AgentId>,
1767 monitoring_claude_source: bool,
1768 ) -> Result<AgentRegistry, NodeServerError> {
1769 if !proof_path.is_absolute()
1770 || !proof_path
1771 .parent()
1772 .is_some_and(|parent| parent.is_dir())
1773 {
1774 return Err(NodeServerError::Registry(
1775 "context pack proof path requires an existing absolute parent".to_owned(),
1776 ));
1777 }
1778 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1779 NodeServerError::Registry(
1780 "context pack proof path is not valid Unicode".to_owned(),
1781 )
1782 })?;
1783 let providers = ["claude", "codex", "grok", "kimi"];
1784 let builtins = builtin_registry();
1785 let mut specs = Vec::with_capacity(providers.len());
1786 for provider_id in providers {
1787 let agent_id = AgentId::new(provider_id)
1788 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1789 let provider = builtins.get(&agent_id).ok_or_else(|| {
1790 NodeServerError::Registry(
1791 "context pack fixture provider adapter is unavailable".to_owned(),
1792 )
1793 })?;
1794 let monitored_provider =
1795 monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1796 let mut spec = if monitored_provider {
1797 gate4agent_testkit::monitoring_hook_agent_spec()
1798 } else {
1799 gate4agent_testkit::interactive_agent_spec()
1800 };
1801 spec.id = agent_id.clone();
1802 spec.display_name = format!("Controlled {agent_id} context pack fixture");
1803 spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1804 spec.detection.aliases.clear();
1805 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1806 if provider_id == "codex" {
1807 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1808 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1809 })?;
1810 let validation = r#"
1811$bundleArgs = @($bundleArgs)
1812if ($bundleArgs.Count -ne 0) { exit 91 }
1813$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1814$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1815$cwd = (Get-Location).ProviderPath
1816if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1817$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1818if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1819$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1820if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1821$sha = [Security.Cryptography.SHA256]::Create()
1822try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1823if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1824if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1825$contextPath = Join-Path $contextRoot 'context-pack.json'
1826if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1827$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1828if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1829$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1830$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1831$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1832$separator = [IO.Path]::DirectorySeparatorChar
1833if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1834if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1835$rawContext = [IO.File]::ReadAllText($contextPath)
1836if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1837try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1838$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1839$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1840foreach ($property in @($document.PSObject.Properties.Name)) {
1841 if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1842}
1843foreach ($property in $requiredTopLevel) {
1844 if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1845}
1846if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1847if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1848$messages = @($document.retained_messages)
1849if ($messages.Count -eq 0) { exit 107 }
1850foreach ($message in $messages) {
1851 $messageProperties = @($message.PSObject.Properties.Name)
1852 foreach ($property in $messageProperties) {
1853 if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1854 }
1855 if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1856}
1857$sha = [Security.Cryptography.SHA256]::Create()
1858try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1859[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))
1860$rawProof = [IO.File]::ReadAllText($proofPath)
1861if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1862[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1863"#;
1864 spec.launch.fixed_args.push(format!(
1865 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1866 ));
1867 spec.launch.fixed_args.push(proof_path.clone());
1868 } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1869 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1870 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1871 })?;
1872 let validation = r#"
1873$bundleArgs = @($bundleArgs)
1874if ($bundleArgs.Count -ne 0) { exit 91 }
1875$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1876$cwd = (Get-Location).ProviderPath
1877if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1878$contextPath = Join-Path $contextRoot 'context-pack.json'
1879if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1880$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1881if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1882$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1883$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1884$separator = [IO.Path]::DirectorySeparatorChar
1885if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1886try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1887if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1888if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1889$messages = @($document.retained_messages)
1890if ($messages.Count -eq 0) { exit 99 }
1891$hasUser = $false
1892$hasAssistant = $false
1893foreach ($message in $messages) {
1894 if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1895 if ([string]$message.role -ceq 'user') { $hasUser = $true }
1896 elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1897 else { exit 101 }
1898}
1899if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1900$sha = [Security.Cryptography.SHA256]::Create()
1901try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1902[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1903[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1904"#;
1905 spec.launch.fixed_args.push(format!(
1906 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1907 ));
1908 spec.launch.fixed_args.push(proof_path.clone());
1909 }
1910 specs.push(spec);
1911 }
1912 let catalog = AgentRegistry::new(specs)
1913 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1914 Ok(catalog)
1915 }
1916
1917 #[cfg(feature = "fixture")]
1918 pub fn new_exact_launcher_fixture(
1919 config: NodeServerConfig,
1920 agent_id: AgentId,
1921 launcher: String,
1922 ) -> Result<Self, NodeServerError> {
1923 if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1924 return Err(NodeServerError::Registry(
1925 "exact launcher fixture requires a supported provider".to_owned(),
1926 ));
1927 }
1928 if launcher.contains('\0') {
1929 return Err(NodeServerError::Registry(
1930 "exact launcher fixture path contains NUL".to_owned(),
1931 ));
1932 }
1933 let launcher_path = Path::new(&launcher);
1934 if !launcher_path.is_absolute() {
1935 return Err(NodeServerError::Registry(
1936 "exact launcher fixture path must be absolute".to_owned(),
1937 ));
1938 }
1939 let metadata = std::fs::metadata(launcher_path).map_err(|_| {
1940 NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
1941 })?;
1942 if !metadata.is_file() {
1943 return Err(NodeServerError::Registry(
1944 "exact launcher fixture path must be a regular file".to_owned(),
1945 ));
1946 }
1947 let mut spec = builtin_registry()
1948 .get(&agent_id)
1949 .ok_or_else(|| {
1950 NodeServerError::Registry(
1951 "exact launcher fixture provider is unavailable".to_owned(),
1952 )
1953 })?
1954 .clone();
1955 spec.launch.program = launcher;
1956 Self::new_fixture_with_spec(config, spec)
1957 }
1958
1959 #[cfg(feature = "fixture")]
1968 fn h3b_provider_spec_with_program_and_args(
1969 agent_id: AgentId,
1970 provider_program: PathBuf,
1971 fixed_args: Vec<OsString>,
1972 ) -> Result<AgentSpec, NodeServerError> {
1973 if !provider_program.is_absolute() || fixed_args.len() > 32 {
1974 return Err(NodeServerError::Registry(
1975 "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
1976 ));
1977 }
1978 let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
1979 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
1980 })?;
1981 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
1982 return Err(NodeServerError::Registry(
1983 "H3B fixture provider must be an exact regular file".to_owned(),
1984 ));
1985 }
1986 let program = provider_program.into_os_string().into_string().map_err(|_| {
1987 NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
1988 })?;
1989 let fixed_args = fixed_args.into_iter().map(|argument| {
1990 argument.into_string().map_err(|_| NodeServerError::Registry(
1991 "H3B fixture argument is not Unicode".to_owned(),
1992 ))
1993 }).collect::<Result<Vec<_>, _>>()?;
1994 let argument_bytes = fixed_args.iter()
1995 .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
1996 if program.contains('\0')
1997 || fixed_args.iter().any(|argument| argument.contains('\0'))
1998 || !matches!(argument_bytes, Some(total) if total <= 65_536)
1999 {
2000 return Err(NodeServerError::Registry(
2001 "H3B fixture provider or arguments are invalid".to_owned(),
2002 ));
2003 }
2004 let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2005 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2006 })?.clone();
2007 let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2008 .ok_or_else(|| NodeServerError::Registry(
2009 "H3B fixture provider basename is unavailable".to_owned(),
2010 ))?.to_owned();
2011 spec.detection.command = process_name.clone();
2012 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2013 name: process_name,
2014 }];
2015 spec.launch.program = program;
2016 spec.launch.fixed_args = fixed_args;
2017 Ok(spec)
2018 }
2019
2020 #[cfg(feature = "fixture")]
2021 pub fn new_harness_mcp_proxy_fixture(
2022 config: NodeServerConfig,
2023 provider_program: PathBuf,
2024 fixed_args: Vec<OsString>,
2025 harness_mcp_program: PathBuf,
2026 ) -> Result<Self, NodeServerError> {
2027 let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2028 let agent_id = AgentId::new("codex")
2029 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2030 let mut spec =
2031 Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2032 spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2033 config.fixture_raw_pty_runtime = true;
2034 Self::new_fixture_with_spec(config, spec)
2035 }
2036
2037 #[cfg(feature = "fixture")]
2061 pub fn new_harness_mcp_acp_launcher_fixture(
2062 config: NodeServerConfig,
2063 helper_program: PathBuf,
2064 provider: AgentId,
2065 program: PathBuf,
2066 args: Vec<OsString>,
2067 ) -> Result<Self, NodeServerError> {
2068 if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2069 return Err(NodeServerError::Registry(
2070 "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2071 ));
2072 }
2073 let config = config.with_harness_mcp_helper(helper_program)?;
2074 let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2075 let launch = spec.launch.clone();
2076 let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2077 NodeServerError::Registry(
2078 "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2079 .to_owned(),
2080 )
2081 })?;
2082 acp.launch_override = Some(launch);
2090 spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2091 Self::new_fixture_with_spec(config, spec)
2092 }
2093
2094 #[cfg(feature = "fixture")]
2095 pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2096 let mut spec = gate4agent_testkit::interactive_agent_spec();
2097 let claude_id = AgentId::new("claude")
2098 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2099 let claude = builtin_registry()
2100 .get(&claude_id)
2101 .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2102 spec.id = claude_id;
2103 spec.display_name = "Resumable Claude PTY fixture".to_owned();
2104 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2105 name: "claude".to_owned(),
2106 }];
2107 spec.capabilities.transports.pty_adapter = claude
2108 .capabilities
2109 .transports
2110 .pty_adapter
2111 .clone();
2112 spec.capabilities.adapters.resume = claude
2113 .capabilities
2114 .adapters
2115 .resume
2116 .clone();
2117 let script = spec
2118 .launch
2119 .fixed_args
2120 .pop()
2121 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2122 spec.launch.fixed_args.push(format!(
2123 "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2124 ));
2125 Self::new_fixture_with_spec(config, spec)
2126 }
2127
2128 #[cfg(feature = "fixture")]
2129 pub fn new_cmd_cwd_fixture(
2130 config: NodeServerConfig,
2131 command: String,
2132 ) -> Result<Self, NodeServerError> {
2133 let mut spec = gate4agent_testkit::interactive_agent_spec();
2134 spec.id = AgentId::new("claude")
2135 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2136 spec.display_name = "Controlled cmd cwd fixture".to_owned();
2137 spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2138 spec.launch.program = command;
2139 spec.launch.fixed_args.clear();
2140 Self::new_fixture_with_spec(config, spec)
2141 }
2142
2143 #[cfg(feature = "fixture")]
2144 fn new_fixture_with_spec(
2145 config: NodeServerConfig,
2146 spec: gate4agent_types::AgentSpec,
2147 ) -> Result<Self, NodeServerError> {
2148 let catalog = AgentRegistry::new([spec])
2149 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2150 Self::new_with_registry(config, catalog)
2151 }
2152
2153 fn new_with_registry(
2154 config: NodeServerConfig,
2155 catalog: AgentRegistry,
2156 ) -> Result<Self, NodeServerError> {
2157 for profile in config.spawn_profiles.iter() {
2158 if catalog.get(&profile.provider).is_none() {
2159 return Err(NodeServerError::Registry(
2160 "spawn profile references an unavailable provider".to_owned(),
2161 ));
2162 }
2163 }
2164 let input_settle_timeout_ms = catalog
2165 .iter()
2166 .map(|spec| spec.readiness.timeout_ms)
2167 .max()
2168 .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2169 .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2170 .max(MUTATION_SETTLE_TIMEOUT_MS);
2171 let (provider_contracts, provider_adapter_contracts) =
2172 provider_contract_manifest(&catalog)?;
2173 let compatibility_support = node_compatibility_support_for_manifest(
2174 &provider_contracts,
2175 &provider_adapter_contracts,
2176 )?;
2177 validate_node_negotiated_handshake_capacity(&compatibility_support)
2178 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2179 let enabled_providers = provider_contracts
2180 .iter()
2181 .map(|contract| contract.provider.clone())
2182 .collect();
2183 #[cfg(feature = "fixture")]
2184 let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2185 #[cfg(not(feature = "fixture"))]
2186 let fixture_raw_pty_runtime = false;
2187 let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2188 let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2189 crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2190 })).expect("fixture provider catalog remains bounded");
2191 (None, statuses)
2192 } else {
2193 let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2194 let statuses = monitor.collect();
2195 (Some(monitor), statuses)
2196 };
2197 let state_path_lock =
2198 session_registry::StatePathLock::acquire(config.state_path.as_deref())
2199 .map_err(|error| {
2200 durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2201 })?;
2202 let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2203 Some(state_path) => {
2204 let delivery_root = delivery_store_root_for_state_path(state_path)
2205 .ok_or_else(|| {
2206 NodeServerError::DeliveryStore(
2207 "delivery store root could not be derived from state path".to_owned(),
2208 )
2209 })?;
2210 let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2211 let detail = match &error {
2212 DeliveryStoreError::Storage(io_error) => {
2213 format!("{error}: {io_error}")
2214 }
2215 _ => error.to_string(),
2216 };
2217 NodeServerError::DeliveryStore(detail)
2218 })?;
2219 (Some(store), bundles)
2220 }
2221 None => (None, Vec::new()),
2222 };
2223 let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2224 NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2225 })?;
2226 let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2227 Some(state_path) => {
2228 let context_pack_root = context_pack_store_root_for_state_path(state_path)
2229 .ok_or(NodeServerError::ContextPackStore)?;
2230 let (store, packs) = ContextPackStore::open(context_pack_root)
2231 .map_err(|_| NodeServerError::ContextPackStore)?;
2232 (Some(store), packs)
2233 }
2234 None => (None, Vec::new()),
2235 };
2236 let mut durable_context_catalog = ContextPackCatalog::default();
2237 for pack in durable_context_packs {
2238 durable_context_catalog
2239 .insert(pack)
2240 .map_err(|_| NodeServerError::ContextPackStore)?;
2241 }
2242 let session_environment_materializer = config
2243 .session_environment
2244 .as_ref()
2245 .map(|environment| {
2246 SessionEnvironmentMaterializer::new(
2247 environment.root.clone(),
2248 Arc::clone(&environment.resolver),
2249 )
2250 .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2251 })
2252 .transpose()?;
2253 let native_session_catalog = config.history.clone().map(|history| {
2254 Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2255 });
2256 let (handle, runtime) = match config.history.clone() {
2257 Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2258 None => NativeRuntime::new(catalog, config.runtime),
2259 };
2260 let native_launch_profile_control = runtime.native_launch_profile_control();
2261 let harness_mcp_registry = config
2262 .harness_mcp_helper
2263 .clone()
2264 .map(HarnessMcpProxyRegistry::new);
2265 let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2266 let incarnation_id = random_incarnation_id()
2267 .map_err(NodeServerError::IncarnationIdentity)?;
2268 let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2269 .map_err(durable_state_load_error)?;
2270 let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2271 let managed_worktree_tombstones =
2272 std::mem::take(&mut loaded.managed_worktree_tombstones);
2273 let materializations = std::mem::take(&mut loaded.materializations);
2274 let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2275 let (workspaces, records, persistence_warning) =
2276 merge_durable_state(&config.workspaces, loaded)?;
2277 let mut shared = NodeShared::new_with_incarnation(
2278 handle,
2279 config.access_token.clone(),
2280 config.node_id.clone(),
2281 incarnation_id,
2282 workspaces,
2283 enabled_providers,
2284 provider_runtime_statuses,
2285 provider_runtime_monitor,
2286 provider_contracts,
2287 provider_adapter_contracts,
2288 config.spawn_profiles.clone(),
2289 Some(native_launch_profile_control),
2290 native_session_catalog,
2291 session_environment_materializer,
2292 config.state_path.clone(),
2293 records,
2294 managed_worktrees,
2295 managed_worktree_tombstones,
2296 materializations,
2297 persistence_warning,
2298 input_settle_timeout_ms,
2299 );
2300 shared.harness_mcp_registry = harness_mcp_registry.clone();
2301 shared.session_record_retention = config.session_record_retention;
2302 shared.network_allowlist_catalog =
2303 RwLock::new(config.network_allowlist_catalog.clone());
2304 shared.bundle_catalog = RwLock::new(delivered_catalog);
2305 shared.delivery_store = Mutex::new(delivery_store);
2306 shared.context_catalog = RwLock::new(durable_context_catalog);
2307 shared.context_pack_store = Mutex::new(context_pack_store);
2308 shared.managed_spawn_replays = Mutex::new(
2309 managed_spawn_replays
2310 .into_iter()
2311 .map(|record| (record.idempotency_key.clone(), record))
2312 .collect(),
2313 );
2314 let shared = Arc::new(shared);
2315 if let Some(registry) = harness_mcp_registry {
2316 let weak = Arc::downgrade(&shared);
2317 registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2318 Some(shared) => shared.publish_transient(event),
2319 None => false,
2320 }));
2321 }
2322 if shared.persistence_error().is_none() {
2323 shared.persist_state().map_err(|error| {
2324 durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2325 })?;
2326 }
2327 Ok(Self {
2328 config,
2329 runtime,
2330 shared,
2331 events,
2332 state_path_lock,
2333 })
2334 }
2335
2336 pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2337 NodeShutdownHandle {
2338 shared: Arc::clone(&self.shared),
2339 }
2340 }
2341
2342 #[cfg(feature = "fixture")]
2343 pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2344 &self,
2345 ) -> SpawnManagedWorktreeV2FailureProbe {
2346 self.shared
2347 .fixture_spawn_managed_worktree_v2_failure_probe
2348 .clone()
2349 }
2350
2351 pub fn install_environment_profile(
2356 &mut self,
2357 profile: NodeEnvironmentProfile,
2358 ) -> Result<(), NodeServerError> {
2359 if !self.shared.enabled_providers.contains(profile.provider()) {
2360 return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2361 profile.provider().clone(),
2362 ));
2363 }
2364 let (binding, native_profiles, materialization) = profile.into_parts();
2365 if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2366 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2367 }
2368 let mut profiles = self
2369 .shared
2370 .environment_profiles
2371 .write()
2372 .unwrap_or_else(|poisoned| poisoned.into_inner());
2373 if profiles.contains_key(&binding.id) {
2374 return Err(NodeServerError::DuplicateEnvironmentProfile(
2375 binding.id.clone(),
2376 ));
2377 }
2378 if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2379 return Err(NodeServerError::EnvironmentProfileCapacity {
2380 max: MAX_NODE_ENVIRONMENT_PROFILES,
2381 });
2382 }
2383 for native_id in binding.native_profile_ids() {
2384 if profiles.values().any(|current| {
2385 current.native_profile_ids().any(|current_id| current_id == native_id)
2386 }) {
2387 return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2388 native_id.to_string(),
2389 ));
2390 }
2391 }
2392
2393 let mut installed = Vec::with_capacity(native_profiles.len());
2394 for native_profile in native_profiles {
2395 let native_id = native_profile.id().clone();
2396 if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2397 for installed_id in installed.into_iter().rev() {
2398 let _ = self.runtime.remove_native_launch_profile(&installed_id);
2399 }
2400 return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2401 }
2402 installed.push(native_id);
2403 }
2404 if let Some(materialization) = materialization {
2405 self.shared
2406 .environment_materialization_profiles
2407 .write()
2408 .unwrap_or_else(|poisoned| poisoned.into_inner())
2409 .insert(binding.id.clone(), materialization);
2410 }
2411 profiles.insert(binding.id.clone(), binding);
2412 Ok(())
2413 }
2414
2415 pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2417 if self.shared.session_environment_materializer.is_none() {
2418 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2419 }
2420 let mut catalog = self
2421 .shared
2422 .bundle_catalog
2423 .write()
2424 .unwrap_or_else(|poisoned| poisoned.into_inner());
2425 catalog
2426 .insert_idempotent(bundle)
2427 .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2428 Ok(())
2429 }
2430
2431 pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2432 let shutdown = self.shutdown_handle();
2433 let run = self.run();
2434 tokio::pin!(run);
2435 tokio::select! {
2436 result = &mut run => result,
2437 signal = wait_for_ctrl_signal() => {
2438 signal?;
2439 let shutdown_result = shutdown.request_shutdown().await;
2440 let run_result = run.await;
2441 shutdown_result.and(run_result)
2442 }
2443 }
2444 }
2445
2446 pub async fn run(self) -> Result<(), NodeServerError> {
2447 let NodeServer {
2448 config,
2449 mut runtime,
2450 shared,
2451 events,
2452 state_path_lock: _state_path_lock,
2453 } = self;
2454 shared.reconcile_materializations();
2455 shared.reconcile_managed_worktrees().await;
2456 shared.retire_unavailable_session_records().await;
2457 shared.reconcile_context_pack_exports().await;
2458 let endpoint = config.endpoint.clone();
2462 let call_home = config.call_home;
2463 let api_listen = config.api_listen;
2464 let bridge_listen = config.bridge_listen;
2465 let bridge_underlay = config.bridge_underlay;
2466 let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2467 .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2468 let accept_shared = Arc::clone(&shared);
2469 let api_shared = Arc::clone(&shared);
2470 let bridge_shared = Arc::clone(&shared);
2471 let shutdown_shared = Arc::clone(&shared);
2472 let shutdown_timeout = Duration::from_millis(
2473 config.runtime.provider_shutdown_timeout_ms.max(1),
2474 );
2475 let result = {
2476 let runtime_loop = drive_runtime_until_shutdown(
2477 &mut runtime,
2478 events,
2479 Arc::clone(&shared),
2480 shutdown_timeout,
2481 );
2482 let accept_loop = accept_connections(&endpoint, call_home, accept_shared);
2483 let api_loop = http_api::run(api_listen, api_shared);
2484 let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2485 tokio::pin!(runtime_loop);
2486 tokio::pin!(accept_loop);
2487 tokio::pin!(api_loop);
2488 tokio::pin!(bridge_loop);
2489 tokio::select! {
2490 runtime_result = &mut runtime_loop => {
2491 let shutdown_result = shutdown_shared
2492 .begin_shutdown()
2493 .await
2494 .map_err(NodeServerError::ShutdownDispatch);
2495 let (accept_result, api_result, bridge_result) =
2496 tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2497 match runtime_result {
2498 Err(error) => Err(error),
2499 Ok(()) => shutdown_result
2500 .and(accept_result)
2501 .and(api_result.map_err(NodeServerError::HttpApi))
2502 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2503 }
2504 }
2505 accept_result = &mut accept_loop => {
2506 let shutdown_result = shutdown_shared
2507 .begin_shutdown()
2508 .await
2509 .map_err(NodeServerError::ShutdownDispatch);
2510 let (runtime_result, api_result, bridge_result) =
2511 tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2512 match accept_result {
2513 Err(error) => Err(error),
2514 Ok(()) => shutdown_result
2515 .and(runtime_result)
2516 .and(api_result.map_err(NodeServerError::HttpApi))
2517 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2518 }
2519 }
2520 api_result = &mut api_loop => {
2521 let shutdown_result = shutdown_shared
2522 .begin_shutdown()
2523 .await
2524 .map_err(NodeServerError::ShutdownDispatch);
2525 let (runtime_result, accept_result, bridge_result) =
2526 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2527 match api_result {
2528 Err(error) => Err(NodeServerError::HttpApi(error)),
2529 Ok(()) => shutdown_result
2530 .and(runtime_result)
2531 .and(accept_result)
2532 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2533 }
2534 }
2535 bridge_result = &mut bridge_loop => {
2536 let shutdown_result = shutdown_shared
2537 .begin_shutdown()
2538 .await
2539 .map_err(NodeServerError::ShutdownDispatch);
2540 let (runtime_result, accept_result, api_result) =
2541 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2542 match bridge_result {
2543 Err(error) => Err(NodeServerError::BridgeApi(error)),
2544 Ok(()) => shutdown_result
2545 .and(runtime_result)
2546 .and(accept_result)
2547 .and(api_result.map_err(NodeServerError::HttpApi)),
2548 }
2549 }
2550 }
2551 };
2552 if let Ok(mut guard) = shared.delivery_store.lock() {
2559 *guard = None;
2560 }
2561 if let Ok(mut guard) = shared.context_pack_store.lock() {
2562 *guard = None;
2563 }
2564 result
2565 }
2566}
2567
2568async fn drive_runtime_until_shutdown(
2569 runtime: &mut NativeRuntime,
2570 events: EventSubscription,
2571 shared: Arc<NodeShared>,
2572 shutdown_timeout: Duration,
2573) -> Result<(), NodeServerError> {
2574 let _ = shared
2578 .shell_efficiency_profile
2579 .set(runtime.shell_efficiency_profile());
2580 let mut shutdown_started = None;
2581 let mut last_retention_sweep = Instant::now();
2582 loop {
2583 let runtime_tick_start = Instant::now();
2590 let tick_result = runtime.tick().await;
2591 let runtime_tick_elapsed = runtime_tick_start.elapsed();
2592
2593 let event_drain_start = Instant::now();
2594 for outcome in &tick_result.command_outcomes {
2602 if let Err(error) = &outcome.result {
2603 shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2604 tracing::warn!(
2605 command_id = outcome.command_id.0,
2606 cause = %error,
2607 "control command rejected"
2608 );
2609 shared.record_rejected_command(
2610 outcome.command_id,
2611 error.to_string(),
2612 error.is_unsupported_transport(),
2613 );
2614 }
2615 }
2616 let mut events_drained: u64 = 0;
2617 while let Ok(event) = events.try_recv() {
2618 events_drained += 1;
2619 let clean_exit = matches!(
2620 event.event,
2621 ControlEventKind::Exited { exit_code: Some(0), forced: false },
2622 );
2623 let instance_id = event.instance_id;
2624 let generation = event.generation;
2625 if clean_exit {
2637 if let Some((record_id, session)) =
2638 shared.managed_record_export_target_for_instance(instance_id, generation)
2639 {
2640 let export_shared = Arc::clone(&shared);
2641 tokio::spawn(async move {
2642 export_shared
2643 .reconcile_context_pack_export_for_record(&record_id, session)
2644 .await;
2645 });
2646 }
2647 }
2648 shared.publish_control(event);
2649 }
2650 let event_drain_elapsed = event_drain_start.elapsed();
2651
2652 let publish_terminal_frames_start = Instant::now();
2653 shared.publish_terminal_frames();
2654 let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2655
2656 shared
2662 .native_session_gauge
2663 .store(runtime.active_native_sessions(), Ordering::Relaxed);
2664 *shared
2665 .runtime_tick_profile
2666 .lock()
2667 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2668
2669 let remainder_start = Instant::now();
2670 if shared.shutdown.load(Ordering::Acquire) {
2671 let started = *shutdown_started.get_or_insert_with(Instant::now);
2672 let snapshot = shared.handle.snapshot();
2673 let sessions_settled = snapshot.sessions.iter().all(|session| {
2674 !matches!(
2675 session.status,
2676 gate4agent_types::SessionStatus::Starting
2677 | gate4agent_types::SessionStatus::Running
2678 | gate4agent_types::SessionStatus::Stopping
2679 )
2680 });
2681 if runtime.active_native_sessions() == 0 && sessions_settled {
2682 return Ok(());
2683 }
2684 if started.elapsed() >= shutdown_timeout {
2685 return Err(NodeServerError::ShutdownTimedOut {
2686 active_native_sessions: runtime.active_native_sessions(),
2687 });
2688 }
2689 }
2690 let remainder_elapsed = remainder_start.elapsed();
2691
2692 {
2693 let mut profile = shared
2694 .drive_loop_profile
2695 .lock()
2696 .unwrap_or_else(|poisoned| poisoned.into_inner());
2697 profile.record_runtime_tick(runtime_tick_elapsed);
2698 profile.record_event_drain(event_drain_elapsed);
2699 profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2700 profile.record_remainder(remainder_elapsed);
2701 profile.note_iteration(Instant::now());
2702 }
2703
2704 shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2716 if tick_result.observations_applied == 0 && events_drained == 0 {
2717 shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2718 }
2719
2720 if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2725 shared.retire_unavailable_session_records().await;
2726 last_retention_sweep = Instant::now();
2727 }
2728
2729 sleep(Duration::from_millis(10)).await;
2730 }
2731}
2732
2733#[derive(Clone)]
2734pub struct NodeShutdownHandle {
2735 shared: Arc<NodeShared>,
2736}
2737
2738impl NodeShutdownHandle {
2739 pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2740 self.shared
2741 .begin_shutdown()
2742 .await
2743 .map_err(NodeServerError::ShutdownDispatch)
2744 }
2745}
2746
2747async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2748 #[cfg(windows)]
2749 {
2750 let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2751 let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2752 return tokio::select! {
2753 signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2754 signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2755 };
2756 }
2757 #[cfg(unix)]
2758 {
2759 let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2760 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2761 tokio::select! {
2762 signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2763 signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2764 }
2765 }
2766}
2767
2768fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2781 let specs = builtin_registry()
2782 .iter()
2783 .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2784 .cloned()
2785 .collect::<Vec<_>>();
2786 AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2787}
2788
2789fn provider_contract_manifest(
2790 catalog: &AgentRegistry,
2791) -> Result<
2792 (
2793 Vec<ProviderContractSupport>,
2794 Vec<ProviderAdapterContractSupport>,
2795 ),
2796 NodeServerError,
2797> {
2798 let mut provider_contracts = Vec::new();
2799 let mut provider_adapter_contracts = Vec::new();
2800 for spec in catalog.iter() {
2801 let provider = spec.id.clone();
2802 let revision = ProviderContractRevision::new(spec.revision.clone())
2803 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2804 provider_contracts.push(ProviderContractSupport {
2805 provider: provider.clone(),
2806 revision,
2807 });
2808 for (family, binding) in declared_adapter_bindings(spec)? {
2809 let revision = AdapterContractRevision::new(binding.revision.clone())
2810 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2811 provider_adapter_contracts.push(ProviderAdapterContractSupport {
2812 provider: provider.clone(),
2813 family,
2814 adapter_id: binding.id.clone(),
2815 revision,
2816 });
2817 }
2818 }
2819 validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2820 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2821 Ok((provider_contracts, provider_adapter_contracts))
2822}
2823
2824fn declared_adapter_bindings(
2825 spec: &AgentSpec,
2826) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2827 let mut bindings = Vec::new();
2828 push_declared_binding(
2829 &mut bindings,
2830 AdapterFamily::PtySemantic,
2831 spec.capabilities.transports.pty_adapter.as_ref(),
2832 )?;
2833 push_declared_binding(
2834 &mut bindings,
2835 AdapterFamily::Pipe,
2836 spec.capabilities.adapters.pty_sidecar.as_ref(),
2837 )?;
2838 let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2839 let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2840 gate4agent_types::PipeProtocol::SemanticNdjson
2841 | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2842 gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2843 });
2844 push_declared_binding(
2845 &mut bindings,
2846 AdapterFamily::Pipe,
2847 pipe_transport
2848 .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2849 .map(|transport| &transport.adapter),
2850 )?;
2851 push_declared_binding(
2852 &mut bindings,
2853 AdapterFamily::Acp,
2854 spec.capabilities
2855 .transports
2856 .acp
2857 .as_ref()
2858 .map(|transport| &transport.adapter),
2859 )?;
2860 push_declared_binding(
2861 &mut bindings,
2862 AdapterFamily::Hook,
2863 spec.capabilities.adapters.hook.as_ref(),
2864 )?;
2865 push_declared_binding(
2866 &mut bindings,
2867 AdapterFamily::ManagedHook,
2868 spec.capabilities.adapters.managed_hook.as_ref(),
2869 )?;
2870 push_declared_binding(
2871 &mut bindings,
2872 AdapterFamily::OneShot,
2873 pipe_transport
2874 .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
2875 .map(|transport| &transport.adapter),
2876 )?;
2877 push_declared_binding(
2878 &mut bindings,
2879 AdapterFamily::OneShot,
2880 spec.capabilities.adapters.one_shot.as_ref(),
2881 )?;
2882 push_declared_binding(
2883 &mut bindings,
2884 AdapterFamily::History,
2885 spec.capabilities.adapters.history.as_ref(),
2886 )?;
2887 push_declared_binding(
2888 &mut bindings,
2889 AdapterFamily::Resume,
2890 spec.capabilities.adapters.resume.as_ref(),
2891 )?;
2892 push_declared_binding(
2893 &mut bindings,
2894 AdapterFamily::SessionOptions,
2895 spec.capabilities.adapters.session_options.as_ref(),
2896 )?;
2897 push_declared_binding(
2898 &mut bindings,
2899 AdapterFamily::CapabilityProbe,
2900 spec.capabilities.adapters.capability_probe.as_ref(),
2901 )?;
2902 Ok(bindings)
2903}
2904
2905fn push_declared_binding<'a>(
2906 bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
2907 family: AdapterFamily,
2908 binding: Option<&'a AdapterBinding>,
2909) -> Result<(), NodeServerError> {
2910 let Some(binding) = binding else {
2911 return Ok(());
2912 };
2913 if let Some((_, existing)) = bindings
2914 .iter()
2915 .find(|(existing_family, _)| *existing_family == family)
2916 {
2917 if *existing == binding {
2918 return Ok(());
2919 }
2920 return Err(NodeServerError::ProviderContractManifest(format!(
2921 "provider declares conflicting {family:?} adapter bindings"
2922 )));
2923 }
2924 bindings.push((family, binding));
2925 Ok(())
2926}
2927
2928fn merge_durable_state(
2929 configured: &[WorkspaceConfig],
2930 mut loaded: LoadedNodeState,
2931) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
2932 let mut workspaces = configured
2933 .iter()
2934 .cloned()
2935 .map(|workspace| (workspace.workspace_id.clone(), workspace))
2936 .collect::<BTreeMap<_, _>>();
2937 let mut roots = workspaces
2938 .values()
2939 .map(|workspace| {
2940 (
2941 platform::root_identity(&workspace.canonical_root),
2942 workspace.workspace_id.clone(),
2943 )
2944 })
2945 .collect::<BTreeMap<_, _>>();
2946 let mut warning = loaded
2947 .warning
2948 .take()
2949 .map(|message| session_registry::sanitized_persistence_summary(&message));
2950
2951 for (workspace_id, canonical_root) in loaded.workspaces {
2952 if let Some(existing) = workspaces.get(&workspace_id) {
2953 if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
2954 return Err(NodeServerError::DurableState(io::Error::new(
2955 io::ErrorKind::InvalidData,
2956 DURABLE_STATE_CONFLICT_ERROR,
2957 )));
2958 }
2959 continue;
2960 }
2961 if roots.get(&platform::root_identity(&canonical_root)).is_some() {
2962 return Err(NodeServerError::DurableState(io::Error::new(
2963 io::ErrorKind::InvalidData,
2964 DURABLE_STATE_CONFLICT_ERROR,
2965 )));
2966 }
2967 if !Path::new(&canonical_root).is_dir() {
2968 warning.get_or_insert_with(|| {
2969 session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
2970 });
2971 continue;
2972 }
2973 let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
2974 NodeServerError::DurableState(io::Error::new(
2975 io::ErrorKind::InvalidData,
2976 DURABLE_STATE_CONFLICT_ERROR,
2977 ))
2978 })?;
2979 roots.insert(
2980 platform::root_identity(&workspace.canonical_root),
2981 workspace.workspace_id.clone(),
2982 );
2983 workspaces.insert(workspace_id, workspace);
2984 }
2985
2986 for record in &mut loaded.records {
2987 match workspaces.get(&record.workspace_id) {
2988 Some(workspace)
2989 if platform::roots_equal(
2990 &workspace.canonical_root,
2991 windows_path_text(&record.canonical_root),
2992 ) => {}
2993 Some(_) => {
2994 return Err(NodeServerError::DurableState(io::Error::new(
2995 io::ErrorKind::InvalidData,
2996 DURABLE_STATE_CONFLICT_ERROR,
2997 )));
2998 }
2999 None => {
3000 record.state = ManagedSessionState::Unavailable;
3001 record.active_session = None;
3002 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3003 }
3004 }
3005 }
3006 Ok((workspaces.into_values().collect(), loaded.records, warning))
3007}
3008
3009struct RejectedCommandReason {
3012 message: String,
3013 unsupported_transport: bool,
3020 expires_at: Instant,
3021}
3022
3023struct NodeShared {
3024 handle: Gate4AgentHandle,
3025 access_token: String,
3026 node_id: NodeId,
3027 incarnation_id: NodeIncarnationId,
3028 started_at_unix_ms: u64,
3029 workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3030 worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3031 managed_worktree_profiles:
3032 BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3033 managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3034 enabled_providers: Vec<AgentId>,
3035 provider_runtime_statuses: ProviderRuntimeStatuses,
3036 provider_runtime_status_updates:
3037 RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3038 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3039 provider_contracts: Vec<ProviderContractSupport>,
3040 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3041 spawn_profiles: SpawnProfileRegistry,
3042 environment_profiles:
3043 RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3044 environment_materialization_profiles:
3045 RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3046 network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3054 #[cfg(feature = "dig2-station-probe")]
3058 browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3059 bundle_catalog: RwLock<BundleCatalog>,
3060 delivery_store: Mutex<Option<DeliveryStore>>,
3061 context_pack_store: Mutex<Option<ContextPackStore>>,
3062 context_catalog: RwLock<ContextPackCatalog>,
3063 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3064 harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3065 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3066 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3067 materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3068 spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3069 managed_spawn_replays:
3070 Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3071 controller: Mutex<Option<ControllerLease>>,
3072 history: Mutex<NodeEventHistory>,
3073 event_tx: broadcast::Sender<NodeEventEnvelope>,
3074 terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3075 agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3083 harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3092 terminal_frame_watermarks:
3093 Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3094 next_connection_id: AtomicU64,
3095 next_instance_id: AtomicU64,
3096 next_command_id: AtomicU64,
3097 shutdown: AtomicBool,
3098 shutdown_notify: Notify,
3099 preauth_slots: Arc<Semaphore>,
3100 authenticated_slots: Arc<Semaphore>,
3101 inspection_slots: Arc<Semaphore>,
3102 mutation_gate: AsyncMutex<()>,
3103 session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3104 session_records: Mutex<SessionRecords>,
3105 state_path: Option<PathBuf>,
3106 persistence_error: RwLock<Option<String>>,
3107 state_transaction: Mutex<()>,
3108 input_settle_timeout_ms: u64,
3109 drive_loop_profile: Mutex<DriveLoopProfiler>,
3113 runtime_tick_profile: Mutex<TickProfileSnapshot>,
3119 shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3131 native_session_gauge: AtomicUsize,
3138 connection_loop_iterations: AtomicU64,
3149 connection_events_sent: AtomicU64,
3150 rejected_commands_total: AtomicU64,
3159 rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3179 drive_loop_iterations_total: AtomicU64,
3180 drive_loop_iterations_idle: AtomicU64,
3181 session_record_retention: SessionRecordRetentionConfig,
3185 retired_records_total: AtomicU64,
3191 #[cfg(feature = "fixture")]
3192 fixture_semantic_hook_policy: bool,
3193 #[cfg(feature = "fixture")]
3194 fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3195}
3196
3197impl Drop for NodeShared {
3198 fn drop(&mut self) {
3199 SessionLifecycleHook::on_shutdown(self);
3200 }
3201}
3202
3203#[derive(Clone, Debug, Eq, PartialEq)]
3204struct SessionBinding {
3205 workspace_id: WorkspaceId,
3206 generation: SessionGeneration,
3207 runtime_policy: ProviderRuntimePolicy,
3208 pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3209 record_id: Option<SessionRecordId>,
3210 managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3211 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3212 bundle: Option<ResolvedBundleReceipt>,
3213 context: Option<ResolvedContextPackReceipt>,
3214 materialization_id: Option<MaterializationId>,
3215}
3216
3217#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3218enum SpawnRecordPolicy {
3219 ProviderIdentityOnly,
3220 Always,
3221}
3222
3223struct NativeEnvironmentSelectionGuard {
3224 control: Option<NativeLaunchProfileControl>,
3225 instance_id: AgentInstanceId,
3226}
3227
3228struct NativeInstanceOverlayGuard {
3229 control: Option<NativeLaunchProfileControl>,
3230 instance_id: AgentInstanceId,
3231}
3232
3233struct NativeHarnessMcpOverlayGuard {
3234 control: NativeLaunchProfileControl,
3235 instance_id: AgentInstanceId,
3236}
3237
3238impl NativeHarnessMcpOverlayGuard {
3239 fn retain(self) { std::mem::forget(self); }
3240}
3241
3242impl Drop for NativeHarnessMcpOverlayGuard {
3243 fn drop(&mut self) {
3244 self.control
3245 .clear_native_mcp_server_launch_overlay(self.instance_id);
3246 }
3247}
3248
3249impl NativeInstanceOverlayGuard {
3250 fn retain(mut self) {
3251 self.control = None;
3252 }
3253}
3254
3255impl Drop for NativeInstanceOverlayGuard {
3256 fn drop(&mut self) {
3257 if let Some(control) = self.control.as_ref() {
3258 control.clear_native_instance_launch_overlay(self.instance_id);
3259 }
3260 }
3261}
3262
3263enum PreparedNativeLaunchOverlay {
3264 Environment(NativeLaunchEnvironmentOverlay),
3265 Instance(NativeInstanceLaunchOverlay),
3266}
3267
3268impl NativeEnvironmentSelectionGuard {
3269 fn retain(mut self) {
3270 self.control = None;
3271 }
3272}
3273
3274impl Drop for NativeEnvironmentSelectionGuard {
3275 fn drop(&mut self) {
3276 if let Some(control) = self.control.as_ref() {
3277 control.clear_native_launch_profile_selection(self.instance_id);
3278 }
3279 }
3280}
3281
3282struct SessionMaterializationGuard<'a> {
3283 shared: &'a NodeShared,
3284 id: Option<MaterializationId>,
3285 provider_home: Option<PathBuf>,
3288}
3289
3290impl SessionMaterializationGuard<'_> {
3291 fn id(&self) -> Option<&MaterializationId> {
3292 self.id.as_ref()
3293 }
3294
3295 fn provider_home(&self) -> Option<&Path> {
3296 self.provider_home.as_deref()
3297 }
3298
3299 fn retain(mut self) {
3300 self.id = None;
3301 }
3302}
3303
3304impl Drop for SessionMaterializationGuard<'_> {
3305 fn drop(&mut self) {
3306 if let Some(id) = self.id.take() {
3307 let _ = self.shared.cleanup_materialization(&id);
3308 }
3309 }
3310}
3311
3312
3313trait SessionLifecycleHook {
3316 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3318 fn on_session_ended(&self, address: &SessionAddress);
3320 fn on_shutdown(&self);
3322 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3324 fn cleanup_session_materialization(
3326 &self,
3327 address: &SessionAddress,
3328 binding: &SessionBinding,
3329 ) -> Result<(), NodeFailure>;
3330}
3331
3332impl SessionLifecycleHook for NodeShared {
3333 fn on_session_ended(&self, address: &SessionAddress) {
3334 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3335 registry.revoke_session(address);
3336 }
3337 if let Some(control) = self.native_launch_profile_control.as_ref() {
3338 control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3339 }
3340 }
3341
3342 fn on_shutdown(&self) {
3343 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3344 let instances = registry.shutdown();
3345 if let Some(control) = self.native_launch_profile_control.as_ref() {
3346 for instance_id in instances {
3347 control.clear_native_mcp_server_launch_overlay(instance_id);
3348 }
3349 }
3350 }
3351 }
3352
3353 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3354 if let Some(control) = self.native_launch_profile_control.as_ref() {
3355 control.clear_native_mcp_server_launch_overlay(instance_id);
3356 }
3357 }
3358
3359 fn cleanup_session_materialization(
3360 &self,
3361 address: &SessionAddress,
3362 binding: &SessionBinding,
3363 ) -> Result<(), NodeFailure> {
3364 self.cleanup_session_owned_materialization(address, binding)
3365 }
3366
3367 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3368 #[cfg(feature = "dig2-station-probe")]
3371 {
3372 crate::dig2_station_lease::release_lease_for_holder(
3373 &self.browser_station_leases,
3374 address.session.instance_id,
3375 );
3376 }
3377 if let Some(control) = self.native_launch_profile_control.as_ref() {
3378 control.clear_native_mcp_server_launch_overlay(
3379 address.session.instance_id,
3380 );
3381 }
3382 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3383 registry.revoke_session(address);
3384 }
3385 if removed
3386 .is_some_and(|binding| {
3387 binding.environment_profile.is_some()
3388 || binding.bundle.is_some()
3389 || binding.context.is_some()
3390 })
3391 {
3392 if let Some(control) = self.native_launch_profile_control.as_ref() {
3393 control.clear_native_launch_profile_selection(
3394 address.session.instance_id,
3395 );
3396 control.clear_native_instance_launch_overlay(
3397 address.session.instance_id,
3398 );
3399 }
3400 }
3401 }
3402}
3403
3404struct SpawnPrepareRequest<'r> {
3405 instance_id: AgentInstanceId,
3406 address: &'r SessionAddress,
3407 provider: &'r AgentId,
3408 mode: SessionMode,
3409 environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3410 bundle: Option<&'r ResolvedBundleReceipt>,
3411 context: Option<&'r ResolvedContextPackReceipt>,
3412 managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3413 harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3414 approval_level: ApprovalLevel,
3415 network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3416 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3417}
3418
3419struct PreparedSpawn<'a> {
3423 instance_overlay: Option<NativeInstanceOverlayGuard>,
3424 environment_selection: Option<NativeEnvironmentSelectionGuard>,
3425 materialization_guard: Option<SessionMaterializationGuard<'a>>,
3426 harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3427 #[cfg(feature = "dig2-station-probe")]
3428 browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3429 materialization_id: Option<MaterializationId>,
3430}
3431
3432impl PreparedSpawn<'_> {
3433 fn retain_session_guards(&mut self) {
3435 if let Some(selection) = self.environment_selection.take() {
3436 selection.retain();
3437 }
3438 if let Some(overlay) = self.instance_overlay.take() {
3439 overlay.retain();
3440 }
3441 if let Some(guard) = self.materialization_guard.take() {
3442 guard.retain();
3443 }
3444 }
3445
3446 fn retain_launch_guards(&mut self) {
3448 if let Some(overlay) = self.harness_mcp_overlay.take() {
3449 overlay.retain();
3450 }
3451 #[cfg(feature = "dig2-station-probe")]
3452 if let Some(guard) = self.browser_station_lease_guard.take() {
3453 guard.retain();
3454 }
3455 }
3456}
3457
3458trait SpawnPreparer {
3462 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3463 fn select_environment(
3465 &self,
3466 instance_id: AgentInstanceId,
3467 provider: &AgentId,
3468 mode: SessionMode,
3469 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3470 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3471 fn install_launch_overlay(
3473 &self,
3474 instance_id: AgentInstanceId,
3475 overlay: PreparedNativeLaunchOverlay,
3476 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3477}
3478
3479impl SpawnPreparer for NodeShared {
3480 fn select_environment(
3481 &self,
3482 instance_id: AgentInstanceId,
3483 provider: &AgentId,
3484 mode: SessionMode,
3485 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3486 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3487 self.select_environment_profile(instance_id, provider, mode, environment_profile)
3488 }
3489
3490 fn install_launch_overlay(
3491 &self,
3492 instance_id: AgentInstanceId,
3493 overlay: PreparedNativeLaunchOverlay,
3494 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3495 self.install_prepared_launch_overlay(instance_id, overlay)
3496 }
3497
3498 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3499 let SpawnPrepareRequest {
3500 instance_id,
3501 address,
3502 provider,
3503 mode,
3504 environment_profile,
3505 bundle,
3506 context,
3507 managed_authority,
3508 harness_mcp,
3509 approval_level,
3510 network_allowlist,
3511 browser_profile_id,
3512 } = request;
3513 #[cfg(feature = "dig2-station-probe")]
3518 let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3519 use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3520 match acquire_lease(
3521 &self.browser_station_leases,
3522 profile_id,
3523 instance_id,
3524 unix_time_ms(),
3525 ) {
3526 Ok(guard) => Some(guard),
3527 Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3528 return Err(failure(
3529 NodeFailureCode::BrowserStationProfileBusy,
3530 "dig2browser station profile is already leased by another session",
3531 ));
3532 }
3533 Err(BrowserStationLeaseError::CapacityExceeded) => {
3534 return Err(failure(
3535 NodeFailureCode::BrowserStationProfileBusy,
3536 "dig2browser station profile lease capacity exhausted",
3537 ));
3538 }
3539 Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3540 return Err(failure(
3541 NodeFailureCode::BrowserStationProfileBusy,
3542 "session already holds a dig2browser station profile lease",
3543 ));
3544 }
3545 }
3546 } else {
3547 None
3548 };
3549 #[cfg(not(feature = "dig2-station-probe"))]
3550 let _ = browser_profile_id;
3551 let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3552 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3561 {
3562 return Err(failure(
3563 NodeFailureCode::BindingMismatch,
3564 "harness MCP reservation does not match the exact PTY/ACP provider",
3565 ));
3566 }
3567 prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3568 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3569 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3570 })?;
3571 let launch = prepared.launch();
3576 let mut env_entries = vec![
3577 (
3578 OsString::from(launch.endpoint_env.as_str()),
3579 prepared.endpoint().as_os_str().to_os_string(),
3580 ),
3581 (
3582 OsString::from(launch.token_env.as_str()),
3583 prepared.token().expose().into(),
3584 ),
3585 (
3586 OsString::from(launch.program_env.as_str()),
3587 prepared.helper_program().as_os_str().to_os_string(),
3588 ),
3589 ];
3590 if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3591 env_entries.push(trace_entry);
3592 }
3593 let overlay = NativeMcpServerLaunchOverlay::new(
3594 provider.clone(),
3595 launch.server_name.as_str(),
3596 prepared.helper_program().as_os_str().to_os_string(),
3597 launch.args.iter().map(OsString::from).collect(),
3598 env_entries,
3599 launch.scrub_env.iter().map(OsString::from).collect(),
3600 ).map_err(|_| failure(
3601 NodeFailureCode::HarnessMcpUnavailable,
3602 "harness MCP launch overlay is unavailable",
3603 ))?;
3604 control
3605 .install_native_mcp_server_launch_overlay(instance_id, overlay)
3606 .map_err(|_| failure(
3607 NodeFailureCode::HarnessMcpUnavailable,
3608 "harness MCP launch overlay could not be installed",
3609 ))?;
3610 tracing::info!(
3616 node_id = %self.node_id,
3617 instance_id = ?instance_id,
3618 provider = %provider,
3619 mode = ?mode,
3620 "harness MCP overlay installed for spawn",
3621 );
3622 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3623 } else {
3624 None
3625 };
3626 let prepared_materialization = self.prepare_session_materialization(
3627 &address,
3628 &provider,
3629 mode,
3630 environment_profile,
3631 bundle,
3632 context,
3633 managed_authority.cloned(),
3634 )?;
3635 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3636 Some((overlay, guard)) => (Some(overlay), Some(guard)),
3637 None => (None, None),
3638 };
3639 let materialization_id = materialization_guard
3640 .as_ref()
3641 .and_then(SessionMaterializationGuard::id)
3642 .cloned();
3643 let mut environment_selection = SpawnPreparer::select_environment(self,
3644 instance_id,
3645 &provider,
3646 mode,
3647 environment_profile,
3648 )?;
3649 let provider_home = materialization_guard
3652 .as_ref()
3653 .and_then(SessionMaterializationGuard::provider_home);
3654 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3655 &provider,
3656 mode,
3657 approval_level,
3658 network_allowlist,
3659 provider_home,
3660 )?;
3661 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3662 (prepared, args) if args.is_empty() => prepared,
3663 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3664 overlay.append_extra_args(args).map_err(|_| {
3665 failure(
3666 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3667 "network allowlist overlay could not merge with instance launch args",
3668 )
3669 })?;
3670 Some(PreparedNativeLaunchOverlay::Instance(overlay))
3671 }
3672 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3673 Some(PreparedNativeLaunchOverlay::Instance(
3674 overlay
3675 .into_instance_with_extra_args(args)
3676 .map_err(|_| {
3677 failure(
3678 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3679 "network allowlist overlay could not merge with environment overlay",
3680 )
3681 })?,
3682 ))
3683 }
3684 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3685 NativeInstanceLaunchOverlay::new(
3686 provider.clone(),
3687 match mode {
3688 SessionMode::Pty => TransportKind::Pty,
3689 SessionMode::Acp => TransportKind::Acp,
3690 SessionMode::Inline => TransportKind::Pipe,
3691 },
3692 Vec::new(),
3693 args,
3694 )
3695 .map_err(|_| {
3696 failure(
3697 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3698 "network allowlist overlay is invalid for this transport",
3699 )
3700 })?,
3701 )),
3702 };
3703 let mut instance_overlay = environment_overlay
3704 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
3705 .transpose()?
3706 .flatten();
3707 Ok(PreparedSpawn {
3708 instance_overlay,
3709 environment_selection,
3710 materialization_guard,
3711 harness_mcp_overlay,
3712 #[cfg(feature = "dig2-station-probe")]
3713 browser_station_lease_guard,
3714 materialization_id,
3715 })
3716 }
3717}
3718
3719struct SessionRecords {
3720 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3721}
3722
3723struct SpawnIdempotencyCache {
3724 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3725}
3726
3727struct SpawnIdempotencyEntry {
3728 value: SpawnIdempotencyValue,
3729 expires_at: Instant,
3730}
3731
3732enum SpawnIdempotencyValue {
3733 Standard {
3734 spec: SpawnSpec,
3735 result: Result<ResolvedSpawnReceipt, NodeFailure>,
3736 },
3737 Managed {
3738 request: ManagedWorktreeSpawnRequest,
3739 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3740 },
3741 HarnessMcp,
3742}
3743
3744enum DurableManagedSpawnReplayDecision {
3745 Reserved,
3746 PendingLinked,
3747 Committed(ManagedWorktreeSpawnReceipt),
3748}
3749
3750impl SpawnIdempotencyValue {
3751 fn references_context(&self, context_id: &SpawnContextId) -> bool {
3752 match self {
3753 Self::Standard { result, .. } => result
3754 .as_ref()
3755 .ok()
3756 .and_then(|receipt| receipt.context.as_ref()),
3757 Self::Managed { result, .. } => result
3758 .as_ref()
3759 .ok()
3760 .and_then(|receipt| receipt.spawn.context.as_ref()),
3761 Self::HarnessMcp => None,
3762 }
3763 .is_some_and(|receipt| &receipt.id == context_id)
3764 }
3765}
3766
3767#[derive(Clone, Copy)]
3768struct ControllerLease {
3769 connection_id: u64,
3770 expires_at: Instant,
3771}
3772
3773struct NodeEventHistory {
3774 last_sequence: u64,
3775 replay_floor_sequence: u64,
3776 events: VecDeque<NodeEventEnvelope>,
3777 record_providers: BTreeMap<SessionRecordId, AgentId>,
3778 removed_record_providers: BTreeMap<u64, AgentId>,
3779}
3780
3781impl NodeEventHistory {
3782 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
3783 Self {
3784 last_sequence: 0,
3785 replay_floor_sequence: 1,
3786 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
3787 record_providers,
3788 removed_record_providers: BTreeMap::new(),
3789 }
3790 }
3791}
3792
3793impl NodeShared {
3794 fn new_with_incarnation(
3795 handle: Gate4AgentHandle,
3796 access_token: String,
3797 node_id: NodeId,
3798 incarnation_id: NodeIncarnationId,
3799 workspaces: Vec<WorkspaceConfig>,
3800 enabled_providers: Vec<AgentId>,
3801 provider_runtime_statuses: ProviderRuntimeStatuses,
3802 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3803 provider_contracts: Vec<ProviderContractSupport>,
3804 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3805 spawn_profiles: SpawnProfileRegistry,
3806 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3807 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3808 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3809 state_path: Option<PathBuf>,
3810 records: Vec<ManagedSessionRecord>,
3811 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
3812 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
3813 materializations: Vec<MaterializationOwnershipRecord>,
3814 persistence_warning: Option<String>,
3815 input_settle_timeout_ms: u64,
3816 ) -> Self {
3817 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
3818 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
3819 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
3820 let (harness_mcp_proxy_event_tx, _) =
3821 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
3822 let record_providers = records
3823 .iter()
3824 .map(|record| (record.record_id.clone(), record.provider.clone()))
3825 .collect();
3826 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
3827 .iter()
3828 .map(|workspace| {
3829 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
3830 })
3831 .collect();
3832 for lease in &managed_worktrees {
3833 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
3834 }
3835 let managed_worktree_profiles = workspaces.iter()
3836 .map(|workspace| (
3837 workspace.workspace_id.clone(),
3838 workspace.managed_worktree_profiles.clone(),
3839 ))
3840 .collect();
3841 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
3842 managed_worktrees,
3843 managed_worktree_tombstones,
3844 ).expect("durable managed worktree registry was validated while loading");
3845 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
3846 managed_worktrees.reattach_record_holders(
3847 &managed_worktree_record_holders(&records),
3848 unix_time_ms(),
3849 );
3850 Self {
3851 handle,
3852 access_token,
3853 node_id,
3854 incarnation_id,
3855 started_at_unix_ms: std::time::SystemTime::now()
3856 .duration_since(std::time::UNIX_EPOCH)
3857 .unwrap_or_default()
3858 .as_millis()
3859 .min(u64::MAX as u128) as u64,
3860 workspaces: RwLock::new(
3861 workspaces
3862 .into_iter()
3863 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
3864 .collect(),
3865 ),
3866 worktree_service_modes,
3867 managed_worktree_profiles,
3868 managed_worktrees: Mutex::new(managed_worktrees),
3869 enabled_providers,
3870 provider_runtime_statuses,
3871 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
3872 provider_runtime_monitor,
3873 provider_contracts,
3874 provider_adapter_contracts,
3875 spawn_profiles,
3876 environment_profiles: RwLock::new(BTreeMap::new()),
3877 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
3878 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
3879 #[cfg(feature = "dig2-station-probe")]
3880 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
3881 bundle_catalog: RwLock::new(BundleCatalog::default()),
3882 delivery_store: Mutex::new(None),
3883 context_pack_store: Mutex::new(None),
3884 context_catalog: RwLock::new(ContextPackCatalog::default()),
3885 native_launch_profile_control,
3886 harness_mcp_registry: None,
3887 native_session_catalog,
3888 session_environment_materializer,
3889 materializations: Mutex::new(
3890 materializations
3891 .into_iter()
3892 .map(|record| (record.id().clone(), record))
3893 .collect(),
3894 ),
3895 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
3896 entries: BTreeMap::new(),
3897 }),
3898 managed_spawn_replays: Mutex::new(BTreeMap::new()),
3899 controller: Mutex::new(None),
3900 history: Mutex::new(NodeEventHistory::new(record_providers)),
3901 event_tx,
3902 terminal_event_tx,
3903 agent_stream_event_tx,
3904 harness_mcp_proxy_event_tx,
3905 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
3906 next_connection_id: AtomicU64::new(1),
3907 next_instance_id: AtomicU64::new(1),
3908 next_command_id: AtomicU64::new(1),
3909 shutdown: AtomicBool::new(false),
3910 shutdown_notify: Notify::new(),
3911 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
3912 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
3913 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
3914 mutation_gate: AsyncMutex::new(()),
3915 session_bindings: Mutex::new(BTreeMap::new()),
3916 session_records: Mutex::new(SessionRecords {
3917 records: records
3918 .into_iter()
3919 .map(|record| (record.record_id.clone(), record))
3920 .collect(),
3921 }),
3922 state_path,
3923 persistence_error: RwLock::new(
3924 persistence_warning
3925 .map(|message| session_registry::sanitized_persistence_summary(&message)),
3926 ),
3927 state_transaction: Mutex::new(()),
3928 input_settle_timeout_ms,
3929 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
3930 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
3931 shell_efficiency_profile: OnceLock::new(),
3932 native_session_gauge: AtomicUsize::new(0),
3933 connection_loop_iterations: AtomicU64::new(0),
3934 connection_events_sent: AtomicU64::new(0),
3935 rejected_commands_total: AtomicU64::new(0),
3936 rejected_command_reasons: Mutex::new(BTreeMap::new()),
3937 drive_loop_iterations_total: AtomicU64::new(0),
3938 drive_loop_iterations_idle: AtomicU64::new(0),
3939 session_record_retention: SessionRecordRetentionConfig::default(),
3940 retired_records_total: AtomicU64::new(0),
3941 #[cfg(feature = "fixture")]
3942 fixture_semantic_hook_policy: false,
3943 #[cfg(feature = "fixture")]
3944 fixture_spawn_managed_worktree_v2_failure_probe:
3945 SpawnManagedWorktreeV2FailureProbe::default(),
3946 }
3947 }
3948
3949 #[cfg(test)]
3950 fn new(
3951 handle: Gate4AgentHandle,
3952 access_token: String,
3953 node_id: NodeId,
3954 workspaces: Vec<WorkspaceConfig>,
3955 enabled_providers: Vec<AgentId>,
3956 ) -> Self {
3957 Self::new_with_incarnation(
3958 handle,
3959 access_token,
3960 node_id,
3961 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
3962 workspaces,
3963 enabled_providers,
3964 ProviderRuntimeStatuses::default(),
3965 None,
3966 Vec::new(),
3967 Vec::new(),
3968 SpawnProfileRegistry::default(),
3969 None,
3970 None,
3971 None,
3972 None,
3973 Vec::new(),
3974 Vec::new(),
3975 Vec::new(),
3976 Vec::new(),
3977 None,
3978 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
3979 )
3980 }
3981
3982 fn persistence_error(&self) -> Option<String> {
3983 self.persistence_error
3984 .read()
3985 .unwrap_or_else(|poisoned| poisoned.into_inner())
3986 .clone()
3987 }
3988
3989 async fn admit_provider_runtime(
3990 &self,
3991 provider: &AgentId,
3992 requirement: ProviderRuntimeRequirement,
3993 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
3994 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
3995 let pipe_transport = monitor.supports_pipe_transport(provider);
3996 let acp_transport = monitor.supports_acp_transport(provider);
3997 let provider = provider.clone();
3998 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
3999 let (status, admission) = timeout(
4000 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4001 refresh,
4002 )
4003 .await
4004 .map_err(|_| failure(
4005 NodeFailureCode::BackendBusy,
4006 "provider runtime probe exceeded its bounded deadline",
4007 ))?
4008 .map_err(|_| failure(
4009 NodeFailureCode::BackendOperationFailed,
4010 "provider runtime probe task failed",
4011 ))?;
4012 if let Some(status) = status {
4013 self
4014 .provider_runtime_status_updates
4015 .write()
4016 .unwrap_or_else(|poisoned| poisoned.into_inner())
4017 .insert(status.provider().clone(), status);
4018 }
4019 admission.and_then(|policy| {
4020 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4021 })
4022 } else {
4023 crate::provider_runtime::admit_status(
4029 &self.provider_runtime_statuses,
4030 provider,
4031 false,
4032 false,
4033 requirement,
4034 )
4035 };
4036 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4037 admission.map_err(|error| match error {
4038 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4039 NodeFailureCode::BackendOperationFailed,
4040 "provider launcher is unavailable",
4041 ),
4042 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4043 NodeFailureCode::UnsupportedCapability,
4044 "provider semantic capability is not verified",
4045 ),
4046 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4047 NodeFailureCode::BackendBusy,
4048 "provider runtime probe is already in progress",
4049 ),
4050 })
4051 }
4052
4053 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4059 match self.provider_runtime_monitor.as_deref() {
4060 Some(monitor) => monitor.supports_pipe_transport(provider),
4061 None => false,
4062 }
4063 }
4064
4065 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4071 match self.provider_runtime_monitor.as_deref() {
4072 Some(monitor) => monitor.supports_acp_transport(provider),
4073 None => false,
4074 }
4075 }
4076
4077 fn admit_declared_hook_semantics(
4093 &self,
4094 provider: &AgentId,
4095 policy: ProviderRuntimePolicy,
4096 ) -> ProviderRuntimePolicy {
4097 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4098 return policy;
4099 }
4100 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4101 contract.provider == *provider && contract.family == AdapterFamily::Hook
4102 });
4103 if !declares_hook {
4104 return policy;
4105 }
4106 ProviderRuntimePolicy::new(
4107 policy.raw_pty_lifecycle,
4108 policy.semantic_readiness,
4109 policy.structured_prompt,
4110 policy.provider_session_identity,
4111 policy.semantic_resume,
4112 true,
4113 )
4114 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4115 }
4116
4117 fn resolve_network_allowlist_overlay_args(
4132 &self,
4133 provider: &AgentId,
4134 mode: SessionMode,
4135 approval_level: ApprovalLevel,
4136 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4137 provider_home: Option<&Path>,
4138 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4139 let Some(allowlist_id) = network_allowlist else {
4140 return Ok(Vec::new());
4141 };
4142 let catalog = self
4143 .network_allowlist_catalog
4144 .read()
4145 .unwrap_or_else(|poisoned| poisoned.into_inner());
4146 let Some(entry) = catalog.get(allowlist_id) else {
4147 return Err(failure(
4148 NodeFailureCode::UnknownNetworkAllowlist,
4149 "spawn network allowlist is unavailable on this node",
4150 ));
4151 };
4152
4153 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4155 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4156 |message| {
4157 failure(
4158 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4159 &message,
4160 )
4161 },
4162 )?;
4163 if !matches!(mode, SessionMode::Pty) {
4164 return Err(failure(
4165 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4166 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4167 ));
4168 }
4169 let Some(home) = provider_home else {
4170 return Err(failure(
4171 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4172 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4173 ));
4174 };
4175 let domains =
4176 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4177 if domains.is_empty() {
4178 return Err(failure(
4179 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4180 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4181 ));
4182 }
4183 let settings_path = home.join(
4184 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4185 );
4186 let json =
4187 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4188 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4189 |_| {
4190 failure(
4191 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4192 "Claude station network settings sidecar could not be written under ProviderHome",
4193 )
4194 },
4195 )?;
4196 let overlay =
4197 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4198 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4199 }
4200
4201 let Some(native) = entry.provider_native.as_ref() else {
4202 return Ok(Vec::new());
4203 };
4204 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4205 provider.as_str(),
4206 approval_level,
4207 native,
4208 )
4209 .map_err(|message| {
4210 failure(
4211 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4212 &message,
4213 )
4214 })?;
4215 if overlay.is_empty() {
4216 return Ok(Vec::new());
4217 }
4218 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4219 return Err(failure(
4220 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4221 "Codex network_access -c overlay requires PTY or ACP transport",
4222 ));
4223 }
4224 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4225 }
4226
4227 fn resolve_codex_network_access_overlay_args(
4229 &self,
4230 provider: &AgentId,
4231 mode: SessionMode,
4232 approval_level: ApprovalLevel,
4233 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4234 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4235 self.resolve_network_allowlist_overlay_args(
4236 provider,
4237 mode,
4238 approval_level,
4239 network_allowlist,
4240 None,
4241 )
4242 }
4243
4244 fn resolve_environment_profile(
4245 &self,
4246 resolved: &ResolvedSpawnSpec,
4247 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4248 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4254 let catalog = self
4255 .network_allowlist_catalog
4256 .read()
4257 .unwrap_or_else(|poisoned| poisoned.into_inner());
4258 let Some(entry) = catalog.get(allowlist_id) else {
4259 return Err(failure(
4260 NodeFailureCode::UnknownNetworkAllowlist,
4261 "spawn network allowlist is unavailable on this node",
4262 ));
4263 };
4264 if let Some(native) = entry.provider_native.as_ref() {
4267 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4268 resolved.provider.as_str(),
4269 resolved.approval_level,
4270 native,
4271 ) {
4272 return Err(failure(
4273 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4274 &message,
4275 ));
4276 }
4277 }
4278 }
4279 #[cfg(feature = "dig2-station-probe")]
4288 if resolved.browser_profile_id.is_some() {
4289 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4290 match probe_station_reachable() {
4291 Ok(()) => {}
4292 Err(StationProbeError::UnsupportedPlatform)
4293 | Err(StationProbeError::InvalidPipeSuffix) => {
4294 return Err(failure(
4295 NodeFailureCode::BrowserStationProbeUnavailable,
4296 "dig2browser station probe unavailable on this platform",
4297 ));
4298 }
4299 Err(StationProbeError::Unreachable) => {
4300 return Err(failure(
4301 NodeFailureCode::BrowserStationUnreachable,
4302 "dig2browser station named-pipe is unreachable",
4303 ));
4304 }
4305 }
4306 }
4307 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4308 return Ok(None);
4309 };
4310 let profiles = self
4311 .environment_profiles
4312 .read()
4313 .unwrap_or_else(|poisoned| poisoned.into_inner());
4314 let binding = profiles.get(profile_id).ok_or_else(|| {
4315 failure(
4316 NodeFailureCode::UnknownEnvironmentProfile,
4317 "spawn environment profile is unavailable on this node",
4318 )
4319 })?;
4320 if binding.provider != resolved.provider
4321 || binding.native_profile_id(resolved.mode).is_none()
4322 {
4323 return Err(failure(
4324 NodeFailureCode::EnvironmentProfileBindingMismatch,
4325 "spawn environment profile does not match the provider and mode",
4326 ));
4327 }
4328 Ok(Some(ResolvedEnvironmentProfileReceipt {
4332 profile_id: binding.id.clone(),
4333 profile_revision: binding.revision.clone(),
4334 network_allowlist: resolved.network_allowlist.clone(),
4335 browser_profile_id: resolved.browser_profile_id.clone(),
4336 }))
4337 }
4338
4339 fn resolve_bundle(
4340 &self,
4341 resolved: &ResolvedSpawnSpec,
4342 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4343 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4344 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4345 return Ok(None);
4346 };
4347 let catalog = self.bundle_catalog
4348 .read()
4349 .unwrap_or_else(|poisoned| poisoned.into_inner());
4350 let bundle = catalog.get(bundle_id).ok_or_else(|| {
4351 failure(
4352 NodeFailureCode::UnknownBundle,
4353 "spawn bundle is unavailable on this node",
4354 )
4355 })?;
4356 self.bundle_layout(
4357 &resolved.provider,
4358 resolved.mode,
4359 environment_profile,
4360 bundle,
4361 )?;
4362 Ok(Some(bundle.receipt()))
4363 }
4364
4365 fn begin_delivery_stage(
4366 &self,
4367 manifest: DeliveryBundleManifestV2,
4368 ) -> Result<NodeResponse, NodeFailure> {
4369 let mut store = self
4370 .delivery_store
4371 .lock()
4372 .unwrap_or_else(|poisoned| poisoned.into_inner());
4373 let store = store.as_mut().ok_or_else(|| {
4374 failure(
4375 NodeFailureCode::DeliveryStageStorageFailed,
4376 "node delivery store is unavailable",
4377 )
4378 })?;
4379 let begun = store.begin(manifest).map_err(delivery_failure)?;
4380 Ok(NodeResponse::DeliveryStageBegun {
4381 stage_id: begun.stage_id,
4382 manifest_digest: begun.manifest_digest,
4383 missing_blobs: begun.missing_blobs,
4384 })
4385 }
4386
4387 fn put_delivery_blob_chunk(
4388 &self,
4389 stage_id: DeliveryStageId,
4390 blob_digest: DeliveryBlobDigestV1,
4391 offset: u64,
4392 chunk_hex: DeliveryBlobChunkHexV1,
4393 ) -> Result<NodeResponse, NodeFailure> {
4394 let mut store = self
4395 .delivery_store
4396 .lock()
4397 .unwrap_or_else(|poisoned| poisoned.into_inner());
4398 let store = store.as_mut().ok_or_else(|| {
4399 failure(
4400 NodeFailureCode::DeliveryStageStorageFailed,
4401 "node delivery store is unavailable",
4402 )
4403 })?;
4404 let accepted = store
4405 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4406 .map_err(delivery_failure)?;
4407 Ok(NodeResponse::DeliveryBlobChunkAccepted {
4408 stage_id: accepted.stage_id,
4409 blob_digest: accepted.blob_digest,
4410 next_offset: accepted.next_offset,
4411 })
4412 }
4413
4414 fn commit_delivery_stage(
4415 &self,
4416 stage_id: DeliveryStageId,
4417 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4418 let mut store = self
4419 .delivery_store
4420 .lock()
4421 .unwrap_or_else(|poisoned| poisoned.into_inner());
4422 let store = store.as_mut().ok_or_else(|| {
4423 failure(
4424 NodeFailureCode::DeliveryStageStorageFailed,
4425 "node delivery store is unavailable",
4426 )
4427 })?;
4428 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4429 let mut catalog = self
4430 .bundle_catalog
4431 .write()
4432 .unwrap_or_else(|poisoned| poisoned.into_inner());
4433 let mut next_catalog = catalog.clone();
4434 next_catalog
4435 .insert_idempotent(prepared.bundle().clone())
4436 .map_err(|_| {
4437 failure(
4438 NodeFailureCode::DeliveryStageConflict,
4439 "delivery bundle identity conflicts with installed content",
4440 )
4441 })?;
4442 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4443 *catalog = next_catalog;
4444 Ok(receipt)
4445 }
4446
4447 fn abort_delivery_stage(
4448 &self,
4449 stage_id: &DeliveryStageId,
4450 ) -> Result<(), NodeFailure> {
4451 let mut store = self
4452 .delivery_store
4453 .lock()
4454 .unwrap_or_else(|poisoned| poisoned.into_inner());
4455 let store = store.as_mut().ok_or_else(|| {
4456 failure(
4457 NodeFailureCode::DeliveryStageStorageFailed,
4458 "node delivery store is unavailable",
4459 )
4460 })?;
4461 store.abort(stage_id).map_err(delivery_failure)
4462 }
4463
4464 fn resolve_context(
4465 &self,
4466 resolved: &ResolvedSpawnSpec,
4467 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4468 let Some(context_id) = resolved.context_id.as_ref() else {
4469 return Ok(None);
4470 };
4471 self.context_catalog
4472 .read()
4473 .unwrap_or_else(|poisoned| poisoned.into_inner())
4474 .get(context_id)
4475 .map(|context| context.receipt().clone())
4476 .ok_or_else(|| {
4477 failure(
4478 NodeFailureCode::UnknownContextPack,
4479 "spawn context pack is unavailable on this node",
4480 )
4481 })
4482 .map(Some)
4483 }
4484
4485 fn bundle_layout(
4486 &self,
4487 provider: &AgentId,
4488 mode: SessionMode,
4489 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4490 bundle: &NodeBundle,
4491 ) -> Result<BundleProviderLayout, NodeFailure> {
4492 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4493 failure(
4494 NodeFailureCode::BundleBindingMismatch,
4495 "spawn bundle does not match the provider and mode",
4496 )
4497 })?;
4498 if layout != BundleProviderLayout::Codex {
4499 return Ok(layout);
4500 }
4501 let profile = environment_profile.and_then(|receipt| {
4502 let profiles = self
4503 .environment_profiles
4504 .read()
4505 .unwrap_or_else(|poisoned| poisoned.into_inner());
4506 let binding = profiles.get(&receipt.profile_id)?;
4507 if binding.revision != receipt.profile_revision
4508 || &binding.provider != provider
4509 || binding.native_profile_id(mode).is_none()
4510 {
4511 return None;
4512 }
4513 self.environment_materialization_profiles
4514 .read()
4515 .unwrap_or_else(|poisoned| poisoned.into_inner())
4516 .get(&receipt.profile_id)
4517 .cloned()
4518 });
4519 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4520 return Err(failure(
4521 NodeFailureCode::BundleBindingMismatch,
4522 "Codex bundle requires an exact isolated CODEX_HOME profile",
4523 ));
4524 }
4525 Ok(layout)
4526 }
4527
4528 fn select_environment_profile(
4529 &self,
4530 instance_id: AgentInstanceId,
4531 provider: &AgentId,
4532 mode: SessionMode,
4533 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4534 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4535 let Some(environment_profile) = environment_profile else {
4536 return Ok(None);
4537 };
4538 let native_profile_id = {
4539 let profiles = self
4540 .environment_profiles
4541 .read()
4542 .unwrap_or_else(|poisoned| poisoned.into_inner());
4543 let binding = profiles
4544 .get(&environment_profile.profile_id)
4545 .ok_or_else(|| {
4546 failure(
4547 NodeFailureCode::UnknownEnvironmentProfile,
4548 "session environment profile is unavailable on this node",
4549 )
4550 })?;
4551 if &binding.revision != &environment_profile.profile_revision
4552 || &binding.provider != provider
4553 {
4554 return Err(failure(
4555 NodeFailureCode::EnvironmentProfileBindingMismatch,
4556 "session environment profile revision or provider changed",
4557 ));
4558 }
4559 binding.native_profile_id(mode).cloned().ok_or_else(|| {
4560 failure(
4561 NodeFailureCode::EnvironmentProfileBindingMismatch,
4562 "session environment profile does not support this mode",
4563 )
4564 })?
4565 };
4566 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4567 failure(
4568 NodeFailureCode::BackendOperationFailed,
4569 "native environment profile controller is unavailable",
4570 )
4571 })?;
4572 control
4573 .select_native_launch_profile(instance_id, native_profile_id)
4574 .map_err(|_| {
4575 failure(
4576 NodeFailureCode::BackendOperationFailed,
4577 "native environment profile selection failed",
4578 )
4579 })?;
4580 Ok(Some(NativeEnvironmentSelectionGuard {
4581 control: Some(control),
4582 instance_id,
4583 }))
4584 }
4585
4586 fn install_prepared_launch_overlay(
4587 &self,
4588 instance_id: AgentInstanceId,
4589 overlay: PreparedNativeLaunchOverlay,
4590 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4591 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4592 failure(
4593 NodeFailureCode::BackendOperationFailed,
4594 "native launch profile controller is unavailable",
4595 )
4596 })?;
4597 match overlay {
4598 PreparedNativeLaunchOverlay::Environment(overlay) => {
4599 control
4600 .install_native_launch_environment_overlay(instance_id, overlay)
4601 .map_err(|_| failure(
4602 NodeFailureCode::BackendOperationFailed,
4603 "session environment overlay installation failed",
4604 ))?;
4605 Ok(None)
4606 }
4607 PreparedNativeLaunchOverlay::Instance(overlay) => {
4608 control
4609 .install_native_instance_launch_overlay(instance_id, overlay)
4610 .map_err(|_| failure(
4611 NodeFailureCode::BundleBindingMismatch,
4612 "session bundle launch overlay installation failed",
4613 ))?;
4614 Ok(Some(NativeInstanceOverlayGuard {
4615 control: Some(control),
4616 instance_id,
4617 }))
4618 }
4619 }
4620 }
4621
4622 fn environment_materialization_profile(
4623 &self,
4624 environment_profile: &ResolvedEnvironmentProfileReceipt,
4625 ) -> Option<NodeSessionMaterializationProfile> {
4626 self.environment_materialization_profiles
4627 .read()
4628 .unwrap_or_else(|poisoned| poisoned.into_inner())
4629 .get(&environment_profile.profile_id)
4630 .cloned()
4631 }
4632
4633 fn materialization_profile(
4634 &self,
4635 provider: &AgentId,
4636 mode: SessionMode,
4637 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4638 bundle: Option<&ResolvedBundleReceipt>,
4639 context: Option<&ResolvedContextPackReceipt>,
4640 ) -> Result<Option<(
4641 NodeSessionMaterializationProfile,
4642 bool,
4643 Option<BundleProviderLayout>,
4644 )>, NodeFailure> {
4645 let environment = environment_profile
4646 .and_then(|receipt| self.environment_materialization_profile(receipt));
4647 let (bundle, bundle_layout) = match bundle {
4648 Some(receipt) => {
4649 let catalog = self.bundle_catalog.read()
4650 .unwrap_or_else(|poisoned| poisoned.into_inner());
4651 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4652 NodeFailureCode::UnknownBundle,
4653 "session bundle is unavailable on this node",
4654 ))?;
4655 if installed.receipt() != *receipt {
4656 return Err(failure(
4657 NodeFailureCode::BundleBindingMismatch,
4658 "session bundle revision or digest changed",
4659 ));
4660 }
4661 let layout = self.bundle_layout(
4662 provider,
4663 mode,
4664 environment_profile,
4665 installed,
4666 )?;
4667 (Some(installed.clone()), Some(layout))
4668 }
4669 None => (None, None),
4670 };
4671 let has_environment_overlay = environment.is_some() || context.is_some();
4672 let profile = match (environment, bundle.as_ref(), bundle_layout) {
4673 (None, None, None) => None,
4674 (Some(profile), None, None) => Some(Ok(profile)),
4675 (None, Some(bundle), Some(layout)) => {
4676 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4677 }
4678 (Some(profile), Some(bundle), Some(layout)) => {
4679 Some(profile.with_bundle(bundle, layout))
4680 }
4681 _ => {
4682 return Err(failure(
4683 NodeFailureCode::BundleBindingMismatch,
4684 "session bundle provider layout is unavailable",
4685 ));
4686 }
4687 };
4688 let profile = profile
4689 .transpose()
4690 .map_err(|_| failure(
4691 NodeFailureCode::BundleMaterializationFailed,
4692 "session bundle materialization profile is invalid",
4693 ))?;
4694 let profile = match (profile, context) {
4695 (Some(profile), Some(receipt)) => {
4696 let catalog = self.context_catalog
4697 .read()
4698 .unwrap_or_else(|poisoned| poisoned.into_inner());
4699 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4700 NodeFailureCode::UnknownContextPack,
4701 "session context pack is unavailable on this node",
4702 ))?;
4703 if pack.receipt() != receipt {
4704 return Err(failure(
4705 NodeFailureCode::ContextPackMaterializationFailed,
4706 "session context pack identity changed",
4707 ));
4708 }
4709 profile.with_context(pack)
4710 }
4711 (None, Some(receipt)) => {
4712 let catalog = self.context_catalog
4713 .read()
4714 .unwrap_or_else(|poisoned| poisoned.into_inner());
4715 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4716 NodeFailureCode::UnknownContextPack,
4717 "session context pack is unavailable on this node",
4718 ))?;
4719 if pack.receipt() != receipt {
4720 return Err(failure(
4721 NodeFailureCode::ContextPackMaterializationFailed,
4722 "session context pack identity changed",
4723 ));
4724 }
4725 NodeSessionMaterializationProfile::from_context(pack)
4726 }
4727 (Some(profile), None) => Ok(profile),
4728 (None, None) => return Ok(None),
4729 }
4730 .map_err(|_| failure(
4731 NodeFailureCode::ContextPackMaterializationFailed,
4732 "session context pack materialization profile is invalid",
4733 ))?;
4734 Ok(Some((profile, has_environment_overlay, bundle_layout)))
4735 }
4736
4737 fn prepare_session_materialization<'a>(
4738 &'a self,
4739 address: &SessionAddress,
4740 provider: &AgentId,
4741 mode: SessionMode,
4742 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4743 bundle: Option<&ResolvedBundleReceipt>,
4744 context: Option<&ResolvedContextPackReceipt>,
4745 managed_lease_id: Option<ManagedWorktreeLeaseId>,
4746 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
4747 let Some((profile, has_environment_overlay, bundle_layout)) =
4748 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
4749 else {
4750 return Ok(None);
4751 };
4752 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4753 failure(
4754 NodeFailureCode::BackendOperationFailed,
4755 "session environment materializer is unavailable",
4756 )
4757 })?;
4758 let materialization_failure_code = if context.is_some() {
4759 NodeFailureCode::ContextPackMaterializationFailed
4760 } else if bundle.is_some() {
4761 NodeFailureCode::BundleMaterializationFailed
4762 } else {
4763 NodeFailureCode::BackendOperationFailed
4764 };
4765 let id = MaterializationId::new(format!(
4766 "mat-{}-{}-{}",
4767 self.incarnation_id,
4768 address.session.instance_id.0,
4769 address.session.generation.0,
4770 ))
4771 .map_err(|_| {
4772 failure(
4773 NodeFailureCode::BackendOperationFailed,
4774 "session environment materialization identity failed",
4775 )
4776 })?;
4777 let owner = MaterializationOwner::Session {
4778 incarnation_id: self.incarnation_id,
4779 instance_id: address.session.instance_id,
4780 generation: address.session.generation,
4781 };
4782 let mut ownership = materializer
4783 .begin(
4784 id.clone(),
4785 environment_profile.cloned(),
4786 bundle.cloned(),
4787 context.cloned(),
4788 owner,
4789 managed_lease_id,
4790 &profile,
4791 unix_time_ms(),
4792 )
4793 .map_err(|_| {
4794 failure(
4795 materialization_failure_code,
4796 "session materialization preparation failed",
4797 )
4798 })?;
4799 {
4800 let _transaction = self
4801 .state_transaction
4802 .lock()
4803 .unwrap_or_else(|poisoned| poisoned.into_inner());
4804 let duplicate = self
4805 .materializations
4806 .lock()
4807 .unwrap_or_else(|poisoned| poisoned.into_inner())
4808 .insert(id.clone(), ownership.clone());
4809 if duplicate.is_some() {
4810 return Err(failure(
4811 NodeFailureCode::BackendOperationFailed,
4812 "session environment materialization identity conflict",
4813 ));
4814 }
4815 if let Err(error) = self.persist_state_locked() {
4816 self.materializations
4817 .lock()
4818 .unwrap_or_else(|poisoned| poisoned.into_inner())
4819 .remove(&id);
4820 return Err(persistence_failure(error));
4821 }
4822 }
4823 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
4824 Ok(environment) => environment,
4825 Err(_) => {
4826 let transaction = self
4827 .state_transaction
4828 .lock()
4829 .unwrap_or_else(|poisoned| poisoned.into_inner());
4830 self.materializations
4831 .lock()
4832 .unwrap_or_else(|poisoned| poisoned.into_inner())
4833 .insert(id.clone(), ownership.clone());
4834 if self.persist_state_locked().is_err() {
4835 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4836 }
4837 drop(transaction);
4838 if ownership.state() == MaterializationState::CleanupRequired {
4839 let _ = self.cleanup_materialization(&id);
4840 }
4841 return Err(failure(
4842 materialization_failure_code,
4843 "session materialization failed",
4844 ));
4845 }
4846 };
4847 {
4848 let _transaction = self
4849 .state_transaction
4850 .lock()
4851 .unwrap_or_else(|poisoned| poisoned.into_inner());
4852 self.materializations
4853 .lock()
4854 .unwrap_or_else(|poisoned| poisoned.into_inner())
4855 .insert(id.clone(), ownership.clone());
4856 if let Err(error) = self.persist_state_locked() {
4857 drop(_transaction);
4858 let _ = self.cleanup_materialization(&id);
4859 return Err(persistence_failure(error));
4860 }
4861 }
4862 let guard = SessionMaterializationGuard {
4863 shared: self,
4864 id: Some(id),
4865 provider_home: Some(ownership.provider_home().to_path_buf()),
4866 };
4867 let bundle_arguments = match bundle {
4868 Some(receipt) => {
4869 let catalog = self.bundle_catalog.read()
4870 .unwrap_or_else(|poisoned| poisoned.into_inner());
4871 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4872 NodeFailureCode::UnknownBundle,
4873 "session bundle is unavailable on this node",
4874 ))?;
4875 if installed.receipt() != *receipt {
4876 return Err(failure(
4877 NodeFailureCode::BundleBindingMismatch,
4878 "session bundle revision or digest changed",
4879 ));
4880 }
4881 let layout = bundle_layout.ok_or_else(|| failure(
4882 NodeFailureCode::BundleBindingMismatch,
4883 "materialized bundle provider layout is unavailable",
4884 ))?;
4885 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
4886 failure(
4887 NodeFailureCode::BundleMaterializationFailed,
4888 "session bundle byte revalidation failed",
4889 )
4890 })?;
4891 Some(bundle_launch_arguments(
4892 layout,
4893 ownership.bundle_root(),
4894 ownership.provider_home(),
4895 ).map_err(|_| failure(
4896 NodeFailureCode::BundleBindingMismatch,
4897 "session bundle launch binding is invalid",
4898 ))?)
4899 }
4900 None => None,
4901 };
4902 if let Some(receipt) = context {
4903 materializer
4904 .revalidate_context(&ownership, receipt)
4905 .map_err(|_| failure(
4906 NodeFailureCode::ContextPackMaterializationFailed,
4907 "session context pack byte revalidation failed",
4908 ))?;
4909 }
4910 let transport = match mode {
4911 SessionMode::Pty => TransportKind::Pty,
4912 SessionMode::Inline => TransportKind::Pipe,
4913 SessionMode::Acp => TransportKind::Acp,
4914 };
4915 let overlay = if let Some(extra_args) = bundle_arguments {
4916 Some(PreparedNativeLaunchOverlay::Instance(
4917 NativeInstanceLaunchOverlay::new(
4918 provider.clone(),
4919 transport,
4920 if has_environment_overlay {
4921 environment
4922 } else {
4923 Vec::new()
4924 },
4925 extra_args,
4926 )
4927 .map_err(|_| {
4928 failure(
4929 NodeFailureCode::BundleBindingMismatch,
4930 "session bundle launch overlay is invalid",
4931 )
4932 })?,
4933 ))
4934 } else if has_environment_overlay {
4935 let context_only = environment_profile.is_none()
4936 && bundle.is_none()
4937 && context.is_some();
4938 let overlay = if context_only {
4939 NativeLaunchEnvironmentOverlay::new_context_root(
4940 provider.clone(),
4941 transport,
4942 environment,
4943 )
4944 .map_err(|_| {
4945 failure(
4946 NodeFailureCode::ContextPackMaterializationFailed,
4947 "context-only launch overlay is invalid",
4948 )
4949 })?
4950 } else {
4951 NativeLaunchEnvironmentOverlay::new(
4952 provider.clone(),
4953 transport,
4954 environment,
4955 )
4956 .map_err(|_| {
4957 failure(
4958 NodeFailureCode::BackendOperationFailed,
4959 "session environment overlay is invalid",
4960 )
4961 })?
4962 };
4963 Some(PreparedNativeLaunchOverlay::Environment(overlay))
4964 } else {
4965 None
4966 };
4967 Ok(Some((overlay, guard)))
4968 }
4969
4970 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
4971 let Some(mut ownership) = self
4972 .materializations
4973 .lock()
4974 .unwrap_or_else(|poisoned| poisoned.into_inner())
4975 .get(id)
4976 .cloned()
4977 else {
4978 return Ok(());
4979 };
4980 if ownership.state() != MaterializationState::CleanupRequired {
4981 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
4982 failure(
4983 NodeFailureCode::BackendOperationFailed,
4984 "session environment cleanup state is invalid",
4985 )
4986 })?;
4987 }
4988 {
4989 let _transaction = self
4990 .state_transaction
4991 .lock()
4992 .unwrap_or_else(|poisoned| poisoned.into_inner());
4993 let previous = self
4994 .materializations
4995 .lock()
4996 .unwrap_or_else(|poisoned| poisoned.into_inner())
4997 .insert(id.clone(), ownership.clone());
4998 if let Err(error) = self.persist_state_locked() {
4999 let mut materializations = self
5000 .materializations
5001 .lock()
5002 .unwrap_or_else(|poisoned| poisoned.into_inner());
5003 if let Some(previous) = previous {
5004 materializations.insert(id.clone(), previous);
5005 }
5006 return Err(persistence_failure(error));
5007 }
5008 }
5009 let cleanup = self
5010 .session_environment_materializer
5011 .as_ref()
5012 .ok_or(())
5013 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5014 if cleanup.is_err() {
5015 let _ = ownership.mark_recovery_required(unix_time_ms());
5016 self.materializations
5017 .lock()
5018 .unwrap_or_else(|poisoned| poisoned.into_inner())
5019 .insert(id.clone(), ownership);
5020 if self.persist_state().is_err() {
5021 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5022 }
5023 return Err(failure(
5024 NodeFailureCode::BackendOperationFailed,
5025 "session environment cleanup requires recovery",
5026 ));
5027 }
5028 let _transaction = self
5029 .state_transaction
5030 .lock()
5031 .unwrap_or_else(|poisoned| poisoned.into_inner());
5032 self.materializations
5033 .lock()
5034 .unwrap_or_else(|poisoned| poisoned.into_inner())
5035 .remove(id);
5036 if let Err(error) = self.persist_state_locked() {
5037 self.materializations
5038 .lock()
5039 .unwrap_or_else(|poisoned| poisoned.into_inner())
5040 .insert(id.clone(), ownership);
5041 return Err(persistence_failure(error));
5042 }
5043 Ok(())
5044 }
5045
5046 fn resolve_record_materialization(
5047 &self,
5048 record_id: &SessionRecordId,
5049 provider: &AgentId,
5050 mode: SessionMode,
5051 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5052 bundle: Option<&ResolvedBundleReceipt>,
5053 context: Option<&ResolvedContextPackReceipt>,
5054 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5055 let ownership = {
5056 let materializations = self
5057 .materializations
5058 .lock()
5059 .unwrap_or_else(|poisoned| poisoned.into_inner());
5060 let mut matches = materializations.values().filter(|record| {
5061 record.owner()
5062 == &MaterializationOwner::Record {
5063 record_id: record_id.clone(),
5064 }
5065 });
5066 let ownership = matches.next().cloned();
5067 if matches.next().is_some() {
5068 return Err(failure(
5069 NodeFailureCode::BackendOperationFailed,
5070 "managed session has conflicting environment ownership",
5071 ));
5072 }
5073 ownership
5074 };
5075 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5076 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5077 failure(
5078 NodeFailureCode::ContextPackMaterializationFailed,
5079 "session context materializer is unavailable",
5080 )
5081 })?;
5082 let pack = materializer
5083 .revalidate_context(ownership, receipt)
5084 .map_err(|_| {
5085 self.mark_materialization_recovery_required(ownership.id());
5086 failure(
5087 NodeFailureCode::ContextPackMaterializationFailed,
5088 "managed session context pack revalidation failed",
5089 )
5090 })?;
5091 self.context_catalog
5092 .write()
5093 .unwrap_or_else(|poisoned| poisoned.into_inner())
5094 .insert(pack)
5095 .map_err(|_| failure(
5096 NodeFailureCode::ContextPackMaterializationFailed,
5097 "managed session context pack catalog restore failed",
5098 ))?;
5099 }
5100 let materialization = self.materialization_profile(
5101 provider,
5102 mode,
5103 environment_profile,
5104 bundle,
5105 context,
5106 )?;
5107 match (ownership, materialization) {
5108 (None, None) => Ok(None),
5109 (None, Some(_)) | (Some(_), None) => Err(failure(
5110 NodeFailureCode::BackendOperationFailed,
5111 "managed session materialization is unavailable",
5112 )),
5113 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5114 if ownership.environment_profile() != environment_profile
5115 || ownership.bundle() != bundle
5116 || ownership.context() != context
5117 || ownership.state() != MaterializationState::Ready
5118 {
5119 self.mark_materialization_recovery_required(ownership.id());
5120 return Err(failure(
5121 NodeFailureCode::BackendOperationFailed,
5122 "managed session environment ownership requires recovery",
5123 ));
5124 }
5125 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5126 failure(
5127 NodeFailureCode::BackendOperationFailed,
5128 "session environment materializer is unavailable",
5129 )
5130 })?;
5131 if materializer.revalidate(&ownership).is_err() {
5132 self.mark_materialization_recovery_required(ownership.id());
5133 return Err(failure(
5134 NodeFailureCode::BackendOperationFailed,
5135 "managed session environment revalidation failed",
5136 ));
5137 }
5138 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5139 let catalog = self.bundle_catalog.read()
5140 .unwrap_or_else(|poisoned| poisoned.into_inner());
5141 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5142 return Err(failure(
5143 NodeFailureCode::UnknownBundle,
5144 "managed session bundle is unavailable",
5145 ));
5146 };
5147 let layout = bundle_layout.ok_or_else(|| failure(
5148 NodeFailureCode::BundleBindingMismatch,
5149 "record bundle provider layout is unavailable",
5150 ))?;
5151 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5152 drop(catalog);
5153 self.mark_materialization_recovery_required(ownership.id());
5154 return Err(failure(
5155 NodeFailureCode::BundleMaterializationFailed,
5156 "managed session bundle revalidation failed",
5157 ));
5158 }
5159 Some(bundle_launch_arguments(
5160 layout,
5161 ownership.bundle_root(),
5162 ownership.provider_home(),
5163 ).map_err(|_| failure(
5164 NodeFailureCode::BundleBindingMismatch,
5165 "managed session bundle launch binding is invalid",
5166 ))?)
5167 } else {
5168 None
5169 };
5170 if !has_environment_materialization {
5171 let overlay = bundle_arguments.map(|extra_args| {
5172 NativeInstanceLaunchOverlay::new(
5173 provider.clone(),
5174 TransportKind::Pty,
5175 Vec::new(),
5176 extra_args,
5177 )
5178 .map(PreparedNativeLaunchOverlay::Instance)
5179 .map_err(|_| failure(
5180 NodeFailureCode::BundleBindingMismatch,
5181 "managed session bundle launch overlay is invalid",
5182 ))
5183 }).transpose()?;
5184 return Ok(Some((ownership.id().clone(), overlay)));
5185 }
5186 let environment = match materializer.resolve_environment(&ownership, &profile) {
5187 Ok(environment) => environment,
5188 Err(
5189 SessionEnvironmentMaterializeError::SecretUnavailable
5190 | SessionEnvironmentMaterializeError::SecretDenied
5191 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5192 ) => {
5193 return Err(failure(
5194 NodeFailureCode::BackendOperationFailed,
5195 "managed session environment resolution failed",
5196 ));
5197 }
5198 Err(
5199 SessionEnvironmentMaterializeError::InvalidRoot
5200 | SessionEnvironmentMaterializeError::OwnershipMismatch
5201 | SessionEnvironmentMaterializeError::Filesystem(_)
5202 | SessionEnvironmentMaterializeError::Record(_),
5203 ) => {
5204 self.mark_materialization_recovery_required(ownership.id());
5205 return Err(failure(
5206 NodeFailureCode::BackendOperationFailed,
5207 "managed session environment resolution failed",
5208 ));
5209 }
5210 };
5211 let transport = match mode {
5212 SessionMode::Pty => TransportKind::Pty,
5213 SessionMode::Inline => TransportKind::Pipe,
5214 SessionMode::Acp => TransportKind::Acp,
5215 };
5216 let overlay = match bundle_arguments {
5217 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5218 NativeInstanceLaunchOverlay::new(
5219 provider.clone(),
5220 transport,
5221 environment,
5222 extra_args,
5223 )
5224 .map_err(|_| failure(
5225 NodeFailureCode::BundleBindingMismatch,
5226 "managed session bundle launch overlay is invalid",
5227 ))?,
5228 ),
5229 None => PreparedNativeLaunchOverlay::Environment(
5230 NativeLaunchEnvironmentOverlay::new(
5231 provider.clone(),
5232 transport,
5233 environment,
5234 )
5235 .map_err(|_| {
5236 failure(
5237 NodeFailureCode::BackendOperationFailed,
5238 "managed session environment overlay is invalid",
5239 )
5240 })?,
5241 ),
5242 };
5243 Ok(Some((ownership.id().clone(), Some(overlay))))
5244 }
5245 }
5246 }
5247
5248 fn destroy_record_materialization(
5249 &self,
5250 record_id: &SessionRecordId,
5251 ) -> Result<bool, NodeFailure> {
5252 let ids = self
5253 .materializations
5254 .lock()
5255 .unwrap_or_else(|poisoned| poisoned.into_inner())
5256 .values()
5257 .filter_map(|record| {
5258 (record.owner()
5259 == &MaterializationOwner::Record {
5260 record_id: record_id.clone(),
5261 })
5262 .then(|| record.id().clone())
5263 })
5264 .collect::<Vec<_>>();
5265 if ids.len() > 1 {
5266 return Err(failure(
5267 NodeFailureCode::BackendOperationFailed,
5268 "managed session has conflicting environment ownership",
5269 ));
5270 }
5271 let Some(id) = ids.first().cloned() else {
5272 return Ok(false);
5273 };
5274 let (terminal_record, mut cleanup_ownership) = {
5275 let _transaction = self
5276 .state_transaction
5277 .lock()
5278 .unwrap_or_else(|poisoned| poisoned.into_inner());
5279 let original_record = self.record(record_id)?;
5280 let mut terminal_record = original_record.clone();
5281 terminal_record.provider_session = None;
5282 terminal_record.active_session = None;
5283 terminal_record.state = ManagedSessionState::Unavailable;
5284 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5285 terminal_record.updated_at_unix_ms = unix_time_ms();
5286 let original_ownership = self
5287 .materializations
5288 .lock()
5289 .unwrap_or_else(|poisoned| poisoned.into_inner())
5290 .get(&id)
5291 .cloned()
5292 .ok_or_else(|| {
5293 failure(
5294 NodeFailureCode::BackendOperationFailed,
5295 "managed session environment ownership disappeared during cleanup",
5296 )
5297 })?;
5298 let mut cleanup_ownership = original_ownership.clone();
5299 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5300 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5301 failure(
5302 NodeFailureCode::BackendOperationFailed,
5303 "managed session environment cleanup state is invalid",
5304 )
5305 })?;
5306 }
5307 self.session_records
5308 .lock()
5309 .unwrap_or_else(|poisoned| poisoned.into_inner())
5310 .records
5311 .insert(record_id.clone(), terminal_record.clone());
5312 self.materializations
5313 .lock()
5314 .unwrap_or_else(|poisoned| poisoned.into_inner())
5315 .insert(id.clone(), cleanup_ownership.clone());
5316 if let Err(error) = self.persist_state_locked() {
5317 self.session_records
5318 .lock()
5319 .unwrap_or_else(|poisoned| poisoned.into_inner())
5320 .records
5321 .insert(record_id.clone(), original_record);
5322 self.materializations
5323 .lock()
5324 .unwrap_or_else(|poisoned| poisoned.into_inner())
5325 .insert(id.clone(), original_ownership);
5326 return Err(persistence_failure(error));
5327 }
5328 (terminal_record, cleanup_ownership)
5329 };
5330 let cleanup = self
5331 .session_environment_materializer
5332 .as_ref()
5333 .ok_or(())
5334 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5335 if cleanup.is_err() {
5336 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5337 self.materializations
5338 .lock()
5339 .unwrap_or_else(|poisoned| poisoned.into_inner())
5340 .insert(id.clone(), cleanup_ownership);
5341 if self.persist_state().is_err() {
5342 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5343 }
5344 return Err(failure(
5345 NodeFailureCode::BackendOperationFailed,
5346 "managed session environment cleanup requires recovery",
5347 ));
5348 }
5349 let _transaction = self
5350 .state_transaction
5351 .lock()
5352 .unwrap_or_else(|poisoned| poisoned.into_inner());
5353 self.remove_record_memory(record_id);
5354 self.materializations
5355 .lock()
5356 .unwrap_or_else(|poisoned| poisoned.into_inner())
5357 .remove(&id);
5358 if let Err(error) = self.persist_state_locked() {
5359 self.session_records
5360 .lock()
5361 .unwrap_or_else(|poisoned| poisoned.into_inner())
5362 .records
5363 .insert(record_id.clone(), terminal_record);
5364 self.materializations
5365 .lock()
5366 .unwrap_or_else(|poisoned| poisoned.into_inner())
5367 .insert(id, cleanup_ownership);
5368 return Err(persistence_failure(error));
5369 }
5370 Ok(true)
5371 }
5372
5373 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5374 let changed = {
5375 let mut materializations = self
5376 .materializations
5377 .lock()
5378 .unwrap_or_else(|poisoned| poisoned.into_inner());
5379 let Some(ownership) = materializations.get_mut(id) else {
5380 return;
5381 };
5382 ownership.mark_recovery_required(unix_time_ms()).is_ok()
5383 };
5384 if changed && self.persist_state().is_err() {
5385 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5386 }
5387 }
5388
5389 fn reconcile_materializations(&self) {
5390 let records = self
5391 .materializations
5392 .lock()
5393 .unwrap_or_else(|poisoned| poisoned.into_inner())
5394 .values()
5395 .cloned()
5396 .collect::<Vec<_>>();
5397 for mut ownership in records {
5398 let result = match (ownership.state(), ownership.owner()) {
5399 (MaterializationState::Preparing, _)
5400 | (MaterializationState::CleanupRequired, _) => {
5401 self.cleanup_materialization(ownership.id())
5402 }
5403 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5404 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5405 self.materializations
5406 .lock()
5407 .unwrap_or_else(|poisoned| poisoned.into_inner())
5408 .insert(ownership.id().clone(), ownership.clone());
5409 self.persist_state().map_err(persistence_failure)
5410 } else {
5411 Err(failure(
5412 NodeFailureCode::BackendOperationFailed,
5413 "session environment reconciliation failed",
5414 ))
5415 }
5416 }
5417 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5418 .session_environment_materializer
5419 .as_ref()
5420 .ok_or_else(|| {
5421 failure(
5422 NodeFailureCode::BackendOperationFailed,
5423 "session environment materializer is unavailable",
5424 )
5425 })
5426 .and_then(|materializer| {
5427 materializer.revalidate(&ownership).map_err(|_| failure(
5428 NodeFailureCode::BackendOperationFailed,
5429 "session materialization revalidation failed",
5430 ))?;
5431 let record = self.record(record_id)?;
5432 if record.environment_profile.as_ref() != ownership.environment_profile()
5433 || record.bundle.as_ref() != ownership.bundle()
5434 || record.context.as_ref() != ownership.context()
5435 {
5436 return Err(failure(
5437 NodeFailureCode::BackendOperationFailed,
5438 "session materialization ownership changed after restart",
5439 ));
5440 }
5441 if let Some(receipt) = ownership.bundle() {
5442 let catalog = self.bundle_catalog.read()
5443 .unwrap_or_else(|poisoned| poisoned.into_inner());
5444 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5445 NodeFailureCode::UnknownBundle,
5446 "session bundle is unavailable after restart",
5447 ))?;
5448 if bundle.receipt() != *receipt {
5449 return Err(failure(
5450 NodeFailureCode::BundleBindingMismatch,
5451 "session bundle changed after restart",
5452 ));
5453 }
5454 let layout = self.bundle_layout(
5455 &record.provider,
5456 record.mode,
5457 record.environment_profile.as_ref(),
5458 bundle,
5459 )?;
5460 materializer
5461 .revalidate_bundle(&ownership, bundle, layout)
5462 .map_err(|_| failure(
5463 NodeFailureCode::BundleMaterializationFailed,
5464 "session bundle revalidation failed",
5465 ))?;
5466 }
5467 if let Some(receipt) = ownership.context() {
5468 let pack = materializer
5469 .revalidate_context(&ownership, receipt)
5470 .map_err(|_| {
5471 failure(
5472 NodeFailureCode::ContextPackMaterializationFailed,
5473 "session context revalidation failed",
5474 )
5475 })?;
5476 self.context_catalog
5477 .write()
5478 .unwrap_or_else(|poisoned| poisoned.into_inner())
5479 .insert(pack)
5480 .map_err(|_| {
5481 failure(
5482 NodeFailureCode::ContextPackMaterializationFailed,
5483 "session context catalog restore failed",
5484 )
5485 })?;
5486 }
5487 Ok(())
5488 }),
5489 (MaterializationState::RecoveryRequired, _) => Ok(()),
5490 };
5491 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5492 let _ = ownership.mark_recovery_required(unix_time_ms());
5493 self.materializations
5494 .lock()
5495 .unwrap_or_else(|poisoned| poisoned.into_inner())
5496 .insert(ownership.id().clone(), ownership);
5497 if self.persist_state().is_err() {
5498 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5499 }
5500 }
5501 }
5502 let materialized_profiles = self
5503 .environment_materialization_profiles
5504 .read()
5505 .unwrap_or_else(|poisoned| poisoned.into_inner())
5506 .keys()
5507 .cloned()
5508 .collect::<Vec<_>>();
5509 let ownership = self
5510 .materializations
5511 .lock()
5512 .unwrap_or_else(|poisoned| poisoned.into_inner())
5513 .values()
5514 .cloned()
5515 .collect::<Vec<_>>();
5516 let changed = {
5517 let mut session_records = self
5518 .session_records
5519 .lock()
5520 .unwrap_or_else(|poisoned| poisoned.into_inner());
5521 let mut changed = false;
5522 for record in session_records.records.values_mut() {
5523 let environment_profile = record.environment_profile.as_ref();
5524 if environment_profile.is_none()
5525 && record.bundle.is_none()
5526 && record.context.is_none()
5527 {
5528 continue;
5529 }
5530 if record.bundle.is_none()
5531 && environment_profile.is_some_and(|profile| {
5532 !materialized_profiles.contains(&profile.profile_id)
5533 })
5534 {
5535 continue;
5536 }
5537 let has_exact_ownership = ownership.iter().any(|candidate| {
5538 candidate.state() == MaterializationState::Ready
5539 && candidate.environment_profile() == environment_profile
5540 && candidate.bundle() == record.bundle.as_ref()
5541 && candidate.context() == record.context.as_ref()
5542 && candidate.owner()
5543 == &MaterializationOwner::Record {
5544 record_id: record.record_id.clone(),
5545 }
5546 });
5547 if !has_exact_ownership {
5548 record.provider_session = None;
5549 record.active_session = None;
5550 record.state = ManagedSessionState::Unavailable;
5551 record.last_error = Some(if record.bundle.is_some() {
5552 "bundle-unavailable".to_owned()
5553 } else if record.context.is_some() {
5554 "context-unavailable".to_owned()
5555 } else {
5556 "environment-profile-unavailable".to_owned()
5557 });
5558 record.updated_at_unix_ms = unix_time_ms();
5559 changed = true;
5560 }
5561 }
5562 changed
5563 };
5564 if changed && self.persist_state().is_err() {
5565 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5566 }
5567 }
5568
5569 fn replay_spawn_spec(
5570 &self,
5571 spec: &SpawnSpec,
5572 now: Instant,
5573 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5574 let mut cache = self
5575 .spawn_idempotency
5576 .lock()
5577 .unwrap_or_else(|poisoned| poisoned.into_inner());
5578 cache.entries.retain(|_, entry| entry.expires_at > now);
5579 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5580 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5581 return Err(failure(
5582 NodeFailureCode::SpawnIdempotencyConflict,
5583 "spawn idempotency key was reused across spawn kinds",
5584 ));
5585 };
5586 if existing != spec {
5587 return Err(failure(
5588 NodeFailureCode::SpawnIdempotencyConflict,
5589 "spawn idempotency key was reused with a different specification",
5590 ));
5591 }
5592 return Ok(Some(result.clone()));
5593 }
5594 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5595 return Err(failure(
5596 NodeFailureCode::SpawnIdempotencyCapacity,
5597 "spawn idempotency cache reached its bounded capacity",
5598 ));
5599 }
5600 Ok(None)
5601 }
5602
5603 fn remember_spawn_spec(
5604 &self,
5605 spec: SpawnSpec,
5606 result: Result<ResolvedSpawnReceipt, NodeFailure>,
5607 accepted_at: Instant,
5608 ) {
5609 self
5610 .spawn_idempotency
5611 .lock()
5612 .unwrap_or_else(|poisoned| poisoned.into_inner())
5613 .entries
5614 .insert(
5615 spec.idempotency_key.clone(),
5616 SpawnIdempotencyEntry {
5617 value: SpawnIdempotencyValue::Standard { spec, result },
5618 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5619 },
5620 );
5621 }
5622
5623 fn claim_harness_mcp_spawn(
5624 &self,
5625 _reservation_id: &HarnessMcpReservationId,
5626 _activation_digest: &HarnessMcpActivationDigest,
5627 spec: &SpawnSpec,
5628 now: Instant,
5629 ) -> Result<(), NodeFailure> {
5630 let mut cache = self.spawn_idempotency.lock()
5631 .unwrap_or_else(|poisoned| poisoned.into_inner());
5632 cache.entries.retain(|_, entry| entry.expires_at > now);
5633 if cache.entries.contains_key(&spec.idempotency_key) {
5634 return Err(failure(
5635 NodeFailureCode::SpawnIdempotencyConflict,
5636 "spawn idempotency key was reused across spawn kinds",
5637 ));
5638 }
5639 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5640 return Err(failure(
5641 NodeFailureCode::SpawnIdempotencyCapacity,
5642 "spawn idempotency cache reached its bounded capacity",
5643 ));
5644 }
5645 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5646 value: SpawnIdempotencyValue::HarnessMcp,
5647 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5648 });
5649 Ok(())
5650 }
5651
5652 fn replay_managed_spawn(
5653 &self,
5654 request: &ManagedWorktreeSpawnRequest,
5655 now: Instant,
5656 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5657 let mut cache = self.spawn_idempotency.lock()
5658 .unwrap_or_else(|poisoned| poisoned.into_inner());
5659 cache.entries.retain(|_, entry| entry.expires_at > now);
5660 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5661 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5662 return Err(failure(
5663 NodeFailureCode::SpawnIdempotencyConflict,
5664 "spawn idempotency key was reused across spawn kinds",
5665 ));
5666 };
5667 if existing != request {
5668 return Err(failure(
5669 NodeFailureCode::SpawnIdempotencyConflict,
5670 "spawn idempotency key was reused with a different managed specification",
5671 ));
5672 }
5673 return Ok(Some(result.clone()));
5674 }
5675 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5676 return Err(failure(
5677 NodeFailureCode::SpawnIdempotencyCapacity,
5678 "spawn idempotency cache reached its bounded capacity",
5679 ));
5680 }
5681 Ok(None)
5682 }
5683
5684 fn remember_managed_spawn(
5685 &self,
5686 request: ManagedWorktreeSpawnRequest,
5687 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5688 accepted_at: Instant,
5689 ) {
5690 self.spawn_idempotency.lock()
5691 .unwrap_or_else(|poisoned| poisoned.into_inner())
5692 .entries
5693 .insert(
5694 request.spawn_spec.idempotency_key.clone(),
5695 SpawnIdempotencyEntry {
5696 value: SpawnIdempotencyValue::Managed { request, result },
5697 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5698 },
5699 );
5700 }
5701
5702 fn remember_managed_spawn_attempt(
5703 &self,
5704 durable_v2_key: Option<&SpawnIdempotencyKey>,
5705 request: ManagedWorktreeSpawnRequest,
5706 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5707 accepted_at: Instant,
5708 ) {
5709 if durable_v2_key.is_none() {
5710 self.remember_managed_spawn(request, result, accepted_at);
5711 }
5712 }
5713
5714 fn acquire_durable_managed_spawn_v2(
5715 &self,
5716 request: &ManagedWorktreeSpawnRequestV2,
5717 request_digest: String,
5718 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5719 if self.state_path.is_none() {
5720 return Err(failure(
5721 NodeFailureCode::BackendOperationFailed,
5722 "managed worktree V2 requires durable node state",
5723 ));
5724 }
5725 let _transaction = self.state_transaction
5726 .lock()
5727 .unwrap_or_else(|poisoned| poisoned.into_inner());
5728 let key = request.spawn_spec.idempotency_key.clone();
5729 let mut previous = None;
5730 {
5731 let mut replays = self.managed_spawn_replays
5732 .lock()
5733 .unwrap_or_else(|poisoned| poisoned.into_inner());
5734 if let Some(record) = replays.get_mut(&key) {
5735 if record.request_digest != request_digest {
5736 return Err(failure(
5737 NodeFailureCode::SpawnIdempotencyConflict,
5738 "managed worktree V2 idempotency key was reused with a different request",
5739 ));
5740 }
5741 match &record.state {
5742 ManagedWorktreeSpawnReplayStateV10::Pending {
5743 owner_incarnation_id,
5744 lease_id: None,
5745 } if owner_incarnation_id != &self.incarnation_id => {
5746 previous = Some(record.clone());
5747 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
5748 owner_incarnation_id: self.incarnation_id,
5749 lease_id: None,
5750 };
5751 record.updated_at_unix_ms = unix_time_ms();
5752 }
5753 ManagedWorktreeSpawnReplayStateV10::Pending {
5754 lease_id: None,
5755 ..
5756 } => {
5757 return Err(failure(
5758 NodeFailureCode::BackendBusy,
5759 "managed worktree V2 reservation is already active",
5760 ));
5761 }
5762 ManagedWorktreeSpawnReplayStateV10::Pending {
5763 lease_id: Some(_),
5764 ..
5765 } => {
5766 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
5767 }
5768 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
5769 return Ok(DurableManagedSpawnReplayDecision::Committed(
5770 receipt.clone(),
5771 ));
5772 }
5773 }
5774 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
5775 return Err(failure(
5776 NodeFailureCode::SpawnIdempotencyCapacity,
5777 "durable managed worktree replay ledger reached its bounded capacity",
5778 ));
5779 } else {
5780 let now = unix_time_ms();
5781 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
5782 idempotency_key: key.clone(),
5783 request_digest,
5784 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
5785 profile_id: request.worktree_profile_id.clone(),
5786 expected_profile_revision: request.expected_profile_revision.clone(),
5787 state: ManagedWorktreeSpawnReplayStateV10::Pending {
5788 owner_incarnation_id: self.incarnation_id,
5789 lease_id: None,
5790 },
5791 created_at_unix_ms: now,
5792 updated_at_unix_ms: now,
5793 });
5794 }
5795 }
5796 if let Err(error) = self.persist_state_locked() {
5797 let mut replays = self.managed_spawn_replays
5798 .lock()
5799 .unwrap_or_else(|poisoned| poisoned.into_inner());
5800 match previous {
5801 Some(previous) => {
5802 replays.insert(key, previous);
5803 }
5804 None => {
5805 replays.remove(&key);
5806 }
5807 }
5808 return Err(persistence_failure(error));
5809 }
5810 Ok(DurableManagedSpawnReplayDecision::Reserved)
5811 }
5812
5813 fn link_durable_managed_spawn_v2_lease_locked(
5814 &self,
5815 key: &SpawnIdempotencyKey,
5816 lease_id: &ManagedWorktreeLeaseId,
5817 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
5818 let mut replays = self.managed_spawn_replays
5819 .lock()
5820 .unwrap_or_else(|poisoned| poisoned.into_inner());
5821 let record = replays.get_mut(key).ok_or_else(|| {
5822 failure(
5823 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5824 "managed worktree V2 reservation disappeared before allocation",
5825 )
5826 })?;
5827 let previous = record.clone();
5828 match &mut record.state {
5829 ManagedWorktreeSpawnReplayStateV10::Pending {
5830 lease_id: current,
5831 ..
5832 }
5833 if current.is_none() =>
5834 {
5835 *current = Some(lease_id.clone());
5836 record.updated_at_unix_ms = unix_time_ms();
5837 Ok(previous)
5838 }
5839 _ => Err(failure(
5840 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5841 "managed worktree V2 reservation is not allocation-ready",
5842 )),
5843 }
5844 }
5845
5846 fn restore_durable_managed_spawn_v2_locked(
5847 &self,
5848 key: &SpawnIdempotencyKey,
5849 previous: ManagedWorktreeSpawnReplayRecordV10,
5850 ) {
5851 self.managed_spawn_replays
5852 .lock()
5853 .unwrap_or_else(|poisoned| poisoned.into_inner())
5854 .insert(key.clone(), previous);
5855 }
5856
5857 fn commit_durable_managed_spawn_v2(
5858 &self,
5859 request: &ManagedWorktreeSpawnRequestV2,
5860 receipt: ManagedWorktreeSpawnReceipt,
5861 ) -> Result<(), NodeFailure> {
5862 let _transaction = self.state_transaction
5863 .lock()
5864 .unwrap_or_else(|poisoned| poisoned.into_inner());
5865 let key = &request.spawn_spec.idempotency_key;
5866 let previous = {
5867 let mut replays = self.managed_spawn_replays
5868 .lock()
5869 .unwrap_or_else(|poisoned| poisoned.into_inner());
5870 let record = replays.get_mut(key).ok_or_else(|| {
5871 failure(
5872 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5873 "managed worktree V2 reservation disappeared before commit",
5874 )
5875 })?;
5876 let previous = record.clone();
5877 match &record.state {
5878 ManagedWorktreeSpawnReplayStateV10::Pending {
5879 lease_id: Some(lease_id),
5880 ..
5881 } if lease_id == &receipt.lease.lease_id => {}
5882 _ => {
5883 return Err(failure(
5884 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5885 "managed worktree V2 reservation is not linked to the spawned lease",
5886 ));
5887 }
5888 }
5889 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
5890 record.updated_at_unix_ms = unix_time_ms();
5891 previous
5892 };
5893 if let Err(error) = self.persist_state_locked() {
5894 self.restore_durable_managed_spawn_v2_locked(key, previous);
5895 return Err(persistence_failure(error));
5896 }
5897 Ok(())
5898 }
5899
5900 fn remove_unallocated_durable_managed_spawn_v2(
5901 &self,
5902 request: &ManagedWorktreeSpawnRequestV2,
5903 ) -> Result<(), NodeFailure> {
5904 let _transaction = self.state_transaction
5905 .lock()
5906 .unwrap_or_else(|poisoned| poisoned.into_inner());
5907 let key = &request.spawn_spec.idempotency_key;
5908 let removed = {
5909 let mut replays = self.managed_spawn_replays
5910 .lock()
5911 .unwrap_or_else(|poisoned| poisoned.into_inner());
5912 match replays.get(key).map(|record| &record.state) {
5913 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
5914 lease_id: None,
5915 ..
5916 }) => {
5917 replays.remove(key)
5918 }
5919 _ => None,
5920 }
5921 };
5922 let Some(removed) = removed else {
5923 return Ok(());
5924 };
5925 if let Err(error) = self.persist_state_locked() {
5926 self.managed_spawn_replays
5927 .lock()
5928 .unwrap_or_else(|poisoned| poisoned.into_inner())
5929 .insert(key.clone(), removed);
5930 return Err(persistence_failure(error));
5931 }
5932 Ok(())
5933 }
5934
5935 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
5936 if spec.target.node_id != self.node_id {
5937 return Err(failure(
5938 NodeFailureCode::SpawnTargetMismatch,
5939 "spawn target does not match this node",
5940 ));
5941 }
5942 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
5943 failure(
5944 NodeFailureCode::UnknownSpawnProfile,
5945 "spawn profile is unavailable on this node",
5946 )
5947 })?;
5948 let resolved = spec.resolve(defaults).map_err(|error| match error {
5949 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
5950 NodeFailureCode::SpawnProfileRevisionMismatch,
5951 "spawn profile revision does not match the loaded profile",
5952 ),
5953 _ => failure(
5954 NodeFailureCode::InvalidRequest,
5955 "spawn specification could not be resolved",
5956 ),
5957 })?;
5958 if resolved.target.worktree_id.is_some() {
5959 self.require_worktree_service(&resolved.target.workspace_id)?;
5960 }
5961 let environment_profile = self.resolve_environment_profile(&resolved)?;
5962 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
5963 self.resolve_context(&resolved)?;
5964 Ok(resolved)
5965 }
5966
5967 async fn resolve_spawn_workspace(
5968 &self,
5969 resolved: &ResolvedSpawnSpec,
5970 deadline: Instant,
5971 ) -> Result<WorkspaceId, NodeFailure> {
5972 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
5973 return Ok(resolved.target.workspace_id.clone());
5974 };
5975 if self.managed_worktrees.lock()
5976 .unwrap_or_else(|poisoned| poisoned.into_inner())
5977 .lease_for_workspace(worktree_id)
5978 .is_some()
5979 {
5980 return Err(failure(
5981 NodeFailureCode::ManagedWorktreeOwnershipConflict,
5982 "managed worktree targets may only be spawned through their lease request",
5983 ));
5984 }
5985 self.require_worktree_service(&resolved.target.workspace_id)?;
5986 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
5987 let selected_root = self.workspace_root(worktree_id)?;
5988 let remaining = spawn_deadline_remaining(deadline)?;
5989 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
5990 .await
5991 .map_err(|_| {
5992 failure(
5993 NodeFailureCode::SpawnDeadlineExceeded,
5994 "Git worktree selection exceeded the spawn deadline",
5995 )
5996 })?
5997 .map_err(git_worktree_failure)?;
5998 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
5999 Ok(worktree_id.clone())
6000 }
6001
6002 async fn spawn_from_spec(
6003 &self,
6004 spec: SpawnSpec,
6005 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6006 self.spawn_from_spec_with_proxy(spec, None).await
6007 }
6008
6009 async fn spawn_from_spec_with_proxy(
6010 &self,
6011 spec: SpawnSpec,
6012 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6013 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6014 let accepted_at = Instant::now();
6015 if harness_mcp.is_none() {
6016 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6017 return replayed;
6018 }
6019 }
6020 let resolved = self.resolve_spawn_spec(&spec)?;
6021 let environment_profile = self.resolve_environment_profile(&resolved)?;
6022 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6023 let context = self.resolve_context(&resolved)?;
6024 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6025 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6026 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6027 let spawn_workspace_id = match spawn_workspace_id {
6028 Ok(workspace_id) => workspace_id,
6029 Err(error) => {
6030 let result = Err(error);
6031 if harness_mcp.is_none() {
6032 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6033 }
6034 return result;
6035 }
6036 };
6037 if self.managed_worktrees.lock()
6038 .unwrap_or_else(|poisoned| poisoned.into_inner())
6039 .lease_for_workspace(&spawn_workspace_id)
6040 .is_some()
6041 {
6042 let result = Err(failure(
6043 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6044 "managed worktree targets may only be spawned through their lease request",
6045 ));
6046 if harness_mcp.is_none() {
6047 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6048 }
6049 return result;
6050 }
6051 let result = self
6052 .spawn_session_with_deadline(
6053 spawn_workspace_id,
6054 resolved.provider.clone(),
6055 resolved.mode,
6056 resolved.terminal_size,
6057 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6058 environment_profile.clone(),
6059 bundle.clone(),
6060 context.clone(),
6061 None,
6062 None,
6063 SpawnRecordPolicy::Always,
6064 Some(deadline),
6065 &required_capabilities,
6066 harness_mcp,
6067 resolved.approval_level,
6068 resolved.network_allowlist.clone(),
6069 resolved.browser_profile_id.clone(),
6070 )
6071 .await
6072 .map(|(session, _runtime_policy)| {
6073 resolved.receipt_with_materialization(
6074 self.incarnation_id,
6075 session,
6076 environment_profile,
6077 bundle,
6078 context,
6079 )
6080 });
6081 if harness_mcp.is_none() {
6082 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6083 }
6084 result
6085 }
6086
6087 async fn spawn_from_spec_with_harness_mcp(
6088 &self,
6089 reservation_id: HarnessMcpReservationId,
6090 activation_digest: HarnessMcpActivationDigest,
6091 spec: SpawnSpec,
6092 deadline_unix_ms: u64,
6093 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6094 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6095 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6096 })?;
6097 if let Some(receipt) = registry
6098 .replay_spawn(&reservation_id, &activation_digest, &spec)
6099 .map_err(harness_mcp_failure)?
6100 {
6101 return Ok(receipt);
6102 }
6103 let resolved = self.resolve_spawn_spec(&spec)?;
6104 let prepared = registry
6105 .prepare_spawn(
6106 &reservation_id,
6107 &activation_digest,
6108 &spec,
6109 deadline_unix_ms,
6110 resolved.provider.clone(),
6111 )
6112 .map_err(harness_mcp_failure)?;
6113 self.claim_harness_mcp_spawn(
6114 &reservation_id,
6115 &activation_digest,
6116 &spec,
6117 Instant::now(),
6118 )?;
6119 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6120 let mut receipt = match spawn {
6121 Ok(receipt) => receipt,
6122 Err(error) => {
6123 let _ = registry.abort(&reservation_id, &activation_digest);
6124 return Err(error);
6125 }
6126 };
6127 let record_id = self
6128 .session_bindings
6129 .lock()
6130 .unwrap_or_else(|poisoned| poisoned.into_inner())
6131 .get(&receipt.session.session.instance_id)
6132 .and_then(|binding| binding.record_id.clone());
6133 let record_id = match record_id {
6134 Some(record_id) => record_id,
6135 None => {
6136 let rollback = self.remove_session(&receipt.session).await;
6137 let _ = registry.abort(&reservation_id, &activation_digest);
6138 if let Err(rollback_error) = rollback {
6139 return Err(rollback_error);
6140 }
6141 return Err(failure(
6142 NodeFailureCode::BindingMismatch,
6143 "harness MCP spawn did not retain its exact managed record",
6144 ));
6145 }
6146 };
6147 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6148 reservation_id: reservation_id.clone(),
6149 activation_digest: activation_digest.clone(),
6150 });
6151 if let Err(error) = registry.mark_spawned(
6152 &prepared,
6153 receipt.session.clone(),
6154 record_id,
6155 receipt.clone(),
6156 ) {
6157 let rollback = self.remove_session(&receipt.session).await;
6158 let _ = registry.abort(&reservation_id, &activation_digest);
6159 if let Err(rollback_error) = rollback {
6160 return Err(rollback_error);
6161 }
6162 return Err(harness_mcp_failure(error));
6163 }
6164 Ok(receipt)
6165 }
6166
6167 async fn spawn_managed_worktree(
6168 &self,
6169 request: ManagedWorktreeSpawnRequest,
6170 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6171 self.spawn_managed_worktree_inner(request, None).await
6172 }
6173
6174 async fn spawn_managed_worktree_inner(
6175 &self,
6176 request: ManagedWorktreeSpawnRequest,
6177 durable_v2_key: Option<&SpawnIdempotencyKey>,
6178 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6179 let accepted_at = Instant::now();
6180 if durable_v2_key.is_none() {
6181 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6182 return replayed;
6183 }
6184 }
6185 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6186 if request.spawn_spec.target.worktree_id.is_some() {
6187 let result = Err(failure(
6188 NodeFailureCode::InvalidRequest,
6189 "managed worktree spawn must not select a caller-provided worktree",
6190 ));
6191 self.remember_managed_spawn_attempt(
6192 durable_v2_key,
6193 request,
6194 result.clone(),
6195 accepted_at,
6196 );
6197 return result;
6198 }
6199 let environment_profile = self.resolve_environment_profile(&resolved)?;
6200 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6201 let context = self.resolve_context(&resolved)?;
6202 let source_workspace_id = resolved.target.workspace_id.clone();
6203 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6204 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6205 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6206 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6207 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6208 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6209 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6210 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6211 };
6212 let runtime_policy = timeout(
6213 spawn_deadline_remaining(deadline)?,
6214 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6215 ).await.map_err(|_| failure(
6216 NodeFailureCode::SpawnDeadlineExceeded,
6217 "provider admission exceeded the managed spawn deadline",
6218 ))??;
6219 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6220 return Err(failure(
6221 NodeFailureCode::UnsupportedSpawnCapability,
6222 "provider runtime does not admit a required spawn capability",
6223 ));
6224 }
6225 self.ensure_binding_capacity()?;
6226 let source_root = self.workspace_root(&source_workspace_id)?;
6227 profile.validate_for_workspace(&source_root)
6228 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6229 let base_timeout_ms = u64::try_from(
6230 spawn_deadline_remaining(deadline)?.as_millis(),
6231 ).unwrap_or(u64::MAX).max(1);
6232 let base_commit = resolve_base_commit_with_timeout(
6233 &source_root,
6234 profile.base(),
6235 base_timeout_ms,
6236 ).await.map_err(|error| {
6237 if Instant::now() >= deadline || error.message.contains("timed out") {
6238 failure(
6239 NodeFailureCode::SpawnDeadlineExceeded,
6240 "managed worktree base resolution exceeded the spawn deadline",
6241 )
6242 } else {
6243 managed_git_worktree_failure(error)
6244 }
6245 })?;
6246
6247 let lease = {
6248 let _transaction = self.state_transaction.lock()
6249 .unwrap_or_else(|poisoned| poisoned.into_inner());
6250 let previous = self.managed_worktrees.lock()
6251 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6252 let lease = self.managed_worktrees.lock()
6253 .unwrap_or_else(|poisoned| poisoned.into_inner())
6254 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6255 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6256 let previous_replay = match durable_v2_key
6257 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6258 .transpose()
6259 {
6260 Ok(previous) => previous,
6261 Err(error) => {
6262 *self.managed_worktrees.lock()
6263 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6264 return Err(error);
6265 }
6266 };
6267 if let Err(error) = self.persist_state_locked() {
6268 *self.managed_worktrees.lock()
6269 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6270 if let (Some(key), Some(previous_replay)) =
6271 (durable_v2_key, previous_replay)
6272 {
6273 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6274 }
6275 return Err(persistence_failure(error));
6276 }
6277 lease
6278 };
6279 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6280 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6281 self.managed_worktrees.lock()
6282 .unwrap_or_else(|poisoned| poisoned.into_inner())
6283 .remove_unmutated(&lease.lease_id);
6284 if self.persist_state().is_err() {
6285 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6286 }
6287 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6288 self.remember_managed_spawn_attempt(
6289 durable_v2_key,
6290 request,
6291 result.clone(),
6292 accepted_at,
6293 );
6294 return result;
6295 }
6296 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6297 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6298 Err(error) => {
6299 self.reconcile_failed_allocation(&lease, &source_root).await;
6300 let result = Err(error);
6301 self.remember_managed_spawn_attempt(
6302 durable_v2_key,
6303 request,
6304 result.clone(),
6305 accepted_at,
6306 );
6307 return result;
6308 }
6309 };
6310 let created = match create_git_worktree_with_timeout(
6311 &source_root,
6312 &lease.target_root,
6313 &lease.branch,
6314 Some(&base_commit),
6315 mutation_timeout_ms,
6316 ).await {
6317 Ok(created) => created,
6318 Err(error) => {
6319 self.reconcile_failed_allocation(&lease, &source_root).await;
6320 let result = Err(if Instant::now() >= deadline
6321 || error.message.contains("timed out")
6322 || error.message.contains("deadline")
6323 {
6324 failure(
6325 NodeFailureCode::SpawnDeadlineExceeded,
6326 "managed worktree creation exceeded the spawn deadline",
6327 )
6328 } else {
6329 managed_git_worktree_failure(error)
6330 });
6331 self.remember_managed_spawn_attempt(
6332 durable_v2_key,
6333 request,
6334 result.clone(),
6335 accepted_at,
6336 );
6337 return result;
6338 }
6339 };
6340 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6341 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6342 let result = Err(failure(
6343 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6344 "created worktree identity did not match its durable lease",
6345 ));
6346 self.remember_managed_spawn_attempt(
6347 durable_v2_key,
6348 request,
6349 result.clone(),
6350 accepted_at,
6351 );
6352 return result;
6353 }
6354 {
6355 let mut registry = self.managed_worktrees.lock()
6356 .unwrap_or_else(|poisoned| poisoned.into_inner());
6357 let current = registry.get_mut(&lease.lease_id)
6358 .expect("allocated managed worktree lease remains present");
6359 current.expected_head = Some(created.head.clone());
6360 current.state = ManagedWorktreeLeaseState::Ready;
6361 current.updated_at_unix_ms = unix_time_ms();
6362 }
6363 if let Err(error) = self.persist_state() {
6364 self.mark_managed_recovery_required(
6365 &lease.lease_id,
6366 ManagedWorktreeCleanupFailure::Backend,
6367 );
6368 let result = Err(persistence_failure(error));
6369 self.remember_managed_spawn_attempt(
6370 durable_v2_key,
6371 request,
6372 result.clone(),
6373 accepted_at,
6374 );
6375 return result;
6376 }
6377 let ready = self.managed_worktrees.lock()
6378 .unwrap_or_else(|poisoned| poisoned.into_inner())
6379 .get(&lease.lease_id)
6380 .expect("ready managed worktree lease remains present")
6381 .snapshot();
6382 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6383 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6384 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6385 let result = Err(error);
6386 self.remember_managed_spawn_attempt(
6387 durable_v2_key,
6388 request,
6389 result.clone(),
6390 accepted_at,
6391 );
6392 return result;
6393 }
6394 let spawn = self.spawn_session_with_deadline(
6395 lease.workspace_id.clone(),
6396 resolved.provider.clone(),
6397 resolved.mode,
6398 resolved.terminal_size,
6399 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6400 environment_profile.clone(),
6401 bundle.clone(),
6402 context.clone(),
6403 Some(lease.lease_id.clone()),
6404 Some(runtime_policy),
6405 SpawnRecordPolicy::Always,
6406 Some(deadline),
6407 &required_capabilities,
6408 None,
6409 resolved.approval_level,
6410 resolved.network_allowlist.clone(),
6411 resolved.browser_profile_id.clone(),
6412 ).await;
6413 let (session, effective_runtime_policy) = match spawn {
6414 Ok(spawned) => spawned,
6415 Err(error) => {
6416 if self
6417 .cleanup_managed_worktree(&lease.lease_id, true)
6418 .await
6419 .is_err()
6420 {
6421 self.mark_managed_recovery_required(
6422 &lease.lease_id,
6423 ManagedWorktreeCleanupFailure::Backend,
6424 );
6425 }
6426 let result = Err(error);
6427 self.remember_managed_spawn_attempt(
6428 durable_v2_key,
6429 request,
6430 result.clone(),
6431 accepted_at,
6432 );
6433 return result;
6434 }
6435 };
6436 let (record_id, raw_inventory_record_id) = {
6437 let mut bindings = self.session_bindings.lock()
6438 .unwrap_or_else(|poisoned| poisoned.into_inner());
6439 let binding = bindings.get_mut(&session.session.instance_id)
6440 .expect("successful spawn retains its session binding");
6441 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6442 managed_spawn_record_ownership(binding, effective_runtime_policy)
6443 };
6444 let bound = self.managed_worktrees.lock()
6445 .unwrap_or_else(|poisoned| poisoned.into_inner())
6446 .bind_session(
6447 &lease.lease_id,
6448 ManagedWorktreeSessionHolder {
6449 incarnation_id: self.incarnation_id,
6450 instance_id: session.session.instance_id,
6451 generation: session.session.generation,
6452 },
6453 record_id,
6454 unix_time_ms(),
6455 )
6456 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6457 let snapshot = match bound {
6458 Ok(snapshot) => snapshot,
6459 Err(error) => {
6460 if raw_inventory_record_id
6461 .as_ref()
6462 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6463 {
6464 self.mark_managed_recovery_required(
6465 &lease.lease_id,
6466 ManagedWorktreeCleanupFailure::Backend,
6467 );
6468 }
6469 let _ = self.remove_session(&session).await;
6470 self.mark_managed_recovery_required(
6471 &lease.lease_id,
6472 ManagedWorktreeCleanupFailure::Backend,
6473 );
6474 let result = Err(error);
6475 self.remember_managed_spawn_attempt(
6476 durable_v2_key,
6477 request,
6478 result.clone(),
6479 accepted_at,
6480 );
6481 return result;
6482 }
6483 };
6484 if let Err(error) = self.persist_state() {
6485 if raw_inventory_record_id
6486 .as_ref()
6487 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6488 {
6489 self.mark_managed_recovery_required(
6490 &lease.lease_id,
6491 ManagedWorktreeCleanupFailure::Backend,
6492 );
6493 }
6494 let _ = self.remove_session(&session).await;
6495 if self
6496 .cleanup_managed_worktree(&lease.lease_id, true)
6497 .await
6498 .is_err()
6499 {
6500 self.mark_managed_recovery_required(
6501 &lease.lease_id,
6502 ManagedWorktreeCleanupFailure::Backend,
6503 );
6504 }
6505 let result = Err(persistence_failure(error));
6506 self.remember_managed_spawn_attempt(
6507 durable_v2_key,
6508 request,
6509 result.clone(),
6510 accepted_at,
6511 );
6512 return result;
6513 }
6514 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6515 let receipt = managed_spawn_receipt(
6516 &resolved,
6517 self.incarnation_id,
6518 session,
6519 snapshot,
6520 environment_profile,
6521 bundle,
6522 context,
6523 );
6524 let result = Ok(receipt);
6525 self.remember_managed_spawn_attempt(
6526 durable_v2_key,
6527 request,
6528 result.clone(),
6529 accepted_at,
6530 );
6531 result
6532 }
6533
6534 async fn spawn_managed_worktree_v2(
6535 &self,
6536 request: ManagedWorktreeSpawnRequestV2,
6537 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6538 if request.spawn_spec.target.node_id != self.node_id {
6539 return Err(failure(
6540 NodeFailureCode::SpawnTargetMismatch,
6541 "spawn target does not match this node",
6542 ));
6543 }
6544 if request.spawn_spec.target.worktree_id.is_some() {
6545 return Err(failure(
6546 NodeFailureCode::InvalidRequest,
6547 "managed worktree spawn must not select a caller-provided worktree",
6548 ));
6549 }
6550 let request_digest = managed_spawn_request_digest_v2(&request)?;
6551 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6552 match replay {
6553 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6554 DurableManagedSpawnReplayDecision::PendingLinked => {
6555 return Err(failure(
6556 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6557 "managed worktree V2 reservation has an incomplete durable allocation",
6558 ));
6559 }
6560 DurableManagedSpawnReplayDecision::Reserved => {}
6561 }
6562 let validated = (|| {
6563 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6564 let profile = self.managed_profile(
6565 &resolved.target.workspace_id,
6566 &request.worktree_profile_id,
6567 )?;
6568 if profile.revision() != &request.expected_profile_revision {
6569 return Err(failure(
6570 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6571 "managed worktree profile revision does not match the loaded profile",
6572 ));
6573 }
6574 Ok(())
6575 })();
6576 match validated {
6577 Ok(()) => {}
6578 Err(error) => {
6579 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6580 return Err(error);
6581 }
6582 }
6583 let legacy = ManagedWorktreeSpawnRequest {
6584 spawn_spec: request.spawn_spec.clone(),
6585 worktree_profile_id: request.worktree_profile_id.clone(),
6586 };
6587 match self
6588 .spawn_managed_worktree_inner(
6589 legacy,
6590 Some(&request.spawn_spec.idempotency_key),
6591 )
6592 .await
6593 {
6594 Ok(receipt) => {
6595 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6596 Ok(receipt)
6597 }
6598 Err(error) => {
6599 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6600 Err(error)
6601 }
6602 }
6603 }
6604
6605 fn managed_profile(
6606 &self,
6607 workspace_id: &WorkspaceId,
6608 profile_id: &WorktreeProfileId,
6609 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6610 if self.worktree_service_modes.get(workspace_id).copied()
6611 != Some(WorktreeServiceMode::Managed)
6612 {
6613 return Err(failure(
6614 NodeFailureCode::UnsupportedSpawnCapability,
6615 "workspace does not enable managed worktrees",
6616 ));
6617 }
6618 self.managed_worktree_profiles.get(workspace_id)
6619 .and_then(|profiles| profiles.get(profile_id))
6620 .cloned()
6621 .ok_or_else(|| failure(
6622 NodeFailureCode::InvalidRequest,
6623 "managed worktree profile is unavailable for this workspace",
6624 ))
6625 }
6626
6627 async fn reconcile_failed_allocation(
6628 &self,
6629 lease: &ManagedWorktreeLeaseRecord,
6630 source_root: &str,
6631 ) {
6632 match list_git_worktrees(source_root).await {
6633 Ok(worktrees) => {
6634 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6635 None => {
6636 match std::fs::symlink_metadata(&lease.target_root) {
6637 Err(error) if error.kind() == io::ErrorKind::NotFound => {
6638 self.managed_worktrees.lock()
6639 .unwrap_or_else(|poisoned| poisoned.into_inner())
6640 .remove_unmutated(&lease.lease_id);
6641 let _ = self.persist_state();
6642 }
6643 _ => self.mark_managed_recovery_required(
6644 &lease.lease_id,
6645 ManagedWorktreeCleanupFailure::OwnershipConflict,
6646 ),
6647 }
6648 }
6649 Some(item) if exact_owned_worktree(
6650 lease,
6651 &item.path,
6652 item.branch.as_deref(),
6653 &item.head,
6654 ) => {
6655 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6656 }
6657 Some(_) => self.mark_managed_recovery_required(
6658 &lease.lease_id,
6659 ManagedWorktreeCleanupFailure::OwnershipConflict,
6660 ),
6661 }
6662 }
6663 Err(_) => self.mark_managed_recovery_required(
6664 &lease.lease_id,
6665 ManagedWorktreeCleanupFailure::Backend,
6666 ),
6667 }
6668 }
6669
6670 async fn reconcile_managed_worktrees(&self) {
6671 let leases = self.managed_worktrees.lock()
6672 .unwrap_or_else(|poisoned| poisoned.into_inner())
6673 .active_records()
6674 .cloned()
6675 .collect::<Vec<_>>();
6676 for lease in leases {
6677 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6678 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6679 _ => {
6680 self.mark_managed_recovery_required(
6681 &lease.lease_id,
6682 ManagedWorktreeCleanupFailure::OwnershipConflict,
6683 );
6684 continue;
6685 }
6686 };
6687 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6688 self.mark_managed_recovery_required(
6689 &lease.lease_id,
6690 ManagedWorktreeCleanupFailure::OwnershipConflict,
6691 );
6692 continue;
6693 }
6694 let source_root = match self.workspace_root(&lease.source_workspace_id) {
6695 Ok(root) => root,
6696 Err(_) => {
6697 self.mark_managed_recovery_required(
6698 &lease.lease_id,
6699 ManagedWorktreeCleanupFailure::OwnershipConflict,
6700 );
6701 continue;
6702 }
6703 };
6704 let worktrees = match list_git_worktrees(&source_root).await {
6705 Ok(worktrees) => worktrees,
6706 Err(_) => {
6707 self.mark_managed_recovery_required(
6708 &lease.lease_id,
6709 ManagedWorktreeCleanupFailure::Backend,
6710 );
6711 continue;
6712 }
6713 };
6714 let listed = worktrees.iter()
6715 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6716 let Some(listed) = listed else {
6717 match std::fs::symlink_metadata(&lease.target_root) {
6718 Err(error) if error.kind() == io::ErrorKind::NotFound
6719 && lease.state == ManagedWorktreeLeaseState::Allocating
6720 && !lease.has_holders() => {
6721 let removed = self.managed_worktrees.lock()
6722 .unwrap_or_else(|poisoned| poisoned.into_inner())
6723 .tombstone(&lease.lease_id, unix_time_ms());
6724 if self.persist_state().is_ok() && removed.is_some() {
6725 self.publish(NodeEvent::ManagedWorktreeRemoved {
6726 lease_id: lease.lease_id.clone(),
6727 });
6728 }
6729 }
6730 _ => self.mark_managed_recovery_required(
6731 &lease.lease_id,
6732 ManagedWorktreeCleanupFailure::OwnershipConflict,
6733 ),
6734 }
6735 continue;
6736 };
6737 if listed.is_main || listed.is_bare || !exact_owned_worktree(
6738 &lease,
6739 &listed.path,
6740 listed.branch.as_deref(),
6741 &listed.head,
6742 ) {
6743 self.mark_managed_recovery_required(
6744 &lease.lease_id,
6745 ManagedWorktreeCleanupFailure::OwnershipConflict,
6746 );
6747 continue;
6748 }
6749 if !self.workspaces.read()
6750 .unwrap_or_else(|poisoned| poisoned.into_inner())
6751 .contains_key(&lease.workspace_id)
6752 {
6753 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
6754 self.mark_managed_recovery_required(
6755 &lease.lease_id,
6756 ManagedWorktreeCleanupFailure::Backend,
6757 );
6758 continue;
6759 }
6760 }
6761 let snapshot = {
6762 let mut registry = self.managed_worktrees.lock()
6763 .unwrap_or_else(|poisoned| poisoned.into_inner());
6764 let current = registry.get_mut(&lease.lease_id)
6765 .expect("reconciled managed lease remains present");
6766 current.expected_head = Some(listed.head.clone());
6767 current.cleanup_failure = None;
6768 current.state = if current.has_holders() {
6769 ManagedWorktreeLeaseState::InUse
6770 } else if current.retention == ManagedWorktreeRetention::Retain {
6771 ManagedWorktreeLeaseState::Retained
6772 } else {
6773 ManagedWorktreeLeaseState::Ready
6774 };
6775 current.updated_at_unix_ms = unix_time_ms();
6776 current.snapshot()
6777 };
6778 if self.persist_state().is_err() {
6779 self.mark_managed_recovery_required(
6780 &lease.lease_id,
6781 ManagedWorktreeCleanupFailure::Backend,
6782 );
6783 continue;
6784 }
6785 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6786 if snapshot.state == ManagedWorktreeLeaseState::Ready {
6787 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
6788 }
6789 }
6790 }
6791
6792 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
6812 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
6813 .session_records
6814 .lock()
6815 .unwrap_or_else(|poisoned| poisoned.into_inner())
6816 .records
6817 .values()
6818 .filter(|record| {
6819 record.state == ManagedSessionState::Live
6820 && record.exported_context.is_none()
6821 && record.provider_session.is_some()
6822 })
6823 .filter_map(|record| {
6824 record
6825 .active_session
6826 .clone()
6827 .map(|session| (record.record_id.clone(), session))
6828 })
6829 .collect();
6830 for (record_id, session) in candidates {
6831 let shared = Arc::clone(self);
6832 tokio::spawn(async move {
6833 shared
6834 .reconcile_context_pack_export_for_record(&record_id, session)
6835 .await;
6836 });
6837 }
6838 }
6839
6840 async fn reconcile_context_pack_export_for_record(
6859 &self,
6860 record_id: &SessionRecordId,
6861 session: SessionAddress,
6862 ) {
6863 let Ok(record) = self.record(record_id) else {
6864 return;
6865 };
6866 if !matches!(
6867 record.state,
6868 ManagedSessionState::Live | ManagedSessionState::Dormant
6869 ) || record.exported_context.is_some()
6870 || record.provider_session.is_none()
6871 {
6872 return;
6873 }
6874 let Ok(receipt) = self
6881 .export_context_pack_for_session_record(&record.record_id, &session, true)
6882 .await
6883 else {
6884 return;
6885 };
6886 self.set_exported_context(&record.record_id, receipt);
6887 }
6888
6889 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
6894 let transaction = self
6895 .state_transaction
6896 .lock()
6897 .unwrap_or_else(|poisoned| poisoned.into_inner());
6898 let (previous, updated) = {
6899 let mut records = self
6900 .session_records
6901 .lock()
6902 .unwrap_or_else(|poisoned| poisoned.into_inner());
6903 let Some(record) = records.records.get_mut(record_id) else {
6904 return;
6905 };
6906 if record.exported_context.is_some() {
6907 return;
6908 }
6909 let previous = record.clone();
6910 record.exported_context = Some(receipt);
6911 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
6912 (previous, record.clone())
6913 };
6914 if self.persist_state_locked().is_err() {
6915 self.session_records
6916 .lock()
6917 .unwrap_or_else(|poisoned| poisoned.into_inner())
6918 .records
6919 .insert(record_id.clone(), previous);
6920 return;
6921 }
6922 drop(transaction);
6923 self.publish_record(updated);
6924 }
6925
6926 fn managed_record_export_target_for_instance(
6936 &self,
6937 instance_id: AgentInstanceId,
6938 generation: SessionGeneration,
6939 ) -> Option<(SessionRecordId, SessionAddress)> {
6940 let bindings = self
6941 .session_bindings
6942 .lock()
6943 .unwrap_or_else(|poisoned| poisoned.into_inner());
6944 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
6945 let record_id = binding.record_id.clone()?;
6946 Some((
6947 record_id,
6948 SessionAddress {
6949 workspace_id: binding.workspace_id.clone(),
6950 session: SessionKey { instance_id, generation },
6951 },
6952 ))
6953 }
6954
6955 fn mark_managed_recovery_required(
6956 &self,
6957 lease_id: &ManagedWorktreeLeaseId,
6958 failure_kind: ManagedWorktreeCleanupFailure,
6959 ) {
6960 let snapshot = {
6961 let mut registry = self.managed_worktrees.lock()
6962 .unwrap_or_else(|poisoned| poisoned.into_inner());
6963 let Some(lease) = registry.get_mut(lease_id) else { return };
6964 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
6965 lease.cleanup_failure = Some(failure_kind);
6966 lease.updated_at_unix_ms = unix_time_ms();
6967 lease.snapshot()
6968 };
6969 let _ = self.persist_state();
6970 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
6971 }
6972
6973 fn set_persistence_error(&self, error: Option<String>) {
6974 *self
6975 .persistence_error
6976 .write()
6977 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
6978 .map(|message| session_registry::sanitized_persistence_summary(&message));
6979 }
6980
6981 fn persist_state(&self) -> io::Result<()> {
6982 let _transaction = self
6983 .state_transaction
6984 .lock()
6985 .unwrap_or_else(|poisoned| poisoned.into_inner());
6986 self.persist_state_locked()
6987 }
6988
6989 fn persist_state_locked(&self) -> io::Result<()> {
6990 let workspaces = self
6991 .workspaces
6992 .read()
6993 .unwrap_or_else(|poisoned| poisoned.into_inner())
6994 .clone();
6995 let records = self
6996 .session_records
6997 .lock()
6998 .unwrap_or_else(|poisoned| poisoned.into_inner())
6999 .records
7000 .values()
7001 .cloned()
7002 .collect::<Vec<_>>();
7003 let mut managed = self.managed_worktrees
7004 .lock()
7005 .unwrap_or_else(|poisoned| poisoned.into_inner());
7006 managed.reattach_record_holders(
7007 &managed_worktree_record_holders(&records),
7008 unix_time_ms(),
7009 );
7010 let materializations = self
7011 .materializations
7012 .lock()
7013 .unwrap_or_else(|poisoned| poisoned.into_inner())
7014 .values()
7015 .cloned()
7016 .collect::<Vec<_>>();
7017 let managed_spawn_replays = self.managed_spawn_replays
7018 .lock()
7019 .unwrap_or_else(|poisoned| poisoned.into_inner())
7020 .values()
7021 .cloned()
7022 .collect::<Vec<_>>();
7023 let result = session_registry::save_v10(
7024 self.state_path.as_deref(),
7025 &self.node_id,
7026 &workspaces,
7027 &records,
7028 &managed.records(),
7029 &managed.tombstones(),
7030 &materializations,
7031 &managed_spawn_replays,
7032 );
7033 match &result {
7034 Ok(warning) => self.set_persistence_error(warning.clone()),
7035 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7036 }
7037 result.map(|_| ())
7038 }
7039
7040 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7041 let _mutation_guard = self.mutation_gate.lock().await;
7042 self.begin_shutdown_locked().await
7043 }
7044
7045 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7046 if self.shutdown.swap(true, Ordering::AcqRel) {
7047 return Ok(());
7048 }
7049 SessionLifecycleHook::on_shutdown(self);
7050 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7051 let mut first_error = None;
7052 for session in &self.handle.snapshot().sessions {
7053 let remaining = deadline.saturating_duration_since(Instant::now());
7054 if remaining.is_zero() {
7055 first_error.get_or_insert_with(|| {
7056 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7057 });
7058 break;
7059 }
7060 if let Err(error) = self
7061 .dispatch_bounded(
7062 ControlCommand::Stop {
7063 instance_id: session.instance_id,
7064 force: true,
7065 },
7066 remaining,
7067 )
7068 .await
7069 {
7070 first_error.get_or_insert(error);
7071 }
7072 }
7073 self.shutdown_notify.notify_waiters();
7074 match first_error {
7075 Some(error) => Err(error),
7076 None => Ok(()),
7077 }
7078 }
7079
7080 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7081 let bindings = self
7082 .session_bindings
7083 .lock()
7084 .unwrap_or_else(|poisoned| poisoned.into_inner());
7085 if bindings.len() >= CONTROL_SESSIONS_MAX {
7086 Err(failure(
7087 NodeFailureCode::BackendBusy,
7088 "node session-binding capacity is full; remove an existing session first",
7089 ))
7090 } else {
7091 Ok(())
7092 }
7093 }
7094
7095 fn ensure_workspace_environment_profile_compatible(
7102 &self,
7103 workspace_id: &WorkspaceId,
7104 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7105 ) -> Result<(), NodeFailure> {
7106 let bindings = self
7107 .session_bindings
7108 .lock()
7109 .unwrap_or_else(|poisoned| poisoned.into_inner());
7110 for binding in bindings.values() {
7111 if &binding.workspace_id != workspace_id {
7112 continue;
7113 }
7114 if binding.environment_profile.as_ref() != environment_profile {
7115 return Err(failure(
7116 NodeFailureCode::WorkspaceBusy,
7117 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7118 ));
7119 }
7120 }
7121 Ok(())
7122 }
7123
7124 #[cfg(test)]
7125 fn bind_session(
7126 &self,
7127 instance_id: AgentInstanceId,
7128 workspace_id: WorkspaceId,
7129 generation: SessionGeneration,
7130 ) {
7131 self.bind_session_with_policy(
7132 &SessionAddress {
7133 workspace_id,
7134 session: SessionKey { instance_id, generation },
7135 },
7136 ProviderRuntimePolicy::raw_pty(),
7137 None,
7138 );
7139 }
7140
7141 #[cfg(test)]
7142 fn bind_session_with_policy(
7143 &self,
7144 address: &SessionAddress,
7145 runtime_policy: ProviderRuntimePolicy,
7146 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7147 ) {
7148 self.bind_session_with_materialization(
7149 address,
7150 runtime_policy,
7151 environment_profile,
7152 None,
7153 None,
7154 None,
7155 );
7156 }
7157
7158 fn bind_session_with_materialization(
7159 &self,
7160 address: &SessionAddress,
7161 runtime_policy: ProviderRuntimePolicy,
7162 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7163 bundle: Option<ResolvedBundleReceipt>,
7164 context: Option<ResolvedContextPackReceipt>,
7165 materialization_id: Option<MaterializationId>,
7166 ) {
7167 let mut bindings = self
7168 .session_bindings
7169 .lock()
7170 .unwrap_or_else(|poisoned| poisoned.into_inner());
7171 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7172 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7173 bindings.insert(address.session.instance_id, SessionBinding {
7174 workspace_id: address.workspace_id.clone(),
7175 generation: address.session.generation,
7176 runtime_policy,
7177 pending_resume: None,
7178 record_id: None,
7179 managed_worktree_lease_id: None,
7180 environment_profile,
7181 bundle,
7182 context,
7183 materialization_id,
7184 });
7185 }
7186
7187 #[cfg(test)]
7188 fn bind_managed_session(
7189 &self,
7190 address: &SessionAddress,
7191 record_id: SessionRecordId,
7192 runtime_policy: ProviderRuntimePolicy,
7193 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7194 ) {
7195 self.bind_managed_session_with_materialization(
7196 address,
7197 record_id,
7198 runtime_policy,
7199 environment_profile,
7200 None,
7201 None,
7202 None,
7203 );
7204 }
7205
7206 fn bind_managed_session_with_materialization(
7207 &self,
7208 address: &SessionAddress,
7209 record_id: SessionRecordId,
7210 runtime_policy: ProviderRuntimePolicy,
7211 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7212 bundle: Option<ResolvedBundleReceipt>,
7213 context: Option<ResolvedContextPackReceipt>,
7214 materialization_id: Option<MaterializationId>,
7215 ) {
7216 let mut bindings = self
7217 .session_bindings
7218 .lock()
7219 .unwrap_or_else(|poisoned| poisoned.into_inner());
7220 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7221 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7222 bindings.insert(
7223 address.session.instance_id,
7224 SessionBinding {
7225 workspace_id: address.workspace_id.clone(),
7226 generation: address.session.generation,
7227 runtime_policy,
7228 pending_resume: None,
7229 record_id: Some(record_id),
7230 managed_worktree_lease_id: None,
7231 environment_profile,
7232 bundle,
7233 context,
7234 materialization_id,
7235 },
7236 );
7237 }
7238
7239 #[cfg(test)]
7240 fn bind_spawn_session(
7241 &self,
7242 address: &SessionAddress,
7243 provider: AgentId,
7244 mode: SessionMode,
7245 runtime_policy: ProviderRuntimePolicy,
7246 record_policy: SpawnRecordPolicy,
7247 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7248 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7249 self.bind_spawn_session_with_materialization(
7250 address,
7251 provider,
7252 mode,
7253 runtime_policy,
7254 record_policy,
7255 environment_profile,
7256 None,
7257 None,
7258 None,
7259 )
7260 }
7261
7262 fn bind_spawn_session_with_materialization(
7263 &self,
7264 address: &SessionAddress,
7265 provider: AgentId,
7266 mode: SessionMode,
7267 runtime_policy: ProviderRuntimePolicy,
7268 record_policy: SpawnRecordPolicy,
7269 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7270 bundle: Option<ResolvedBundleReceipt>,
7271 context: Option<ResolvedContextPackReceipt>,
7272 materialization_id: Option<MaterializationId>,
7273 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7274 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7275 && !runtime_policy.provider_session_identity
7276 {
7277 self.bind_session_with_materialization(
7278 address,
7279 runtime_policy,
7280 environment_profile,
7281 bundle,
7282 context,
7283 materialization_id,
7284 );
7285 return Ok(None);
7286 }
7287 let record_owns_lifecycle = runtime_policy.provider_session_identity;
7288 let record = self.new_record(
7289 address,
7290 provider,
7291 mode,
7292 active_record_state(runtime_policy.provider_session_identity, false),
7293 None,
7294 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7295 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7296 record_owns_lifecycle.then(|| context.clone()).flatten(),
7297 )?;
7298 let record_id = record.record_id.clone();
7299 let transaction = self
7300 .state_transaction
7301 .lock()
7302 .unwrap_or_else(|poisoned| poisoned.into_inner());
7303 self.insert_record(record.clone())?;
7304 let previous_materialization = if record_owns_lifecycle {
7305 if let Some(materialization_id) = materialization_id.as_ref() {
7306 let mut materializations = self
7307 .materializations
7308 .lock()
7309 .unwrap_or_else(|poisoned| poisoned.into_inner());
7310 let Some(ownership) = materializations.get_mut(materialization_id) else {
7311 drop(materializations);
7312 self.remove_record_memory(&record_id);
7313 return Err(failure(
7314 NodeFailureCode::BackendOperationFailed,
7315 "session environment ownership disappeared before binding",
7316 ));
7317 };
7318 let previous = ownership.clone();
7319 if ownership
7320 .transfer_to_record(record_id.clone(), unix_time_ms())
7321 .is_err()
7322 {
7323 drop(materializations);
7324 self.remove_record_memory(&record_id);
7325 return Err(failure(
7326 NodeFailureCode::BackendOperationFailed,
7327 "session environment ownership transfer failed",
7328 ));
7329 }
7330 Some(previous)
7331 } else {
7332 None
7333 }
7334 } else {
7335 None
7336 };
7337 self.bind_managed_session_with_materialization(
7338 address,
7339 record_id.clone(),
7340 runtime_policy,
7341 environment_profile,
7342 bundle,
7343 context,
7344 materialization_id.clone(),
7345 );
7346 if let Err(error) = self.persist_state_locked() {
7347 self.remove_binding(address);
7348 self.remove_record_memory(&record_id);
7349 if let (Some(materialization_id), Some(previous)) =
7350 (materialization_id.as_ref(), previous_materialization)
7351 {
7352 self.materializations
7353 .lock()
7354 .unwrap_or_else(|poisoned| poisoned.into_inner())
7355 .insert(materialization_id.clone(), previous);
7356 }
7357 return Err(persistence_failure(error));
7358 }
7359 drop(transaction);
7360 self.publish_record(record);
7361 Ok(Some(record_id))
7362 }
7363
7364 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7365 for _ in 0..8 {
7366 let bytes = random_nonce().map_err(|error| {
7367 failure(
7368 NodeFailureCode::BackendOperationFailed,
7369 &format!("session record identity generation failed: {error}"),
7370 )
7371 })?;
7372 let mut value = String::with_capacity(27);
7373 value.push_str("sr-");
7374 for byte in &bytes[..12] {
7375 use std::fmt::Write as _;
7376 let _ = write!(&mut value, "{byte:02x}");
7377 }
7378 let record_id = SessionRecordId::new(value).map_err(|error| {
7379 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7380 })?;
7381 let records = self
7382 .session_records
7383 .lock()
7384 .unwrap_or_else(|poisoned| poisoned.into_inner());
7385 if !records.records.contains_key(&record_id) {
7386 return Ok(record_id);
7387 }
7388 }
7389 Err(failure(
7390 NodeFailureCode::BackendBusy,
7391 "could not allocate a unique session record ID",
7392 ))
7393 }
7394
7395 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7396 for _ in 0..8 {
7397 let bytes = random_nonce().map_err(|error| {
7398 failure(
7399 NodeFailureCode::BackendOperationFailed,
7400 &format!("task identity generation failed: {error}"),
7401 )
7402 })?;
7403 let mut nonce = [0_u8; 12];
7404 nonce.copy_from_slice(&bytes[..12]);
7405 let task_id = TaskId::from_nonce(nonce);
7406 let records = self
7407 .session_records
7408 .lock()
7409 .unwrap_or_else(|poisoned| poisoned.into_inner());
7410 if !records.records.values().any(|record| {
7411 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7412 == Some(&task_id)
7413 }) {
7414 return Ok(task_id);
7415 }
7416 }
7417 Err(failure(
7418 NodeFailureCode::BackendBusy,
7419 "could not allocate a unique task ID",
7420 ))
7421 }
7422
7423 fn default_record_name(&self, provider: &AgentId) -> String {
7424 let records = self
7425 .session_records
7426 .lock()
7427 .unwrap_or_else(|poisoned| poisoned.into_inner());
7428 let ordinal = records
7429 .records
7430 .values()
7431 .filter(|record| &record.provider == provider)
7432 .count()
7433 .saturating_add(1);
7434 format!("{} #{ordinal}", provider.as_str())
7435 }
7436
7437 fn new_record(
7438 &self,
7439 address: &SessionAddress,
7440 provider: AgentId,
7441 mode: SessionMode,
7442 state: ManagedSessionState,
7443 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7444 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7445 bundle: Option<ResolvedBundleReceipt>,
7446 context: Option<ResolvedContextPackReceipt>,
7447 ) -> Result<ManagedSessionRecord, NodeFailure> {
7448 let canonical_root = self.workspace_root(&address.workspace_id)?;
7449 let now = unix_time_ms();
7450 Ok(ManagedSessionRecord {
7451 record_id: self.allocate_record_id()?,
7452 display_name: self.default_record_name(&provider),
7453 provider,
7454 mode,
7455 state,
7456 workspace_id: address.workspace_id.clone(),
7457 canonical_root: opaque_windows_path(canonical_root),
7458 provider_session,
7459 active_session: Some(address.clone()),
7460 environment_profile,
7461 bundle,
7462 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7463 context,
7464 exported_context: None,
7465 task_binding: None,
7466 created_at_unix_ms: now,
7467 updated_at_unix_ms: now,
7468 last_error: None,
7469 })
7470 }
7471
7472 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7473 let mut records = self
7474 .session_records
7475 .lock()
7476 .unwrap_or_else(|poisoned| poisoned.into_inner());
7477 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7478 return Err(failure(
7479 NodeFailureCode::BackendBusy,
7480 "managed session record capacity is full; forget an inactive record first",
7481 ));
7482 }
7483 if let Some(identity) = record.provider_session.as_ref() {
7484 if records.records.values().any(|candidate| {
7485 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7486 session_registry::same_provider_session(
7487 &candidate.provider,
7488 candidate_identity,
7489 &record.provider,
7490 identity,
7491 )
7492 })
7493 }) {
7494 return Err(failure(
7495 NodeFailureCode::SessionRecordConflict,
7496 "provider session is already represented by another managed record",
7497 ));
7498 }
7499 }
7500 match records.records.entry(record.record_id.clone()) {
7501 std::collections::btree_map::Entry::Vacant(entry) => {
7502 entry.insert(record);
7503 }
7504 std::collections::btree_map::Entry::Occupied(_) => {
7505 return Err(failure(
7506 NodeFailureCode::SessionRecordConflict,
7507 "session record ID collision",
7508 ));
7509 }
7510 }
7511 Ok(())
7512 }
7513
7514 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7515 self.session_records
7516 .lock()
7517 .unwrap_or_else(|poisoned| poisoned.into_inner())
7518 .records
7519 .get(record_id)
7520 .cloned()
7521 .ok_or_else(|| {
7522 failure(
7523 NodeFailureCode::UnknownSessionRecord,
7524 "managed session record does not exist",
7525 )
7526 })
7527 }
7528
7529 fn revalidate_session_record_preview(
7530 &self,
7531 expected: &ManagedSessionRecord,
7532 identity: &ProviderSessionIdentity,
7533 ) -> Result<(), NodeFailure> {
7534 let current = self.record(&expected.record_id)?;
7535 let current_root = self.workspace_root(¤t.workspace_id)?;
7536 if current.provider != expected.provider
7537 || current.workspace_id != expected.workspace_id
7538 || current.canonical_root != expected.canonical_root
7539 || current.provider_session.as_ref() != Some(identity)
7540 || !platform::roots_equal(
7541 ¤t_root,
7542 windows_path_text(¤t.canonical_root),
7543 )
7544 {
7545 return Err(failure(
7546 NodeFailureCode::SessionRecordConflict,
7547 "managed session changed while its preview was loading",
7548 ));
7549 }
7550 Ok(())
7551 }
7552
7553 fn revalidate_session_record_context_export(
7554 &self,
7555 expected: &ManagedSessionRecord,
7556 identity: Option<&ProviderSessionIdentity>,
7557 session: &SessionAddress,
7558 allow_clean_detachment: bool,
7559 ) -> Result<(), NodeFailure> {
7560 let current = self.record(&expected.record_id)?;
7561 let current_root = self.workspace_root(¤t.workspace_id)?;
7562 let snapshot = self.internal_session_snapshot(session)?;
7563 let binding = self
7564 .session_bindings
7565 .lock()
7566 .unwrap_or_else(|poisoned| poisoned.into_inner())
7567 .get(&session.session.instance_id)
7568 .cloned()
7569 .ok_or_else(|| {
7570 failure(
7571 NodeFailureCode::SessionRecordConflict,
7572 "managed session runtime binding disappeared during context export",
7573 )
7574 })?;
7575 if !session_record_context_export_binding_is_exact(
7576 expected,
7577 ¤t,
7578 identity,
7579 session,
7580 &binding,
7581 &snapshot.agent_id,
7582 ¤t_root,
7583 allow_clean_detachment,
7584 ) || !session_record_context_export_source_is_usable(
7585 ¤t,
7586 &snapshot.status,
7587 allow_clean_detachment,
7588 ) {
7589 return Err(failure(
7590 NodeFailureCode::SessionRecordConflict,
7591 "managed session binding changed during context export",
7592 ));
7593 }
7594 Ok(())
7595 }
7596
7597 fn revalidate_loaded_history(
7598 &self,
7599 session: &SessionAddress,
7600 candidate_id: &str,
7601 loaded: &HistorySessionRecord,
7602 ) -> Result<(), NodeFailure> {
7603 let snapshot = self.internal_session_snapshot(session)?;
7604 if snapshot.history.pending.is_some()
7605 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7606 || snapshot.history.loaded.as_ref() != Some(loaded)
7607 {
7608 return Err(failure(
7609 NodeFailureCode::SessionRecordConflict,
7610 "managed session history changed during context export",
7611 ));
7612 }
7613 Ok(())
7614 }
7615
7616 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7617 self.session_records
7618 .lock()
7619 .unwrap_or_else(|poisoned| poisoned.into_inner())
7620 .records
7621 .remove(record_id)
7622 }
7623
7624 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7625 if self.destroy_record_materialization(record_id)? {
7626 self.publish(NodeEvent::SessionRecordRemoved {
7627 record_id: record_id.clone(),
7628 });
7629 return Ok(());
7630 }
7631 let transaction = self
7632 .state_transaction
7633 .lock()
7634 .unwrap_or_else(|poisoned| poisoned.into_inner());
7635 let Some(removed) = self.remove_record_memory(record_id) else {
7636 return Ok(());
7637 };
7638 if let Err(error) = self.persist_state_locked() {
7639 let _ = self.insert_record(removed);
7640 return Err(persistence_failure(error));
7641 }
7642 drop(transaction);
7643 self.publish(NodeEvent::SessionRecordRemoved {
7644 record_id: record_id.clone(),
7645 });
7646 Ok(())
7647 }
7648
7649 fn publish_record(&self, record: ManagedSessionRecord) {
7650 self.publish(NodeEvent::SessionRecordUpserted { record });
7651 }
7652
7653 fn mark_record_error(
7654 &self,
7655 record_id: &SessionRecordId,
7656 message: &str,
7657 ) -> Result<(), NodeFailure> {
7658 let transaction = self
7659 .state_transaction
7660 .lock()
7661 .unwrap_or_else(|poisoned| poisoned.into_inner());
7662 let previous = {
7663 let mut records = self
7664 .session_records
7665 .lock()
7666 .unwrap_or_else(|poisoned| poisoned.into_inner());
7667 let Some(record) = records.records.get_mut(record_id) else {
7668 return Ok(());
7669 };
7670 let previous = record.clone();
7671 record.state = ManagedSessionState::Unavailable;
7672 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7673 record.updated_at_unix_ms = unix_time_ms();
7674 previous
7675 };
7676 if let Err(error) = self.persist_state_locked() {
7677 self.session_records
7678 .lock()
7679 .unwrap_or_else(|poisoned| poisoned.into_inner())
7680 .records
7681 .insert(record_id.clone(), previous);
7682 return Err(persistence_failure(error));
7683 }
7684 let record = self.record(record_id)?;
7685 drop(transaction);
7686 self.publish_record(record);
7687 Ok(())
7688 }
7689
7690 fn rename_session_record(
7691 &self,
7692 record_id: &SessionRecordId,
7693 display_name: String,
7694 ) -> Result<ManagedSessionRecord, NodeFailure> {
7695 validate_display_name(&display_name)
7696 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7697 let _transaction = self
7698 .state_transaction
7699 .lock()
7700 .unwrap_or_else(|poisoned| poisoned.into_inner());
7701 let previous = {
7702 let mut records = self
7703 .session_records
7704 .lock()
7705 .unwrap_or_else(|poisoned| poisoned.into_inner());
7706 let record = records.records.get_mut(record_id).ok_or_else(|| {
7707 failure(
7708 NodeFailureCode::UnknownSessionRecord,
7709 "managed session record does not exist",
7710 )
7711 })?;
7712 let previous = record.clone();
7713 record.display_name = display_name;
7714 record.updated_at_unix_ms = unix_time_ms();
7715 previous
7716 };
7717 if let Err(error) = self.persist_state_locked() {
7718 self.session_records
7719 .lock()
7720 .unwrap_or_else(|poisoned| poisoned.into_inner())
7721 .records
7722 .insert(record_id.clone(), previous);
7723 return Err(persistence_failure(error));
7724 }
7725 let updated = self.record(record_id)?;
7726 self.publish_record(updated.clone());
7727 Ok(updated)
7728 }
7729
7730 fn set_session_task(
7731 &self,
7732 record_id: &SessionRecordId,
7733 expected_revision: u64,
7734 target: SessionTaskTargetV1,
7735 ) -> Result<ManagedSessionRecord, NodeFailure> {
7736 let transaction = self
7737 .state_transaction
7738 .lock()
7739 .unwrap_or_else(|poisoned| poisoned.into_inner());
7740 let current = self.record(record_id)?;
7741 let current_task_id = current
7742 .task_binding
7743 .as_ref()
7744 .and_then(|binding| binding.task_id.as_ref());
7745 let current_revision = current
7746 .task_binding
7747 .as_ref()
7748 .map_or(0, |binding| binding.revision);
7749 if expected_revision != current_revision {
7750 return Err(failure(
7751 NodeFailureCode::SessionRecordConflict,
7752 "managed session task binding revision changed",
7753 ));
7754 }
7755 let exact = match &target {
7756 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
7757 SessionTaskTargetV1::Clear => current_task_id.is_none(),
7758 SessionTaskTargetV1::New => false,
7759 };
7760 if exact {
7761 return Ok(current);
7762 }
7763 let revision = current_revision.checked_add(1).ok_or_else(|| {
7764 failure(
7765 NodeFailureCode::SessionRecordConflict,
7766 "managed session task binding revision is exhausted",
7767 )
7768 })?;
7769 let task_id = match target {
7770 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
7771 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
7772 SessionTaskTargetV1::Clear => None,
7773 };
7774 let changed_at_unix_ms = unix_time_ms()
7775 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
7776 failure(
7777 NodeFailureCode::SessionRecordConflict,
7778 "managed session record timestamp is exhausted",
7779 )
7780 })?);
7781 let updated = {
7782 let mut records = self
7783 .session_records
7784 .lock()
7785 .unwrap_or_else(|poisoned| poisoned.into_inner());
7786 let record = records.records.get_mut(record_id).ok_or_else(|| {
7787 failure(
7788 NodeFailureCode::UnknownSessionRecord,
7789 "managed session record does not exist",
7790 )
7791 })?;
7792 record.task_binding = Some(SessionTaskBindingV1 {
7793 revision,
7794 task_id,
7795 changed_at_unix_ms,
7796 });
7797 record.updated_at_unix_ms = changed_at_unix_ms;
7798 record.clone()
7799 };
7800 if let Err(error) = self.persist_state_locked() {
7801 self.session_records
7802 .lock()
7803 .unwrap_or_else(|poisoned| poisoned.into_inner())
7804 .records
7805 .insert(record_id.clone(), current);
7806 return Err(persistence_failure(error));
7807 }
7808 drop(transaction);
7809 self.publish_record(updated.clone());
7810 Ok(updated)
7811 }
7812
7813 fn index_provider_session(
7814 &self,
7815 workspace_id: WorkspaceId,
7816 provider: AgentId,
7817 identity: ProviderSessionIdentity,
7818 display_name: String,
7819 ) -> Result<ManagedSessionRecord, NodeFailure> {
7820 self.index_provider_session_with_policy(
7821 workspace_id,
7822 provider,
7823 identity,
7824 display_name,
7825 false,
7826 )
7827 }
7828
7829 fn index_provider_session_with_policy(
7830 &self,
7831 workspace_id: WorkspaceId,
7832 provider: AgentId,
7833 identity: ProviderSessionIdentity,
7834 display_name: String,
7835 allow_validated_transcript_path: bool,
7836 ) -> Result<ManagedSessionRecord, NodeFailure> {
7837 validate_display_name(&display_name)
7838 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7839 identity
7840 .validate()
7841 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7842 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
7843 return Err(failure(
7844 NodeFailureCode::InvalidRequest,
7845 "provider session reference index v1 does not accept transcript paths",
7846 ));
7847 }
7848 let canonical_root = self.workspace_root(&workspace_id)?;
7849 if !self.enabled_providers.contains(&provider) {
7850 return Err(failure(
7851 NodeFailureCode::InvalidRequest,
7852 "provider is not enabled on this node",
7853 ));
7854 }
7855
7856 let transaction = self
7857 .state_transaction
7858 .lock()
7859 .unwrap_or_else(|poisoned| poisoned.into_inner());
7860 {
7861 let records = self
7862 .session_records
7863 .lock()
7864 .unwrap_or_else(|poisoned| poisoned.into_inner());
7865 if let Some(existing) = records.records.values().find(|candidate| {
7866 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7867 candidate_identity.key == identity.key && candidate_identity.id == identity.id
7868 })
7869 }) {
7870 if existing.provider == provider && existing.workspace_id == workspace_id {
7871 return Ok(existing.clone());
7872 }
7873 return Err(failure(
7874 NodeFailureCode::SessionRecordConflict,
7875 "provider session reference conflicts with another provider or workspace",
7876 ));
7877 }
7878 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7879 return Err(failure(
7880 NodeFailureCode::BackendBusy,
7881 "managed session record capacity is full; forget an inactive record first",
7882 ));
7883 }
7884 }
7885
7886 let now = unix_time_ms();
7887 let record = ManagedSessionRecord {
7888 record_id: self.allocate_record_id()?,
7889 display_name,
7890 provider,
7891 mode: SessionMode::Pty,
7892 state: ManagedSessionState::Dormant,
7893 workspace_id,
7894 canonical_root: opaque_windows_path(canonical_root),
7895 provider_session: Some(identity),
7896 active_session: None,
7897 environment_profile: None,
7898 bundle: None,
7899 context_id: None,
7900 context: None,
7901 exported_context: None,
7902 task_binding: None,
7903 created_at_unix_ms: now,
7904 updated_at_unix_ms: now,
7905 last_error: None,
7906 };
7907 self.insert_record(record.clone())?;
7908 if let Err(error) = self.persist_state_locked() {
7909 self.remove_record_memory(&record.record_id);
7910 return Err(persistence_failure(error));
7911 }
7912 drop(transaction);
7913 self.publish_record(record.clone());
7914 Ok(record)
7915 }
7916
7917 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
7918 self.session_bindings
7919 .lock()
7920 .unwrap_or_else(|poisoned| poisoned.into_inner())
7921 .iter()
7922 .find_map(|(instance_id, binding)| {
7923 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
7924 workspace_id: binding.workspace_id.clone(),
7925 session: SessionKey {
7926 instance_id: *instance_id,
7927 generation: binding.generation,
7928 },
7929 })
7930 })
7931 }
7932
7933 async fn forget_session_record(
7934 &self,
7935 record_id: &SessionRecordId,
7936 ) -> Result<(), NodeFailure> {
7937 let record = self.record(record_id)?;
7938 if record.active_session.is_some()
7939 || matches!(
7940 record.state,
7941 ManagedSessionState::Live | ManagedSessionState::IdentityPending
7942 )
7943 {
7944 return Err(failure(
7945 NodeFailureCode::SessionRecordBusy,
7946 "stop the managed session before forgetting it",
7947 ));
7948 }
7949 if let Some(address) = self.bound_address_for_record(record_id) {
7950 self.remove_session(&address).await?;
7951 }
7952 self.remove_managed_session_record(record_id, &record.workspace_id).await
7953 }
7954
7955 async fn remove_managed_session_record(
7970 &self,
7971 record_id: &SessionRecordId,
7972 workspace_id: &WorkspaceId,
7973 ) -> Result<(), NodeFailure> {
7974 let managed_lease_id = self.managed_worktrees.lock()
7975 .unwrap_or_else(|poisoned| poisoned.into_inner())
7976 .lease_for_workspace(workspace_id);
7977 if !self.destroy_record_materialization(record_id)? {
7978 let transaction = self
7979 .state_transaction
7980 .lock()
7981 .unwrap_or_else(|poisoned| poisoned.into_inner());
7982 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
7983 failure(
7984 NodeFailureCode::UnknownSessionRecord,
7985 "managed session record does not exist",
7986 )
7987 })?;
7988 if let Err(error) = self.persist_state_locked() {
7989 let _ = self.insert_record(removed);
7990 return Err(persistence_failure(error));
7991 }
7992 drop(transaction);
7993 }
7994 self.publish(NodeEvent::SessionRecordRemoved {
7995 record_id: record_id.clone(),
7996 });
7997 if let Some(lease_id) = managed_lease_id {
7998 if let Some(snapshot) = self.managed_worktrees.lock()
7999 .unwrap_or_else(|poisoned| poisoned.into_inner())
8000 .get(&lease_id)
8001 .map(ManagedWorktreeLeaseRecord::snapshot)
8002 {
8003 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8004 }
8005 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8006 }
8007 Ok(())
8008 }
8009
8010 async fn retire_unavailable_session_records(&self) {
8023 let policy = self.session_record_retention;
8024 let age_enabled = policy.age_ms > 0;
8025 let keep_enabled = policy.keep_per_workspace > 0;
8026 if !age_enabled && !keep_enabled {
8027 return;
8028 }
8029 let now = unix_time_ms();
8030 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8031 self.session_records
8032 .lock()
8033 .unwrap_or_else(|poisoned| poisoned.into_inner())
8034 .records
8035 .values()
8036 .filter(|record| record.state == ManagedSessionState::Unavailable)
8037 .map(|record| {
8038 (
8039 record.record_id.clone(),
8040 record.workspace_id.clone(),
8041 record.provider.clone(),
8042 record.updated_at_unix_ms,
8043 )
8044 })
8045 .collect()
8046 };
8047 if candidates.is_empty() {
8048 return;
8049 }
8050 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8051 BTreeMap::new();
8052 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8053 by_workspace
8054 .entry(workspace_id)
8055 .or_default()
8056 .push((record_id, provider, updated_at_unix_ms));
8057 }
8058 let reason = if age_enabled && keep_enabled {
8059 "age+keep-n"
8060 } else if age_enabled {
8061 "age"
8062 } else {
8063 "keep-n"
8064 };
8065 let mut retired: u64 = 0;
8066 for (workspace_id, mut records) in by_workspace {
8067 records.sort_by(|left, right| right.2.cmp(&left.2));
8070 for (index, (record_id, provider, updated_at_unix_ms)) in
8071 records.into_iter().enumerate()
8072 {
8073 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8074 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8075 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8076 let should_retire = if age_enabled && keep_enabled {
8077 age_hit && keep_hit
8078 } else {
8079 age_hit || keep_hit
8080 };
8081 if !should_retire {
8082 continue;
8083 }
8084 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8085 Ok(()) => {
8086 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8087 retired += 1;
8088 tracing::info!(
8089 node_id = %self.node_id,
8090 record_id = %record_id,
8091 workspace_id = %workspace_id,
8092 provider = %provider,
8093 age_ms = record_age_ms,
8094 reason,
8095 "managed session record retired"
8096 );
8097 }
8098 Err(error) => tracing::warn!(
8099 node_id = %self.node_id,
8100 record_id = %record_id,
8101 workspace_id = %workspace_id,
8102 code = ?error.code,
8103 cause = %error.message,
8104 "session record retention sweep failed to retire a record"
8105 ),
8106 }
8107 }
8108 }
8109 if retired > 0 {
8110 let remaining_unavailable = self
8111 .session_records
8112 .lock()
8113 .unwrap_or_else(|poisoned| poisoned.into_inner())
8114 .records
8115 .values()
8116 .filter(|record| record.state == ManagedSessionState::Unavailable)
8117 .count();
8118 tracing::info!(
8119 node_id = %self.node_id,
8120 retired,
8121 remaining_unavailable,
8122 "session record retention sweep completed"
8123 );
8124 }
8125 }
8126
8127 async fn resume_session_record(
8128 &self,
8129 record_id: &SessionRecordId,
8130 terminal_size: gate4agent_types::TerminalSize,
8131 initial_prompt: Option<String>,
8132 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8133 let record = self.record(record_id)?;
8134 if record.active_session.is_some()
8135 || matches!(
8136 record.state,
8137 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8138 )
8139 {
8140 return Err(failure(
8141 NodeFailureCode::SessionRecordBusy,
8142 "managed session already has a live runtime binding",
8143 ));
8144 }
8145 let identity = record.provider_session.clone().ok_or_else(|| {
8146 failure(
8147 NodeFailureCode::SessionRecordNotResumable,
8148 "managed session has no verified provider session identity",
8149 )
8150 })?;
8151 let working_directory = self.workspace_root(&record.workspace_id)?;
8152 if !platform::roots_equal(
8153 &working_directory,
8154 windows_path_text(&record.canonical_root),
8155 ) {
8156 return Err(failure(
8157 NodeFailureCode::SessionRecordConflict,
8158 "managed session workspace root changed; refusing to resume in another directory",
8159 ));
8160 }
8161 {
8162 let records = self
8163 .session_records
8164 .lock()
8165 .unwrap_or_else(|poisoned| poisoned.into_inner());
8166 if records.records.values().any(|candidate| {
8167 candidate.record_id != *record_id
8168 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8169 session_registry::same_provider_session(
8170 &candidate.provider,
8171 candidate_identity,
8172 &record.provider,
8173 &identity,
8174 )
8175 })
8176 && (candidate.active_session.is_some()
8177 || matches!(
8178 candidate.state,
8179 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8180 ))
8181 }) {
8182 return Err(failure(
8183 NodeFailureCode::SessionRecordConflict,
8184 "provider session is already active through another managed record",
8185 ));
8186 }
8187 }
8188 let runtime_requirement = if initial_prompt.is_some() {
8189 ProviderRuntimeRequirement::ResumeWithPrompt
8190 } else {
8191 ProviderRuntimeRequirement::Resume
8192 };
8193 let runtime_policy = self.admit_provider_runtime(
8194 &record.provider,
8195 runtime_requirement,
8196 )
8197 .await?;
8198 let request = ResumeLaunchRequest {
8199 working_directory,
8200 terminal_size,
8201 initial_prompt,
8202 };
8203 request
8204 .validate()
8205 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8206 self.ensure_binding_capacity()?;
8207 let resolved_materialization = match self.resolve_record_materialization(
8208 record_id,
8209 &record.provider,
8210 record.mode,
8211 record.environment_profile.as_ref(),
8212 record.bundle.as_ref(),
8213 record.context.as_ref(),
8214 ) {
8215 Ok(materialization) => materialization,
8216 Err(error) => {
8217 let label = match error.code {
8218 NodeFailureCode::UnknownBundle => "bundle-unavailable",
8219 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8220 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8221 _ => "environment-profile-unavailable",
8222 };
8223 self.mark_record_error(record_id, label)?;
8224 return Err(error);
8225 }
8226 };
8227 let (materialization_id, environment_overlay) = match resolved_materialization {
8228 Some((id, overlay)) => (Some(id), overlay),
8229 None => (None, None),
8230 };
8231 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8232 let mut environment_selection = match SpawnPreparer::select_environment(self,
8233 instance_id,
8234 &record.provider,
8235 record.mode,
8236 record.environment_profile.as_ref(),
8237 ) {
8238 Ok(selection) => selection,
8239 Err(error) => {
8240 self.mark_record_error(record_id, "environment-profile-unavailable")?;
8241 return Err(error);
8242 }
8243 };
8244 let mut instance_overlay = environment_overlay
8245 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8246 .transpose()?
8247 .flatten();
8248 if let Some(stale_address) = self.bound_address_for_record(record_id) {
8249 self.remove_session(&stale_address).await?;
8250 }
8251 let address = SessionAddress {
8252 workspace_id: record.workspace_id.clone(),
8253 session: SessionKey {
8254 instance_id,
8255 generation: SessionGeneration(1),
8256 },
8257 };
8258 let transaction = self
8259 .state_transaction
8260 .lock()
8261 .unwrap_or_else(|poisoned| poisoned.into_inner());
8262 let current = self.record(record_id)?;
8263 if current.active_session.is_some()
8264 || matches!(
8265 current.state,
8266 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8267 )
8268 {
8269 return Err(failure(
8270 NodeFailureCode::SessionRecordBusy,
8271 "managed session became active while resume was being prepared",
8272 ));
8273 }
8274 self.bind_managed_session_with_materialization(
8275 &address,
8276 record_id.clone(),
8277 runtime_policy,
8278 record.environment_profile.clone(),
8279 record.bundle.clone(),
8280 record.context.clone(),
8281 materialization_id,
8282 );
8283 if let Some(selection) = environment_selection.take() {
8284 selection.retain();
8285 }
8286 if let Some(overlay) = instance_overlay.take() {
8287 overlay.retain();
8288 }
8289 drop(transaction);
8290
8291 let transport = match record.mode {
8292 SessionMode::Pty => TransportKind::Pty,
8293 SessionMode::Inline => TransportKind::Pipe,
8294 SessionMode::Acp => TransportKind::Acp,
8295 };
8296 let agent_id = record.provider.clone();
8297 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8298 if let Err(error) = self
8299 .dispatch_bounded(
8300 ControlCommand::Register {
8301 instance_id,
8302 agent_id,
8303 transport,
8304 },
8305 dispatch_timeout,
8306 )
8307 .await
8308 {
8309 if let Some(binding) = self.remove_binding(&address) {
8310 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8311 }
8312 return Err(error);
8313 }
8314 let command = self.prepare_command(ControlCommand::Resume {
8315 instance_id,
8316 target: ResumeTarget::ProviderSession { identity },
8317 runtime_policy,
8318 request,
8319 });
8320 let started_after = self.current_sequence();
8321 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8322 self.rollback_spawn(&address, dispatch_timeout).await?;
8323 return Err(error);
8324 }
8325 if let Err(error) = self.dispatch_envelope(command) {
8326 self.clear_armed_resume(&address);
8327 self.rollback_spawn(&address, dispatch_timeout).await?;
8328 return Err(error);
8329 }
8330 match self
8331 .wait_for_record_resume(&address, started_after)
8332 .await
8333 {
8334 Ok(settled_address) => {
8335 let managed_lease_id = {
8336 self.managed_worktrees.lock()
8337 .unwrap_or_else(|poisoned| poisoned.into_inner())
8338 .lease_for_workspace(&record.workspace_id)
8339 };
8340 if let Some(lease_id) = managed_lease_id {
8341 {
8342 let mut bindings = self.session_bindings.lock()
8343 .unwrap_or_else(|poisoned| poisoned.into_inner());
8344 let binding = bindings.get_mut(&settled_address.session.instance_id)
8345 .ok_or_else(|| failure(
8346 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8347 "resumed managed session lost its exact binding",
8348 ))?;
8349 if binding.generation != settled_address.session.generation {
8350 return Err(failure(
8351 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8352 "resumed managed session generation diverged",
8353 ));
8354 }
8355 binding.managed_worktree_lease_id = Some(lease_id.clone());
8356 }
8357 let snapshot = self.managed_worktrees.lock()
8358 .unwrap_or_else(|poisoned| poisoned.into_inner())
8359 .bind_session(
8360 &lease_id,
8361 ManagedWorktreeSessionHolder {
8362 incarnation_id: self.incarnation_id,
8363 instance_id: settled_address.session.instance_id,
8364 generation: settled_address.session.generation,
8365 },
8366 Some(record_id.clone()),
8367 unix_time_ms(),
8368 ).map_err(|message| failure(
8369 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8370 &message,
8371 ))?;
8372 if let Err(error) = self.persist_state() {
8373 let _ = self.remove_session(&settled_address).await;
8374 return Err(persistence_failure(error));
8375 }
8376 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8377 }
8378 Ok((self.record(record_id)?, settled_address))
8379 }
8380 Err(error) => {
8381 if matches!(
8382 error.code,
8383 NodeFailureCode::SessionRecordNotResumable
8384 | NodeFailureCode::BackendOperationFailed
8385 ) {
8386 if let Some(bound) = self.bound_address_for_record(record_id) {
8387 let _ = self.remove_session(&bound).await;
8388 }
8389 }
8390 Err(error)
8391 }
8392 }
8393 }
8394
8395 async fn wait_for_record_resume(
8396 &self,
8397 initial_address: &SessionAddress,
8398 after_sequence: u64,
8399 ) -> Result<SessionAddress, NodeFailure> {
8400 let deadline = Instant::now()
8401 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8402 let mut scan_after = after_sequence;
8403 loop {
8404 let events = self
8405 .history
8406 .lock()
8407 .unwrap_or_else(|poisoned| poisoned.into_inner())
8408 .events
8409 .iter()
8410 .filter(|event| event.sequence > scan_after)
8411 .cloned()
8412 .collect::<Vec<_>>();
8413 for envelope in events {
8414 scan_after = scan_after.max(envelope.sequence);
8415 let NodeEvent::Control { address, event } = envelope.event else {
8416 continue;
8417 };
8418 if address.session.instance_id != initial_address.session.instance_id {
8419 continue;
8420 }
8421 match event.event {
8422 ControlEventKind::Resumed { .. } => return Ok(address),
8423 ControlEventKind::ResumeDenied { reason } => {
8424 return Err(failure(
8425 NodeFailureCode::SessionRecordNotResumable,
8426 &reason,
8427 ));
8428 }
8429 ControlEventKind::ResumeFailed { message }
8430 | ControlEventKind::CommandRejected { message } => {
8431 return Err(failure(
8432 NodeFailureCode::BackendOperationFailed,
8433 &message,
8434 ));
8435 }
8436 _ => {}
8437 }
8438 }
8439 if Instant::now() >= deadline {
8440 return Err(failure(
8441 NodeFailureCode::BackendBusy,
8442 "managed resume did not settle before the bounded deadline",
8443 ));
8444 }
8445 sleep(Duration::from_millis(5)).await;
8446 }
8447 }
8448
8449 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8450 self.workspaces
8451 .read()
8452 .unwrap_or_else(|poisoned| poisoned.into_inner())
8453 .get(workspace_id)
8454 .cloned()
8455 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8456 }
8457
8458 fn workspace_roots(&self) -> Vec<PathBuf> {
8459 self.workspaces
8460 .read()
8461 .unwrap_or_else(|poisoned| poisoned.into_inner())
8462 .values()
8463 .map(PathBuf::from)
8464 .collect()
8465 }
8466
8467 async fn inspect_workspace(
8468 &self,
8469 workspace_id: WorkspaceId,
8470 ) -> Result<WorkspaceInspection, NodeFailure> {
8471 let _permit = self
8472 .inspection_slots
8473 .clone()
8474 .try_acquire_owned()
8475 .map_err(|_| {
8476 tracing::warn!(
8477 workspace_id = %workspace_id,
8478 "workspace inspection rejected: inspection capacity is busy",
8479 );
8480 failure(
8481 NodeFailureCode::BackendBusy,
8482 "workspace inspection capacity is busy",
8483 )
8484 })?;
8485 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8486 tracing::warn!(
8487 workspace_id = %workspace_id,
8488 code = ?error.code,
8489 "workspace inspection rejected: workspace is not registered",
8490 );
8491 error
8492 })?;
8493 let tree_root = canonical_root.clone();
8494 let started_at = Instant::now();
8495 let time_budget_ms = workspace_inspection_time_budget_ms();
8496 let entry_cap = workspace_inspection_entry_cap();
8497 let deadline = started_at + Duration::from_millis(time_budget_ms);
8498 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8499 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8500 })
8501 .await
8502 .map_err(|error| {
8503 tracing::warn!(
8504 workspace_id = %workspace_id,
8505 error = %error,
8506 "workspace inspection rejected: walk task failed",
8507 );
8508 failure(
8509 NodeFailureCode::BackendOperationFailed,
8510 &format!("workspace inspection task failed: {error}"),
8511 )
8512 })?;
8513 let (mut git, git_time_budget_exceeded) =
8514 inspect_git_workspace(&canonical_root, deadline).await;
8515 if !git.worktrees.is_empty() {
8516 let registered = self
8517 .workspaces
8518 .read()
8519 .unwrap_or_else(|poisoned| poisoned.into_inner())
8520 .iter()
8521 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8522 .collect::<Vec<_>>();
8523 for worktree in &mut git.worktrees {
8524 worktree.workspace_id = registered
8525 .iter()
8526 .find(|(_, root)| {
8527 worktree_paths_equal(root, windows_path_text(&worktree.path))
8528 })
8529 .map(|(workspace_id, _)| workspace_id.clone());
8530 }
8531 }
8532 if git.is_repository {
8533 git.managed_worktree = self
8534 .managed_worktrees
8535 .lock()
8536 .unwrap_or_else(|poisoned| poisoned.into_inner())
8537 .git_scope(&workspace_id, git.branch.as_deref());
8538 }
8539 let truncation = workspace_inspection_truncation(
8540 &walk_budget,
8541 git_time_budget_exceeded,
8542 started_at.elapsed(),
8543 );
8544 if let Some(truncation) = &truncation {
8545 tracing::info!(
8546 workspace_id = %workspace_id,
8547 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8548 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8549 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8550 entries_visited = truncation.entries_visited,
8551 elapsed_ms = truncation.elapsed_ms,
8552 "workspace inspection truncated by its inner budget",
8553 );
8554 }
8555 Ok(WorkspaceInspection {
8556 workspace_id,
8557 entries,
8558 tree_truncated,
8559 git,
8560 truncation,
8561 })
8562 }
8563
8564 async fn browse_host_directories(
8565 &self,
8566 directory: Option<OpaqueHostPath>,
8567 after: Option<OpaqueHostPath>,
8568 ) -> Result<HostDirectoryListing, NodeFailure> {
8569 let permit = self
8570 .inspection_slots
8571 .clone()
8572 .try_acquire_owned()
8573 .map_err(|_| {
8574 failure(
8575 NodeFailureCode::BackendBusy,
8576 "host directory browse capacity is busy",
8577 )
8578 })?;
8579 let task = tokio::task::spawn_blocking(move || {
8580 let _permit = permit;
8581 browse_host_directories(directory, after)
8582 });
8583 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8584 .await
8585 .map_err(|_| {
8586 failure(
8587 NodeFailureCode::HostDirectoryReadTimedOut,
8588 "host directory browse exceeded its bounded deadline",
8589 )
8590 })?
8591 .map_err(|_| {
8592 failure(
8593 NodeFailureCode::HostDirectoryReadFailed,
8594 "host directory browse task failed",
8595 )
8596 })?
8597 .map_err(host_directory_failure)
8598 }
8599
8600 async fn read_workspace_file(
8601 &self,
8602 workspace_id: WorkspaceId,
8603 path: RepositoryPath,
8604 ) -> Result<WorkspaceFileRead, NodeFailure> {
8605 let canonical_root = self.workspace_root(&workspace_id)?;
8606 let permit = self
8607 .inspection_slots
8608 .clone()
8609 .try_acquire_owned()
8610 .map_err(|_| {
8611 failure(
8612 NodeFailureCode::BackendBusy,
8613 "workspace file read capacity is busy",
8614 )
8615 })?;
8616 let path_to_read = path.clone();
8617 let task = tokio::task::spawn_blocking(move || {
8618 let _permit = permit;
8619 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8620 });
8621 let content = timeout(
8622 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8623 task,
8624 )
8625 .await
8626 .map_err(|_| {
8627 failure(
8628 NodeFailureCode::RepositoryFileReadTimedOut,
8629 "repository file read exceeded its bounded deadline",
8630 )
8631 })?
8632 .map_err(|_| {
8633 failure(
8634 NodeFailureCode::RepositoryFileReadFailed,
8635 "repository file read task failed",
8636 )
8637 })?
8638 .map_err(workspace_file_failure)?;
8639 let content = match content {
8640 WorkspaceFileBytes::Utf8(text) => {
8641 let revision = Some(workspace_file_revision(text.as_bytes()));
8642 return Ok(WorkspaceFileRead {
8643 workspace_id,
8644 path,
8645 content: WorkspaceFileContent::Utf8 {
8646 byte_len: u32::try_from(text.len())
8647 .expect("bounded workspace text length must fit u32"),
8648 text,
8649 },
8650 revision,
8651 });
8652 }
8653 WorkspaceFileBytes::NonUtf8 { byte_length } => {
8654 WorkspaceFileContent::NonUtf8 {
8655 byte_len: u32::try_from(byte_length)
8656 .expect("bounded workspace file length must fit u32"),
8657 }
8658 }
8659 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8660 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8661 },
8662 };
8663 Ok(WorkspaceFileRead {
8664 workspace_id,
8665 path,
8666 content,
8667 revision: None,
8668 })
8669 }
8670
8671 async fn write_workspace_file(
8672 &self,
8673 workspace_id: WorkspaceId,
8674 path: RepositoryPath,
8675 expected_revision: WorkspaceFileRevision,
8676 text: String,
8677 ) -> Result<WorkspaceFileRead, NodeFailure> {
8678 let canonical_root = self.workspace_root(&workspace_id)?;
8679 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8680 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8681 })?;
8682 let path_to_write = path.clone();
8683 let expected = expected_revision.as_str().to_owned();
8684 let text_to_write = text.clone();
8685 let task = tokio::task::spawn_blocking(move || {
8686 let _permit = permit;
8687 write_workspace_file_to_disk(
8688 Path::new(&canonical_root),
8689 &path_to_write,
8690 &expected,
8691 &text_to_write,
8692 )
8693 });
8694 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8695 .await
8696 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8697 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8698 .map_err(workspace_file_write_failure)?;
8699 Ok(WorkspaceFileRead {
8700 workspace_id,
8701 path,
8702 content: WorkspaceFileContent::Utf8 {
8703 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8704 text,
8705 },
8706 revision: Some(WorkspaceFileRevision::new(revision)
8707 .expect("workspace writer returns a valid SHA-256 revision")),
8708 })
8709 }
8710
8711 async fn create_workspace_file(
8712 &self,
8713 workspace_id: WorkspaceId,
8714 path: RepositoryPath,
8715 ) -> Result<WorkspaceFileRead, NodeFailure> {
8716 let canonical_root = self.workspace_root(&workspace_id)?;
8717 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8718 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8719 })?;
8720 let path_to_create = path.clone();
8721 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8722 let task_commit_state = Arc::clone(&commit_state);
8723 let task = tokio::task::spawn_blocking(move || {
8724 let _permit = permit;
8725 create_workspace_file_on_disk(
8726 Path::new(&canonical_root),
8727 &path_to_create,
8728 &task_commit_state,
8729 )
8730 });
8731 let revision = settle_workspace_entry_create(
8732 task,
8733 commit_state,
8734 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8735 "repository file create exceeded its bounded deadline",
8736 "repository file create task failed",
8737 )
8738 .await?;
8739 Ok(WorkspaceFileRead {
8740 workspace_id,
8741 path,
8742 content: WorkspaceFileContent::Utf8 {
8743 text: String::new(),
8744 byte_len: 0,
8745 },
8746 revision: Some(WorkspaceFileRevision::new(revision)
8747 .expect("workspace creator returns a valid SHA-256 revision")),
8748 })
8749 }
8750
8751 async fn create_workspace_directory(
8752 &self,
8753 workspace_id: WorkspaceId,
8754 path: RepositoryPath,
8755 ) -> Result<WorkspaceEntry, NodeFailure> {
8756 let canonical_root = self.workspace_root(&workspace_id)?;
8757 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8758 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8759 })?;
8760 let path_to_create = path.clone();
8761 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8762 let task_commit_state = Arc::clone(&commit_state);
8763 let task = tokio::task::spawn_blocking(move || {
8764 let _permit = permit;
8765 create_workspace_directory_on_disk(
8766 Path::new(&canonical_root),
8767 &path_to_create,
8768 &task_commit_state,
8769 )
8770 });
8771 settle_workspace_entry_create(
8772 task,
8773 commit_state,
8774 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8775 "repository directory create exceeded its bounded deadline",
8776 "repository directory create task failed",
8777 )
8778 .await?;
8779 Ok(WorkspaceEntry {
8780 relative_path: path,
8781 kind: WorkspaceEntryKind::Directory,
8782 })
8783 }
8784
8785 async fn read_git_history(
8786 &self,
8787 workspace_id: WorkspaceId,
8788 path: Option<RepositoryPath>,
8789 before: Option<GitObjectId>,
8790 limit: u16,
8791 ) -> Result<GitHistoryPage, NodeFailure> {
8792 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8793 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
8794 })?;
8795 let root = self.workspace_root(&workspace_id)?;
8796 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
8797 }
8798
8799 async fn read_git_diff(
8800 &self,
8801 workspace_id: WorkspaceId,
8802 request: GitDiffRequest,
8803 ) -> Result<GitDiff, NodeFailure> {
8804 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8805 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
8806 })?;
8807 let root = self.workspace_root(&workspace_id)?;
8808 read_git_diff_bounded(&root, request).await
8809 }
8810
8811 fn native_session_route_root(
8812 &self,
8813 route: &NativeSessionCatalogRoute,
8814 ) -> Result<Option<String>, NodeFailure> {
8815 route
8816 .validate()
8817 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8818 match route.scope {
8819 gate4agent_types::NativeSessionCatalogScope::Workspace => route
8820 .workspace_id
8821 .as_ref()
8822 .map(|workspace_id| self.workspace_root(workspace_id))
8823 .transpose(),
8824 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
8825 }
8826 }
8827
8828 fn project_native_session_entries(
8829 &self,
8830 route: &NativeSessionCatalogRoute,
8831 entries: Vec<ScopedNativeSessionCatalogEntry>,
8832 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
8833 let current_root = route
8834 .workspace_id
8835 .as_ref()
8836 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
8837 let records = self
8838 .session_records
8839 .lock()
8840 .unwrap_or_else(|poisoned| poisoned.into_inner());
8841 let mut seen_records = std::collections::HashSet::new();
8842 entries
8843 .into_iter()
8844 .map(|entry| {
8845 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
8846 let matches = records
8847 .records
8848 .values()
8849 .filter(|record| {
8850 record.provider == route.provider
8851 && record.workspace_id == *workspace_id
8852 && current_root.as_deref().is_some_and(|root| {
8853 platform::roots_equal(
8854 root,
8855 windows_path_text(&record.canonical_root),
8856 )
8857 })
8858 && record.provider_session.as_ref().is_some_and(|identity| {
8859 session_registry::same_provider_session(
8860 &record.provider,
8861 identity,
8862 &route.provider,
8863 &entry.provider_session,
8864 )
8865 })
8866 })
8867 .map(|record| record.record_id.clone())
8868 .collect::<Vec<_>>();
8869 (matches.len() == 1
8870 && seen_records.insert(matches[0].clone()))
8871 .then(|| matches[0].clone())
8872 });
8873 let metadata = entry.metadata;
8874 crate::protocol::NativeSessionCatalogEntry {
8875 selection_id: metadata.selection_id,
8876 title: metadata.title,
8877 modified_at_unix_ms: metadata.modified_at_unix_ms,
8878 model: metadata.model,
8879 message_count: metadata.message_count,
8880 completed_turn_count: metadata.completed_turn_count,
8881 external_group: entry.external_group,
8882 record_id,
8883 }
8884 })
8885 .collect()
8886 }
8887
8888 async fn catalog_native_sessions(
8889 &self,
8890 route: NativeSessionCatalogRoute,
8891 limit: u16,
8892 ) -> Result<(
8893 Vec<crate::protocol::NativeSessionCatalogEntry>,
8894 crate::protocol::NativeSessionCatalogSummary,
8895 ), NodeFailure> {
8896 let canonical_root = self.native_session_route_root(&route)?;
8897 let registered_workspace_roots = self.workspace_roots();
8898 let provider = route.provider.clone();
8899 let scope = route.scope;
8900 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8901 failure(
8902 NodeFailureCode::InvalidRequest,
8903 "native session catalog is not configured",
8904 )
8905 })?;
8906 let _permit = Arc::clone(&self.inspection_slots)
8907 .try_acquire_owned()
8908 .map_err(|_| {
8909 failure(
8910 NodeFailureCode::BackendBusy,
8911 "native session catalog capacity is busy",
8912 )
8913 })?;
8914 let page = run_native_session_catalog_operation(
8915 catalog,
8916 "native session catalog",
8917 move |catalog| {
8918 catalog.catalog_initial_for_scope(
8919 &provider,
8920 scope,
8921 canonical_root.as_deref().map(Path::new),
8922 ®istered_workspace_roots,
8923 limit,
8924 )
8925 },
8926 )
8927 .await?;
8928 let summary = crate::protocol::NativeSessionCatalogSummary {
8929 catalog_revision: page.revision,
8930 recent_cutoff_unix_ms: page.cutoff_unix_ms,
8931 recent_total_count: page.recent_total_count,
8932 older_total_count: page.older_total_count,
8933 recent_next_after_selection_id: page.next_after_selection_id,
8934 recent_has_more: page.remaining_count > 0,
8935 };
8936 Ok((self.project_native_session_entries(&route, page.entries), summary))
8937 }
8938
8939 async fn page_native_sessions(
8940 &self,
8941 route: NativeSessionCatalogRoute,
8942 window: crate::protocol::NativeSessionCatalogWindow,
8943 catalog_revision: u64,
8944 recent_cutoff_unix_ms: u64,
8945 after_selection_id: Option<String>,
8946 limit: u16,
8947 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
8948 let canonical_root = self.native_session_route_root(&route)?;
8949 let registered_workspace_roots = self.workspace_roots();
8950 let provider = route.provider.clone();
8951 let scope = route.scope;
8952 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8953 failure(
8954 NodeFailureCode::InvalidRequest,
8955 "native session catalog is not configured",
8956 )
8957 })?;
8958 let _permit = Arc::clone(&self.inspection_slots)
8959 .try_acquire_owned()
8960 .map_err(|_| {
8961 failure(
8962 NodeFailureCode::BackendBusy,
8963 "native session catalog capacity is busy",
8964 )
8965 })?;
8966 let page = run_native_session_catalog_operation(
8967 catalog,
8968 "native session catalog page",
8969 move |catalog| {
8970 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
8971 &provider,
8972 scope,
8973 canonical_root.as_deref().map(Path::new),
8974 ®istered_workspace_roots,
8975 window,
8976 catalog_revision,
8977 recent_cutoff_unix_ms,
8978 after_selection_id.as_deref(),
8979 limit,
8980 ))
8981 },
8982 )
8983 .await?;
8984 let page = page.map_err(|error| match error {
8985 NativeSessionCatalogError::StaleCatalog => failure(
8986 NodeFailureCode::StaleNativeSessionCatalog,
8987 "native session catalog revision or cursor is stale",
8988 ),
8989 _ => failure(
8990 NodeFailureCode::BackendOperationFailed,
8991 "native session catalog page failed",
8992 ),
8993 })?;
8994 let entries = self.project_native_session_entries(&route, page.entries);
8995 Ok(crate::protocol::NativeSessionCatalogPage {
8996 window: page.window,
8997 revision: page.revision,
8998 entries,
8999 next_after_selection_id: page.next_after_selection_id,
9000 remaining_count: page.remaining_count,
9001 has_more: page.remaining_count > 0,
9002 })
9003 }
9004
9005 async fn preview_native_session(
9006 &self,
9007 selection: NativeSessionSelection,
9008 message_limit: u16,
9009 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9010 selection
9011 .validate()
9012 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9013 let canonical_root = self.native_session_route_root(&selection.route)?;
9014 let registered_workspace_roots = self.workspace_roots();
9015 let provider = selection.route.provider.clone();
9016 let scope = selection.route.scope;
9017 let catalog_revision = selection.catalog_revision;
9018 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9019 let selection_id = selection.selection_id.clone();
9020 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9021 failure(
9022 NodeFailureCode::InvalidRequest,
9023 "native session preview is not configured",
9024 )
9025 })?;
9026 let _permit = Arc::clone(&self.inspection_slots)
9027 .try_acquire_owned()
9028 .map_err(|_| {
9029 failure(
9030 NodeFailureCode::BackendBusy,
9031 "native session preview capacity is busy",
9032 )
9033 })?;
9034 let preview = run_native_session_catalog_operation(
9035 catalog,
9036 "native session preview",
9037 move |catalog| {
9038 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9039 &provider,
9040 scope,
9041 canonical_root.as_deref().map(Path::new),
9042 ®istered_workspace_roots,
9043 catalog_revision,
9044 recent_cutoff_unix_ms,
9045 &selection_id,
9046 message_limit,
9047 ))
9048 },
9049 )
9050 .await?;
9051 preview
9052 .map(Into::into)
9053 .map_err(native_session_preview_failure)
9054 }
9055
9056 async fn index_native_session(
9057 &self,
9058 selection: NativeSessionSelection,
9059 display_name: String,
9060 ) -> Result<ManagedSessionRecord, NodeFailure> {
9061 selection
9062 .validate()
9063 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9064 if selection.route.scope
9065 != gate4agent_types::NativeSessionCatalogScope::Workspace
9066 {
9067 return Err(failure(
9068 NodeFailureCode::WorkspaceRegistrationRequired,
9069 "register the external project as a workspace before indexing or resuming it",
9070 ));
9071 }
9072 let workspace_id = selection
9073 .route
9074 .workspace_id
9075 .clone()
9076 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9077 let canonical_root = self
9078 .native_session_route_root(&selection.route)?
9079 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9080 let registered_workspace_roots = self.workspace_roots();
9081 let provider = selection.route.provider.clone();
9082 let catalog_revision = selection.catalog_revision;
9083 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9084 let selection_id = selection.selection_id.clone();
9085 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9086 failure(
9087 NodeFailureCode::InvalidRequest,
9088 "native session index is not configured",
9089 )
9090 })?;
9091 let _permit = Arc::clone(&self.inspection_slots)
9092 .try_acquire_owned()
9093 .map_err(|_| {
9094 failure(
9095 NodeFailureCode::BackendBusy,
9096 "native session index capacity is busy",
9097 )
9098 })?;
9099 let resolved = run_native_session_catalog_operation(
9100 catalog,
9101 "native session index resolution",
9102 move |catalog| {
9103 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9104 &provider,
9105 gate4agent_types::NativeSessionCatalogScope::Workspace,
9106 Some(Path::new(&canonical_root)),
9107 ®istered_workspace_roots,
9108 catalog_revision,
9109 recent_cutoff_unix_ms,
9110 &selection_id,
9111 ))
9112 },
9113 )
9114 .await?
9115 .map_err(native_session_preview_failure)?;
9116 self.index_provider_session_with_policy(
9117 workspace_id,
9118 selection.route.provider,
9119 resolved.identity,
9120 display_name,
9121 true,
9122 )
9123 }
9124
9125 async fn preview_native_session_by_session_id(
9126 &self,
9127 workspace_id: WorkspaceId,
9128 provider: AgentId,
9129 session_id: String,
9130 message_limit: u16,
9131 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9132 let canonical_root = self.workspace_root(&workspace_id)?;
9133 let registered_workspace_roots = self.workspace_roots();
9134 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9135 failure(
9136 NodeFailureCode::InvalidRequest,
9137 "native session preview is not configured",
9138 )
9139 })?;
9140 let _permit = Arc::clone(&self.inspection_slots)
9141 .try_acquire_owned()
9142 .map_err(|_| {
9143 failure(
9144 NodeFailureCode::BackendBusy,
9145 "native session preview capacity is busy",
9146 )
9147 })?;
9148 run_native_session_catalog_operation(
9149 catalog,
9150 "native session preview",
9151 move |catalog| {
9152 catalog.preview_session_id_for_workspace(
9153 &provider,
9154 Path::new(&canonical_root),
9155 ®istered_workspace_roots,
9156 &session_id,
9157 message_limit,
9158 )
9159 },
9160 )
9161 .await
9162 }
9163
9164 async fn register_workspace(
9165 &self,
9166 workspace_id: WorkspaceId,
9167 root: String,
9168 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9169 validate_workspace_request_root(&root)?;
9170 let canonicalize_workspace_id = workspace_id.clone();
9171 let workspace = tokio::task::spawn_blocking(move || {
9172 WorkspaceConfig::new(canonicalize_workspace_id, root)
9173 })
9174 .await
9175 .map_err(|error| {
9176 failure(
9177 NodeFailureCode::BackendOperationFailed,
9178 &format!("workspace canonicalization task failed: {error}"),
9179 )
9180 })?
9181 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9182 let transaction = self
9183 .state_transaction
9184 .lock()
9185 .unwrap_or_else(|poisoned| poisoned.into_inner());
9186 let mut workspaces = self
9187 .workspaces
9188 .write()
9189 .unwrap_or_else(|poisoned| poisoned.into_inner());
9190 if workspaces.contains_key(&workspace_id) {
9191 return Err(failure(
9192 NodeFailureCode::DuplicateWorkspaceId,
9193 "workspace ID is already registered",
9194 ));
9195 }
9196 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9197 platform::roots_equal(existing_root, workspace.canonical_root())
9198 }) {
9199 return Err(failure(
9200 NodeFailureCode::DuplicateWorkspaceRoot,
9201 &format!("workspace root is already registered as '{existing_id}'"),
9202 ));
9203 }
9204 {
9205 let records = self
9206 .session_records
9207 .lock()
9208 .unwrap_or_else(|poisoned| poisoned.into_inner());
9209 if let Some(record) = records.records.values().find(|record| {
9210 record.workspace_id == workspace_id
9211 && !record
9212 .canonical_root
9213 .as_utf8()
9214 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9215 }) {
9216 return Err(failure(
9217 NodeFailureCode::SessionRecordConflict,
9218 &format!(
9219 "workspace ID is retained by session record '{}' at another root",
9220 record.record_id,
9221 ),
9222 ));
9223 }
9224 }
9225 let snapshot = WorkspaceSnapshot {
9226 workspace_id: workspace_id.clone(),
9227 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9228 sessions: Vec::new(),
9229 worktree_service_mode: Some(WorktreeServiceMode::Manual),
9230 managed_worktree_profiles: Some(WorktreeProfileInventory {
9231 profiles: Vec::new(),
9232 }),
9233 };
9234 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9235 drop(workspaces);
9236 let previous_records = {
9237 let mut records = self
9238 .session_records
9239 .lock()
9240 .unwrap_or_else(|poisoned| poisoned.into_inner());
9241 let previous = records.records.clone();
9242 for record in records.records.values_mut().filter(|record| {
9243 record.workspace_id == workspace_id
9244 && record
9245 .canonical_root
9246 .as_utf8()
9247 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9248 }) {
9249 if record.active_session.is_none() {
9250 record.state = detached_record_state(record);
9251 }
9252 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9253 record.last_error = None;
9254 }
9255 record.updated_at_unix_ms = unix_time_ms();
9256 }
9257 previous
9258 };
9259 if let Err(error) = self.persist_state_locked() {
9260 self.workspaces
9261 .write()
9262 .unwrap_or_else(|poisoned| poisoned.into_inner())
9263 .remove(&workspace_id);
9264 self.session_records
9265 .lock()
9266 .unwrap_or_else(|poisoned| poisoned.into_inner())
9267 .records = previous_records;
9268 return Err(persistence_failure(error));
9269 }
9270 let restored = self
9271 .session_records
9272 .lock()
9273 .unwrap_or_else(|poisoned| poisoned.into_inner())
9274 .records
9275 .values()
9276 .filter(|record| record.workspace_id == workspace_id)
9277 .cloned()
9278 .collect::<Vec<_>>();
9279 drop(transaction);
9280 self.publish(NodeEvent::WorkspaceAdded {
9281 workspace: snapshot.clone(),
9282 });
9283 for record in restored {
9284 self.publish_record(record);
9285 }
9286 Ok(snapshot)
9287 }
9288
9289 async fn create_standalone_workspace(
9290 &self,
9291 workspace_id: WorkspaceId,
9292 root: String,
9293 initial_branch: Option<String>,
9294 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9295 validate_workspace_request_root(&root)?;
9296 validate_git_revision("initial branch", initial_branch.as_deref())?;
9297 let preflight_workspace_id = workspace_id.clone();
9298 let preflight_root = root.clone();
9299 let canonical_candidate = tokio::task::spawn_blocking(move || {
9300 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9301 })
9302 .await
9303 .map_err(|_| failure(
9304 NodeFailureCode::BackendOperationFailed,
9305 "standalone workspace root preflight task failed",
9306 ))?
9307 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9308 {
9309 let workspaces = self
9310 .workspaces
9311 .read()
9312 .unwrap_or_else(|poisoned| poisoned.into_inner());
9313 if workspaces.contains_key(&workspace_id) {
9314 return Err(failure(
9315 NodeFailureCode::DuplicateWorkspaceId,
9316 "workspace ID is already registered",
9317 ));
9318 }
9319 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9320 platform::roots_equal(existing_root, &canonical_candidate)
9321 }) {
9322 return Err(failure(
9323 NodeFailureCode::DuplicateWorkspaceRoot,
9324 &format!("workspace root is already registered as '{existing_id}'"),
9325 ));
9326 }
9327 }
9328 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9329 .await
9330 .map_err(standalone_workspace_failure)?;
9331 prepared
9332 .verify_for_registration()
9333 .await
9334 .map_err(standalone_workspace_failure)?;
9335 match self
9336 .register_workspace(workspace_id, prepared.root().to_owned())
9337 .await
9338 {
9339 Ok(workspace) => Ok(workspace),
9340 Err(registration_error) => {
9341 if prepared.compensate_registration_failure().await {
9342 Err(registration_error)
9343 } else {
9344 Err(failure(
9345 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9346 "standalone workspace initialization succeeded but registration failed; recovery is required",
9347 ))
9348 }
9349 }
9350 }
9351 }
9352
9353 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9354 let bindings = self
9355 .session_bindings
9356 .lock()
9357 .unwrap_or_else(|poisoned| poisoned.into_inner());
9358 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9359 return Err(failure(
9360 NodeFailureCode::WorkspaceBusy,
9361 "workspace retains a bound session; remove every session before unregistering it",
9362 ));
9363 }
9364 drop(bindings);
9365 let transaction = self
9366 .state_transaction
9367 .lock()
9368 .unwrap_or_else(|poisoned| poisoned.into_inner());
9369 let mut workspaces = self
9370 .workspaces
9371 .write()
9372 .unwrap_or_else(|poisoned| poisoned.into_inner());
9373 if !workspaces.contains_key(workspace_id) {
9374 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9375 }
9376 if workspaces.len() == 1 {
9377 return Err(failure(
9378 NodeFailureCode::LastWorkspace,
9379 "node must retain at least one workspace",
9380 ));
9381 }
9382 let removed_root = workspaces
9383 .remove(workspace_id)
9384 .expect("workspace presence was validated");
9385 drop(workspaces);
9386 let previous_records = {
9387 let mut records = self
9388 .session_records
9389 .lock()
9390 .unwrap_or_else(|poisoned| poisoned.into_inner());
9391 let previous = records.records.clone();
9392 for record in records
9393 .records
9394 .values_mut()
9395 .filter(|record| &record.workspace_id == workspace_id)
9396 {
9397 record.active_session = None;
9398 record.state = ManagedSessionState::Unavailable;
9399 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9400 record.updated_at_unix_ms = unix_time_ms();
9401 }
9402 previous
9403 };
9404 if let Err(error) = self.persist_state_locked() {
9405 self.workspaces
9406 .write()
9407 .unwrap_or_else(|poisoned| poisoned.into_inner())
9408 .insert(workspace_id.clone(), removed_root);
9409 self.session_records
9410 .lock()
9411 .unwrap_or_else(|poisoned| poisoned.into_inner())
9412 .records = previous_records;
9413 return Err(persistence_failure(error));
9414 }
9415 let unavailable = self
9416 .session_records
9417 .lock()
9418 .unwrap_or_else(|poisoned| poisoned.into_inner())
9419 .records
9420 .values()
9421 .filter(|record| &record.workspace_id == workspace_id)
9422 .cloned()
9423 .collect::<Vec<_>>();
9424 drop(transaction);
9425 self.publish(NodeEvent::WorkspaceRemoved {
9426 workspace_id: workspace_id.clone(),
9427 });
9428 for record in unavailable {
9429 self.publish_record(record);
9430 }
9431 Ok(())
9432 }
9433
9434 async fn create_worktree(
9435 &self,
9436 source_workspace_id: WorkspaceId,
9437 workspace_id: WorkspaceId,
9438 target_root: String,
9439 branch: String,
9440 base: Option<String>,
9441 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9442 self.require_worktree_service(&source_workspace_id)?;
9443 validate_workspace_request_root(&target_root)?;
9444 validate_git_revision("worktree base", base.as_deref())?;
9445 let source_root = self.workspace_root(&source_workspace_id)?;
9446 {
9447 let workspaces = self
9448 .workspaces
9449 .read()
9450 .unwrap_or_else(|poisoned| poisoned.into_inner());
9451 if workspaces.contains_key(&workspace_id) {
9452 return Err(failure(
9453 NodeFailureCode::DuplicateWorkspaceId,
9454 "workspace ID is already registered",
9455 ));
9456 }
9457 if let Some((existing_id, _)) = workspaces
9458 .iter()
9459 .find(|(_, root)| worktree_paths_equal(root, &target_root))
9460 {
9461 return Err(failure(
9462 NodeFailureCode::DuplicateWorkspaceRoot,
9463 &format!("workspace root is already registered as '{existing_id}'"),
9464 ));
9465 }
9466 }
9467 let mut worktree = create_git_worktree(
9468 &source_root,
9469 &target_root,
9470 &branch,
9471 base.as_deref(),
9472 )
9473 .await
9474 .map_err(git_worktree_failure)?;
9475 let workspace = match self
9476 .register_workspace(workspace_id, worktree.path.clone())
9477 .await
9478 {
9479 Ok(workspace) => workspace,
9480 Err(registration_error) => {
9481 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9482 return Err(failure(
9483 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9484 "worktree registration failed and compensation requires recovery",
9485 ));
9486 }
9487 return Err(failure(
9488 registration_error.code,
9489 ®istration_error.message,
9490 ));
9491 }
9492 };
9493 worktree.workspace_id = Some(workspace.workspace_id.clone());
9494 Ok((protocol_worktree(worktree), workspace))
9495 }
9496
9497 async fn remove_worktree(
9498 &self,
9499 source_workspace_id: WorkspaceId,
9500 target_root: String,
9501 ) -> Result<Option<WorkspaceId>, NodeFailure> {
9502 self.require_worktree_service(&source_workspace_id)?;
9503 validate_workspace_request_root(&target_root)?;
9504 let source_root = self.workspace_root(&source_workspace_id)?;
9505 let target_root = normalize_worktree_removal_target(&target_root)
9506 .map_err(git_worktree_failure)?;
9507 let target = list_git_worktrees(&source_root)
9508 .await
9509 .map_err(git_worktree_failure)?
9510 .into_iter()
9511 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9512 .ok_or_else(|| {
9513 failure(
9514 NodeFailureCode::WorktreeProtected,
9515 "refusing to remove a path that is not in Git's worktree listing",
9516 )
9517 })?;
9518 let registered_workspace_id = {
9519 let workspaces = self
9520 .workspaces
9521 .read()
9522 .unwrap_or_else(|poisoned| poisoned.into_inner());
9523 let matched = workspaces
9524 .iter()
9525 .find(|(_, root)| worktree_paths_equal(root, &target.path))
9526 .map(|(workspace_id, _)| workspace_id.clone());
9527 if matched.is_some() && workspaces.len() == 1 {
9528 return Err(failure(
9529 NodeFailureCode::LastWorkspace,
9530 "node must retain at least one workspace",
9531 ));
9532 }
9533 matched
9534 };
9535 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9536 let bindings = self
9537 .session_bindings
9538 .lock()
9539 .unwrap_or_else(|poisoned| poisoned.into_inner());
9540 if bindings
9541 .values()
9542 .any(|binding| &binding.workspace_id == workspace_id)
9543 {
9544 return Err(failure(
9545 NodeFailureCode::WorkspaceBusy,
9546 "worktree workspace retains a bound session; remove every session first",
9547 ));
9548 }
9549 }
9550 remove_git_worktree(&source_root, &target.path)
9551 .await
9552 .map_err(git_worktree_failure)?;
9553 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9554 self.unregister_workspace(workspace_id)
9555 .map_err(|error| failure(error.code, &error.message))?;
9556 }
9557 Ok(registered_workspace_id)
9558 }
9559
9560 async fn cleanup_managed_worktree(
9561 &self,
9562 lease_id: &ManagedWorktreeLeaseId,
9563 explicit: bool,
9564 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9565 let lease = self.managed_worktrees.lock()
9566 .unwrap_or_else(|poisoned| poisoned.into_inner())
9567 .get(lease_id)
9568 .cloned()
9569 .ok_or_else(|| failure(
9570 NodeFailureCode::UnknownManagedWorktreeLease,
9571 "managed worktree lease does not exist",
9572 ))?;
9573 if lease.state == ManagedWorktreeLeaseState::Removed {
9574 return Ok(lease.snapshot());
9575 }
9576 if lease.has_holders() {
9577 return Err(failure(
9578 NodeFailureCode::ManagedWorktreeBusy,
9579 "managed worktree lease still has a session or durable-record holder",
9580 ));
9581 }
9582 if self
9583 .materializations
9584 .lock()
9585 .unwrap_or_else(|poisoned| poisoned.into_inner())
9586 .values()
9587 .any(|record| record.managed_lease_id() == Some(lease_id))
9588 {
9589 return Err(failure(
9590 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9591 "managed worktree cleanup is blocked by session environment ownership",
9592 ));
9593 }
9594 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9595 let snapshot = {
9596 let mut registry = self.managed_worktrees.lock()
9597 .unwrap_or_else(|poisoned| poisoned.into_inner());
9598 let current = registry.get_mut(lease_id)
9599 .expect("managed worktree lease remains present");
9600 current.state = ManagedWorktreeLeaseState::Retained;
9601 current.cleanup_failure = None;
9602 current.updated_at_unix_ms = unix_time_ms();
9603 current.snapshot()
9604 };
9605 if self.persist_state().is_err() {
9606 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9607 }
9608 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9609 return Ok(snapshot);
9610 }
9611 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9612 if profile.revision() != &lease.profile_revision {
9613 self.mark_managed_recovery_required(
9614 lease_id,
9615 ManagedWorktreeCleanupFailure::OwnershipConflict,
9616 );
9617 return Err(failure(
9618 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9619 "managed worktree profile revision changed",
9620 ));
9621 }
9622 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9623 self.mark_managed_recovery_required(
9624 lease_id,
9625 ManagedWorktreeCleanupFailure::OwnershipConflict,
9626 );
9627 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9628 })?;
9629 let source_root = self.workspace_root(&lease.source_workspace_id)?;
9630 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9631 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9632 managed_git_worktree_failure(error)
9633 })?;
9634 let target = listed.iter()
9635 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9636 if let Some(target) = target {
9637 if target.is_main || target.is_bare || !exact_owned_worktree(
9638 &lease,
9639 &target.path,
9640 target.branch.as_deref(),
9641 &target.head,
9642 ) {
9643 self.mark_managed_recovery_required(
9644 lease_id,
9645 ManagedWorktreeCleanupFailure::OwnershipConflict,
9646 );
9647 return Err(failure(
9648 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9649 "Git worktree identity no longer matches the managed lease",
9650 ));
9651 }
9652 if target.prunable {
9653 self.mark_managed_cleanup_blocked(
9654 lease_id,
9655 ManagedWorktreeCleanupFailure::Prunable,
9656 );
9657 return Err(failure(
9658 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9659 "managed worktree is prunable and requires recovery",
9660 ));
9661 }
9662 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9663 let failure_kind = match error.kind {
9664 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9665 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9666 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9667 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9668 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9669 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9670 };
9671 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9672 self.mark_managed_recovery_required(lease_id, failure_kind);
9673 } else {
9674 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9675 }
9676 return Err(managed_git_worktree_failure(error));
9677 }
9678 } else {
9679 match std::fs::symlink_metadata(&lease.target_root) {
9680 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9681 Ok(_) => {
9682 self.mark_managed_recovery_required(
9683 lease_id,
9684 ManagedWorktreeCleanupFailure::OwnershipConflict,
9685 );
9686 return Err(failure(
9687 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9688 "managed target exists but is not owned by Git",
9689 ));
9690 }
9691 Err(_) => {
9692 self.mark_managed_cleanup_blocked(
9693 lease_id,
9694 ManagedWorktreeCleanupFailure::Backend,
9695 );
9696 return Err(failure(
9697 NodeFailureCode::BackendOperationFailed,
9698 "managed target identity could not be inspected",
9699 ));
9700 }
9701 }
9702 }
9703 let registered = self.workspaces.read()
9704 .unwrap_or_else(|poisoned| poisoned.into_inner())
9705 .contains_key(&lease.workspace_id);
9706 if registered {
9707 self.unregister_workspace(&lease.workspace_id)?;
9708 }
9709 let removed = {
9710 self.managed_worktrees.lock()
9711 .unwrap_or_else(|poisoned| poisoned.into_inner())
9712 .tombstone(lease_id, unix_time_ms())
9713 .expect("managed worktree lease remains present during cleanup")
9714 };
9715 self.persist_state().map_err(persistence_failure)?;
9716 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9717 Ok(removed.snapshot())
9718 }
9719
9720 fn mark_managed_cleanup_blocked(
9721 &self,
9722 lease_id: &ManagedWorktreeLeaseId,
9723 failure_kind: ManagedWorktreeCleanupFailure,
9724 ) {
9725 let snapshot = {
9726 let mut registry = self.managed_worktrees.lock()
9727 .unwrap_or_else(|poisoned| poisoned.into_inner());
9728 let Some(lease) = registry.get_mut(lease_id) else { return };
9729 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9730 lease.cleanup_failure = Some(failure_kind);
9731 lease.updated_at_unix_ms = unix_time_ms();
9732 lease.snapshot()
9733 };
9734 let _ = self.persist_state();
9735 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9736 }
9737
9738 fn require_worktree_service(
9739 &self,
9740 source_workspace_id: &WorkspaceId,
9741 ) -> Result<(), NodeFailure> {
9742 if self.managed_worktrees.lock()
9743 .unwrap_or_else(|poisoned| poisoned.into_inner())
9744 .lease_for_workspace(source_workspace_id)
9745 .is_some()
9746 {
9747 return Err(failure(
9748 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9749 "managed worktree targets are internal-only workspaces",
9750 ));
9751 }
9752 match self
9753 .worktree_service_modes
9754 .get(source_workspace_id)
9755 .copied()
9756 .unwrap_or(WorktreeServiceMode::Manual)
9757 {
9758 WorktreeServiceMode::Manual => Ok(()),
9759 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
9760 NodeFailureCode::UnsupportedSpawnCapability,
9761 "Git worktree service mode is unavailable in this node build",
9762 )),
9763 }
9764 }
9765
9766 fn managed_reservation_conflict(
9767 &self,
9768 workspace_id: Option<&WorkspaceId>,
9769 root: Option<&str>,
9770 ) -> bool {
9771 self.managed_worktrees.lock()
9772 .unwrap_or_else(|poisoned| poisoned.into_inner())
9773 .active_records()
9774 .any(|lease| {
9775 workspace_id.is_some_and(|workspace_id| {
9776 workspace_id == &lease.source_workspace_id
9777 || workspace_id == &lease.workspace_id
9778 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
9779 })
9780 }
9781
9782 fn reject_managed_reservation(
9783 &self,
9784 workspace_id: Option<&WorkspaceId>,
9785 root: Option<&str>,
9786 ) -> Result<(), NodeFailure> {
9787 if self.managed_reservation_conflict(workspace_id, root) {
9788 Err(failure(
9789 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9790 "managed worktree ownership may only be mutated through its lease cleanup",
9791 ))
9792 } else {
9793 Ok(())
9794 }
9795 }
9796
9797 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
9798 self.validate_address(address)?;
9799 self.workspace_root(&address.workspace_id)
9800 }
9801
9802 fn require_session_runtime_policy(
9836 &self,
9837 address: &SessionAddress,
9838 requirement: ProviderRuntimeRequirement,
9839 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
9840 let runtime_policy = {
9841 let bindings = self
9842 .session_bindings
9843 .lock()
9844 .unwrap_or_else(|poisoned| poisoned.into_inner());
9845 let binding = bindings
9846 .get(&address.session.instance_id)
9847 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
9848 if binding.workspace_id != address.workspace_id {
9849 return Err(failure(
9850 NodeFailureCode::SessionWorkspaceMismatch,
9851 "session belongs to another workspace",
9852 ));
9853 }
9854 if binding.generation != address.session.generation {
9855 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
9856 }
9857 binding.runtime_policy
9858 };
9859 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
9860 let snapshot = self.internal_session_snapshot(address)?;
9861 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
9862 && snapshot.provider.lead_activity != ProviderActivity::Idle
9863 {
9864 return Err(failure(
9865 NodeFailureCode::TurnInFlight,
9866 "session already has a turn in flight; wait for it to complete before sending another prompt",
9867 ));
9868 }
9869 }
9870 require_policy(runtime_policy, false, false, requirement)
9871 .map(|()| runtime_policy)
9872 .map_err(|_| failure(
9873 NodeFailureCode::UnsupportedCapability,
9874 "session runtime policy does not admit this semantic operation",
9875 ))
9876 }
9877
9878 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
9879 let mut bindings = self
9880 .session_bindings
9881 .lock()
9882 .unwrap_or_else(|poisoned| poisoned.into_inner());
9883 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
9884 binding.workspace_id == address.workspace_id
9885 && binding.generation == address.session.generation
9886 }) {
9887 let removed = bindings.remove(&address.session.instance_id);
9888 self.clear_terminal_frame_watermark(address);
9889 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
9890 return removed;
9891 }
9892 None
9893 }
9894
9895 fn cleanup_session_owned_materialization(
9896 &self,
9897 address: &SessionAddress,
9898 binding: &SessionBinding,
9899 ) -> Result<(), NodeFailure> {
9900 let Some(id) = binding.materialization_id.as_ref() else {
9901 return Ok(());
9902 };
9903 let owner = self
9904 .materializations
9905 .lock()
9906 .unwrap_or_else(|poisoned| poisoned.into_inner())
9907 .get(id)
9908 .map(|record| record.owner().clone());
9909 match owner {
9910 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
9911 Some(MaterializationOwner::Session {
9912 incarnation_id,
9913 instance_id,
9914 generation,
9915 }) if incarnation_id == self.incarnation_id
9916 && instance_id == address.session.instance_id
9917 && generation == address.session.generation =>
9918 {
9919 self.cleanup_materialization(id)
9920 }
9921 Some(MaterializationOwner::Session { .. }) => Err(failure(
9922 NodeFailureCode::BackendOperationFailed,
9923 "session environment ownership requires recovery",
9924 )),
9925 }
9926 }
9927
9928 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
9929 let mut watermarks = self
9930 .terminal_frame_watermarks
9931 .lock()
9932 .unwrap_or_else(|poisoned| poisoned.into_inner());
9933 if watermarks
9934 .get(&address.session.instance_id)
9935 .is_some_and(|(current, _)| current == address)
9936 {
9937 watermarks.remove(&address.session.instance_id);
9938 }
9939 }
9940
9941 #[cfg(test)]
9942 fn address_for(
9943 &self,
9944 instance_id: AgentInstanceId,
9945 generation: SessionGeneration,
9946 ) -> Option<SessionAddress> {
9947 let binding = self.session_bindings
9948 .lock()
9949 .unwrap_or_else(|poisoned| poisoned.into_inner())
9950 .get(&instance_id)
9951 .cloned()?;
9952 (binding.generation == generation).then_some(SessionAddress {
9953 workspace_id: binding.workspace_id,
9954 session: SessionKey { instance_id, generation },
9955 })
9956 }
9957
9958 fn arm_resume(
9959 &self,
9960 address: &SessionAddress,
9961 command_id: CommandId,
9962 runtime_policy: ProviderRuntimePolicy,
9963 ) -> Result<(), NodeFailure> {
9964 let mut bindings = self
9965 .session_bindings
9966 .lock()
9967 .unwrap_or_else(|poisoned| poisoned.into_inner());
9968 let binding = bindings
9969 .get_mut(&address.session.instance_id)
9970 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
9971 if binding.workspace_id != address.workspace_id {
9972 return Err(failure(
9973 NodeFailureCode::SessionWorkspaceMismatch,
9974 "session belongs to another workspace",
9975 ));
9976 }
9977 if binding.generation != address.session.generation {
9978 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
9979 }
9980 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
9981 Ok(())
9982 }
9983
9984 fn clear_armed_resume(&self, address: &SessionAddress) {
9985 let mut bindings = self
9986 .session_bindings
9987 .lock()
9988 .unwrap_or_else(|poisoned| poisoned.into_inner());
9989 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
9990 if binding.workspace_id == address.workspace_id
9991 && binding.generation == address.session.generation
9992 {
9993 binding.pending_resume = None;
9994 }
9995 }
9996 }
9997
9998 fn publish_control(&self, event: ControlEvent) {
9999 let revokes_harness_mcp = matches!(
10000 &event.event,
10001 ControlEventKind::Exited { .. }
10002 | ControlEventKind::Failed { .. }
10003 | ControlEventKind::Removed
10004 );
10005 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10006 let mut bindings = self
10007 .session_bindings
10008 .lock()
10009 .unwrap_or_else(|poisoned| poisoned.into_inner());
10010 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10011 return;
10012 };
10013 let previous_address = SessionAddress {
10014 workspace_id: binding.workspace_id.clone(),
10015 session: SessionKey {
10016 instance_id: event.instance_id,
10017 generation: binding.generation,
10018 },
10019 };
10020 let mut resume_command_rejected = false;
10021 if binding.generation == event.generation {
10022 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10023 let rejected_resume = matches!(
10024 &event.event,
10025 ControlEventKind::CommandRejected { .. }
10026 ) && event.command_id == Some(pending_command_id);
10027 let resume_failed = matches!(
10028 &event.event,
10029 ControlEventKind::ResumeDenied { .. }
10030 | ControlEventKind::ResumeFailed { .. }
10031 );
10032 if pending_generation == binding.generation
10033 && (rejected_resume || resume_failed)
10034 {
10035 resume_command_rejected = rejected_resume;
10036 binding.pending_resume = None;
10037 }
10038 }
10039 } else {
10040 let expected = binding.generation.0.checked_add(1);
10041 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10042 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10043 generation == binding.generation
10044 })
10045 || expected != Some(event.generation.0)
10046 || !authorized
10047 {
10048 return;
10049 }
10050 let (_, _, runtime_policy) = binding
10051 .pending_resume
10052 .expect("authorized resume retains its pending runtime policy");
10053 binding.generation = event.generation;
10054 binding.runtime_policy = runtime_policy;
10055 binding.pending_resume = None;
10056 }
10057 (
10058 SessionAddress {
10059 workspace_id: binding.workspace_id.clone(),
10060 session: SessionKey {
10061 instance_id: event.instance_id,
10062 generation: event.generation,
10063 },
10064 },
10065 (previous_address.session.generation != event.generation)
10066 .then_some(previous_address),
10067 binding.record_id.clone(),
10068 binding.runtime_policy.provider_session_identity,
10069 resume_command_rejected,
10070 )
10071 };
10072 if let Some(previous_address) = previous_address.as_ref() {
10073 self.clear_terminal_frame_watermark(previous_address);
10074 }
10075 if let Some(record_id) = record_id {
10076 self.reconcile_managed_record(
10077 &record_id,
10078 &address,
10079 &event,
10080 identity_admitted,
10081 resume_command_rejected,
10082 );
10083 }
10084 match &event.event {
10092 ControlEventKind::Running { process_id } => {
10093 tracing::info!(
10094 node_id = %self.node_id,
10095 workspace_id = %address.workspace_id,
10096 session = ?address.session,
10097 pid = ?process_id,
10098 "session process is running",
10099 );
10100 }
10101 ControlEventKind::Exited { exit_code, forced } => {
10102 tracing::info!(
10103 node_id = %self.node_id,
10104 workspace_id = %address.workspace_id,
10105 session = ?address.session,
10106 exit_code = ?exit_code,
10107 forced = forced,
10108 "session process exited",
10109 );
10110 }
10111 ControlEventKind::Failed { message } => {
10112 tracing::warn!(
10113 node_id = %self.node_id,
10114 workspace_id = %address.workspace_id,
10115 session = ?address.session,
10116 cause = %message,
10117 "session failed",
10118 );
10119 }
10120 ControlEventKind::CommandRejected { message } => {
10121 tracing::warn!(
10122 node_id = %self.node_id,
10123 workspace_id = %address.workspace_id,
10124 session = ?address.session,
10125 cause = %message,
10126 "session command rejected",
10127 );
10128 }
10129 ControlEventKind::Removed => {
10130 tracing::info!(
10131 node_id = %self.node_id,
10132 workspace_id = %address.workspace_id,
10133 session = ?address.session,
10134 "session removed",
10135 );
10136 }
10137 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10138 tracing::info!(
10139 node_id = %self.node_id,
10140 workspace_id = %address.workspace_id,
10141 session = ?address.session,
10142 provider_session = ?provider_session,
10143 "session resume authorized",
10144 );
10145 }
10146 ControlEventKind::Resumed { session: provider_session, process_id } => {
10147 tracing::info!(
10148 node_id = %self.node_id,
10149 workspace_id = %address.workspace_id,
10150 session = ?address.session,
10151 provider_session = ?provider_session,
10152 pid = ?process_id,
10153 "session resumed",
10154 );
10155 }
10156 ControlEventKind::ResumeDenied { reason } => {
10157 tracing::warn!(
10158 node_id = %self.node_id,
10159 workspace_id = %address.workspace_id,
10160 session = ?address.session,
10161 cause = %reason,
10162 "session resume denied",
10163 );
10164 }
10165 ControlEventKind::ResumeFailed { message } => {
10166 tracing::warn!(
10167 node_id = %self.node_id,
10168 workspace_id = %address.workspace_id,
10169 session = ?address.session,
10170 cause = %message,
10171 "session resume failed",
10172 );
10173 }
10174 _ => {}
10175 }
10176 let stream_chunk = agent_stream_chunk(&event);
10177 self.publish(NodeEvent::Control {
10178 address: address.clone(),
10179 event,
10180 });
10181 if let Some(chunk) = stream_chunk {
10182 self.publish_agent_stream_chunk(address.clone(), chunk);
10183 }
10184 if revokes_harness_mcp {
10185 SessionLifecycleHook::on_session_ended(self, &address);
10186 }
10187 }
10188
10189 fn reconcile_managed_record(
10190 &self,
10191 record_id: &SessionRecordId,
10192 address: &SessionAddress,
10193 event: &ControlEvent,
10194 identity_admitted: bool,
10195 resume_command_rejected: bool,
10196 ) {
10197 let _transaction = self
10198 .state_transaction
10199 .lock()
10200 .unwrap_or_else(|poisoned| poisoned.into_inner());
10201 let observed_identity = match &event.event {
10202 ControlEventKind::ProviderEvent {
10203 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10204 ..
10205 } if identity_admitted => Some(identity.clone()),
10206 _ => None,
10207 };
10208 let identity_was_observed = observed_identity.is_some();
10209 let replacement_id = observed_identity
10210 .as_ref()
10211 .and_then(|_| self.allocate_record_id().ok());
10212 let mut upserts = Vec::new();
10213 let mut removals = Vec::new();
10214 let mut rebind = None;
10215 let original_records;
10216 let original_materializations = self
10217 .materializations
10218 .lock()
10219 .unwrap_or_else(|poisoned| poisoned.into_inner())
10220 .clone();
10221 {
10222 let mut records = self
10223 .session_records
10224 .lock()
10225 .unwrap_or_else(|poisoned| poisoned.into_inner());
10226 original_records = records.records.clone();
10227 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10228 return;
10229 };
10230 if let Some(identity) = observed_identity {
10231 let existing_id = records
10232 .records
10233 .values()
10234 .find(|record| {
10235 record.record_id != *record_id
10236 && record.provider_session.as_ref().is_some_and(|record_identity| {
10237 session_registry::same_provider_session(
10238 &record.provider,
10239 record_identity,
10240 ¤t_snapshot.provider,
10241 &identity,
10242 )
10243 })
10244 })
10245 .map(|record| record.record_id.clone());
10246 if let Some(existing_id) = existing_id {
10247 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10248 record.mode == current_snapshot.mode
10249 && record.workspace_id == current_snapshot.workspace_id
10250 && record.environment_profile == current_snapshot.environment_profile
10251 && record.bundle == current_snapshot.bundle
10252 && record.context_id == current_snapshot.context_id
10253 && record.context == current_snapshot.context
10254 && record
10255 .canonical_root
10256 .as_utf8()
10257 .zip(current_snapshot.canonical_root.as_utf8())
10258 .is_some_and(|(left, right)| platform::roots_equal(left, right))
10259 });
10260 if !scope_matches {
10261 if let Some(current) = records.records.get_mut(record_id) {
10262 current.state = ManagedSessionState::Unavailable;
10263 current.last_error =
10264 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10265 current.updated_at_unix_ms = unix_time_ms();
10266 upserts.push(current.clone());
10267 }
10268 } else {
10269 let existing_is_active = records
10270 .records
10271 .get(&existing_id)
10272 .is_some_and(|record| record.active_session.is_some());
10273 if existing_is_active {
10274 if let Some(current) = records.records.get_mut(record_id) {
10275 current.state = ManagedSessionState::Unavailable;
10276 current.last_error =
10277 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10278 current.updated_at_unix_ms = unix_time_ms();
10279 upserts.push(current.clone());
10280 }
10281 } else {
10282 if current_snapshot.provider_session.is_some() {
10283 if let Some(current) = records.records.get_mut(record_id) {
10284 current.active_session = None;
10285 current.state = ManagedSessionState::Dormant;
10286 current.updated_at_unix_ms = unix_time_ms();
10287 upserts.push(current.clone());
10288 }
10289 } else {
10290 records.records.remove(record_id);
10291 removals.push(record_id.clone());
10292 }
10293 if let Some(existing) = records.records.get_mut(&existing_id) {
10294 existing.provider_session = Some(identity.clone());
10295 existing.active_session = Some(address.clone());
10296 existing.state = ManagedSessionState::Live;
10297 existing.updated_at_unix_ms = unix_time_ms();
10298 existing.last_error = None;
10299 upserts.push(existing.clone());
10300 }
10301 rebind = Some(existing_id);
10302 }
10303 }
10304 } else if current_snapshot
10305 .provider_session
10306 .as_ref()
10307 .is_some_and(|current| {
10308 !session_registry::same_provider_session(
10309 ¤t_snapshot.provider,
10310 current,
10311 ¤t_snapshot.provider,
10312 &identity,
10313 )
10314 })
10315 {
10316 if let Some(current) = records.records.get_mut(record_id) {
10317 current.active_session = None;
10318 if current.bundle.is_some() || current.context.is_some() {
10319 let had_bundle = current.bundle.is_some();
10320 current.provider_session = None;
10321 current.bundle = None;
10322 current.context_id = None;
10323 current.context = None;
10324 current.state = ManagedSessionState::Unavailable;
10325 current.last_error = Some(if had_bundle {
10326 "bundle-unavailable".to_owned()
10327 } else {
10328 "context-unavailable".to_owned()
10329 });
10330 } else {
10331 current.state = ManagedSessionState::Dormant;
10332 }
10333 current.updated_at_unix_ms = unix_time_ms();
10334 upserts.push(current.clone());
10335 }
10336 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10337 if let Some(current) = records.records.get_mut(record_id) {
10338 current.state = ManagedSessionState::Unavailable;
10339 current.last_error =
10340 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10341 current.updated_at_unix_ms = unix_time_ms();
10342 upserts.push(current.clone());
10343 }
10344 } else if let Some(new_record_id) = replacement_id {
10345 let now = unix_time_ms();
10346 let replacement = ManagedSessionRecord {
10347 record_id: new_record_id.clone(),
10348 display_name: format!(
10349 "{} #{}",
10350 current_snapshot.provider.as_str(),
10351 records
10352 .records
10353 .values()
10354 .filter(|record| {
10355 record.provider == current_snapshot.provider
10356 })
10357 .count()
10358 .saturating_add(1),
10359 ),
10360 provider: current_snapshot.provider.clone(),
10361 mode: current_snapshot.mode,
10362 state: ManagedSessionState::Live,
10363 workspace_id: current_snapshot.workspace_id,
10364 canonical_root: current_snapshot.canonical_root,
10365 provider_session: Some(identity),
10366 active_session: Some(address.clone()),
10367 environment_profile: current_snapshot.environment_profile,
10368 bundle: current_snapshot.bundle,
10369 context_id: current_snapshot.context_id,
10370 context: current_snapshot.context,
10371 exported_context: None,
10377 task_binding: current_snapshot.task_binding,
10378 created_at_unix_ms: now,
10379 updated_at_unix_ms: now,
10380 last_error: None,
10381 };
10382 records
10383 .records
10384 .insert(new_record_id.clone(), replacement.clone());
10385 upserts.push(replacement);
10386 rebind = Some(new_record_id);
10387 } else if let Some(current) = records.records.get_mut(record_id) {
10388 current.state = ManagedSessionState::Unavailable;
10389 current.last_error =
10390 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10391 upserts.push(current.clone());
10392 }
10393 } else if let Some(current) = records.records.get_mut(record_id) {
10394 current.provider_session = Some(identity);
10395 current.active_session = Some(address.clone());
10396 current.state = ManagedSessionState::Live;
10397 current.updated_at_unix_ms = unix_time_ms();
10398 current.last_error = None;
10399 upserts.push(current.clone());
10400 }
10401 } else if let Some(current) = records.records.get_mut(record_id) {
10402 if resume_command_rejected {
10403 current.active_session = None;
10404 current.state = detached_record_state(current);
10405 current.updated_at_unix_ms = unix_time_ms();
10406 current.last_error =
10407 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10408 upserts.push(current.clone());
10409 } else {
10410 match &event.event {
10411 ControlEventKind::Running { .. }
10412 | ControlEventKind::ResumeAuthorized { .. }
10413 | ControlEventKind::Resumed { .. } => {
10414 current.active_session = Some(address.clone());
10415 current.state = active_record_state(
10416 identity_admitted,
10417 current.provider_session.is_some(),
10418 );
10419 current.updated_at_unix_ms = unix_time_ms();
10420 current.last_error = None;
10421 upserts.push(current.clone());
10422 }
10423 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10424 current.active_session = None;
10425 current.state = detached_record_state(current);
10426 current.updated_at_unix_ms = unix_time_ms();
10427 upserts.push(current.clone());
10428 }
10429 ControlEventKind::Failed { message }
10430 | ControlEventKind::ResumeFailed { message } => {
10431 current.active_session = None;
10432 current.state = detached_record_state(current);
10433 current.updated_at_unix_ms = unix_time_ms();
10434 current.last_error = Some(
10435 session_registry::sanitized_record_error_summary(message),
10436 );
10437 upserts.push(current.clone());
10438 }
10439 ControlEventKind::ResumeDenied { reason } => {
10440 current.active_session = None;
10441 current.state = detached_record_state(current);
10442 current.updated_at_unix_ms = unix_time_ms();
10443 current.last_error = Some(
10444 session_registry::sanitized_record_error_summary(reason),
10445 );
10446 upserts.push(current.clone());
10447 }
10448 _ => {}
10449 }
10450 }
10451 }
10452 }
10453 let materialization_owner_record = identity_was_observed.then(|| {
10454 rebind.clone().unwrap_or_else(|| record_id.clone())
10455 }).and_then(|candidate| {
10456 self.session_records
10457 .lock()
10458 .unwrap_or_else(|poisoned| poisoned.into_inner())
10459 .records
10460 .get(&candidate)
10461 .filter(|record| {
10462 record.provider_session.is_some()
10463 && record.active_session.as_ref() == Some(address)
10464 })
10465 .map(|_| candidate)
10466 });
10467 if let Some(owner_record_id) = materialization_owner_record {
10468 let materialization_id = self
10469 .session_bindings
10470 .lock()
10471 .unwrap_or_else(|poisoned| poisoned.into_inner())
10472 .get(&address.session.instance_id)
10473 .and_then(|binding| binding.materialization_id.clone());
10474 if let Some(materialization_id) = materialization_id {
10475 let mut transfer_failed = false;
10476 {
10477 let mut materializations = self
10478 .materializations
10479 .lock()
10480 .unwrap_or_else(|poisoned| poisoned.into_inner());
10481 if let Some(ownership) = materializations.get_mut(&materialization_id) {
10482 match ownership.owner() {
10483 MaterializationOwner::Session {
10484 incarnation_id,
10485 instance_id,
10486 generation,
10487 } if *incarnation_id == self.incarnation_id
10488 && *instance_id == address.session.instance_id
10489 && *generation == address.session.generation =>
10490 {
10491 transfer_failed = ownership
10492 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10493 .is_err();
10494 }
10495 MaterializationOwner::Record { record_id }
10496 if record_id == &owner_record_id => {}
10497 MaterializationOwner::Record { record_id } => {
10498 let expected = record_id.clone();
10499 transfer_failed = ownership
10500 .transfer_record_owner(
10501 &expected,
10502 owner_record_id.clone(),
10503 unix_time_ms(),
10504 )
10505 .is_err();
10506 }
10507 _ => transfer_failed = true,
10508 }
10509 if transfer_failed {
10510 let _ = ownership.mark_recovery_required(unix_time_ms());
10511 }
10512 } else {
10513 transfer_failed = true;
10514 }
10515 }
10516 if transfer_failed {
10517 if let Some(record) = self
10518 .session_records
10519 .lock()
10520 .unwrap_or_else(|poisoned| poisoned.into_inner())
10521 .records
10522 .get_mut(&owner_record_id)
10523 {
10524 record.state = ManagedSessionState::Unavailable;
10525 record.last_error = Some("environment-profile-unavailable".to_owned());
10526 record.updated_at_unix_ms = unix_time_ms();
10527 upserts.push(record.clone());
10528 }
10529 }
10530 }
10531 }
10532 if let Some(new_record_id) = rebind.clone() {
10533 if let Some(binding) = self
10534 .session_bindings
10535 .lock()
10536 .unwrap_or_else(|poisoned| poisoned.into_inner())
10537 .get_mut(&address.session.instance_id)
10538 {
10539 binding.record_id = Some(new_record_id);
10540 }
10541 }
10542 if upserts.is_empty() && removals.is_empty() {
10543 return;
10544 }
10545 if self.persist_state_locked().is_err() {
10546 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10547 let mut records = self
10548 .session_records
10549 .lock()
10550 .unwrap_or_else(|poisoned| poisoned.into_inner());
10551 records.records = original_records;
10552 *self
10553 .materializations
10554 .lock()
10555 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10556 if let Some(current) = records.records.get_mut(record_id) {
10557 current.state = ManagedSessionState::Unavailable;
10558 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10559 current.updated_at_unix_ms = unix_time_ms();
10560 upserts = vec![current.clone()];
10561 } else {
10562 upserts.clear();
10563 }
10564 removals.clear();
10565 drop(records);
10566 if let Some(binding) = self
10567 .session_bindings
10568 .lock()
10569 .unwrap_or_else(|poisoned| poisoned.into_inner())
10570 .get_mut(&address.session.instance_id)
10571 {
10572 binding.record_id = Some(record_id.clone());
10573 }
10574 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10575 }
10576 for record in upserts {
10577 self.publish_record(record);
10578 }
10579 for record_id in removals {
10580 self.publish(NodeEvent::SessionRecordRemoved { record_id });
10581 }
10582 }
10583
10584 fn snapshot(&self) -> NodeSnapshot {
10585 let control = self.handle.snapshot();
10586 let bindings = self
10587 .session_bindings
10588 .lock()
10589 .unwrap_or_else(|poisoned| poisoned.into_inner())
10590 .clone();
10591 let workspace_roots = self
10592 .workspaces
10593 .read()
10594 .unwrap_or_else(|poisoned| poisoned.into_inner())
10595 .clone();
10596 let environment_profiles = self
10597 .environment_profiles
10598 .read()
10599 .unwrap_or_else(|poisoned| poisoned.into_inner());
10600 let mut workspaces = workspace_roots
10601 .iter()
10602 .map(|(workspace_id, canonical_root)| {
10603 (
10604 workspace_id.clone(),
10605 WorkspaceSnapshot {
10606 workspace_id: workspace_id.clone(),
10607 canonical_root: opaque_windows_path(canonical_root.clone()),
10608 sessions: Vec::new(),
10609 worktree_service_mode: Some(
10610 self.worktree_service_modes
10611 .get(workspace_id)
10612 .copied()
10613 .unwrap_or(WorktreeServiceMode::Manual),
10614 ),
10615 managed_worktree_profiles: Some(WorktreeProfileInventory {
10616 profiles: self
10617 .managed_worktree_profiles
10618 .get(workspace_id)
10619 .into_iter()
10620 .flat_map(BTreeMap::values)
10621 .map(|profile| ManagedWorktreeProfileSummary {
10622 id: profile.profile_id().clone(),
10623 revision: profile.revision().clone(),
10624 retention: profile.retention(),
10625 })
10626 .collect(),
10627 }),
10628 },
10629 )
10630 })
10631 .collect::<BTreeMap<_, _>>();
10632 let mut agent_progress = Vec::new();
10633 for session in &control.sessions {
10634 let Some(binding) = bindings.get(&session.instance_id) else {
10635 continue;
10636 };
10637 if binding.generation != session.generation {
10638 continue;
10639 }
10640 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10641 let address = SessionAddress {
10642 workspace_id: binding.workspace_id.clone(),
10643 session: SessionKey {
10644 instance_id: session.instance_id,
10645 generation: session.generation,
10646 },
10647 };
10648 if let Some(progress) =
10649 agent_progress_from_provider_snapshot(address, &session.provider)
10650 {
10651 agent_progress.push(progress);
10652 }
10653 workspace.sessions.push(session.clone());
10654 }
10655 }
10656 NodeSnapshot {
10657 node_id: self.node_id.clone(),
10658 enabled_providers: self.enabled_providers.clone(),
10659 provider_runtime_statuses: {
10660 let updates = self
10661 .provider_runtime_status_updates
10662 .read()
10663 .unwrap_or_else(|poisoned| poisoned.into_inner());
10664 ProviderRuntimeStatuses::new(
10665 self.provider_runtime_statuses
10666 .iter()
10667 .filter(|status| !updates.contains_key(status.provider()))
10668 .cloned()
10669 .chain(updates.values().cloned()),
10670 )
10671 .expect("refreshed provider runtime inventory remains bounded and unique")
10672 },
10673 workspaces: workspaces.into_values().collect(),
10674 session_records: self
10675 .session_records
10676 .lock()
10677 .unwrap_or_else(|poisoned| poisoned.into_inner())
10678 .records
10679 .values()
10680 .cloned()
10681 .collect(),
10682 managed_worktrees: self.managed_worktrees
10683 .lock()
10684 .unwrap_or_else(|poisoned| poisoned.into_inner())
10685 .snapshots(),
10686 launch_inventory: Some(LaunchInventory {
10687 spawn_profiles: Some(
10688 self.spawn_profiles
10689 .iter()
10690 .filter_map(|profile| {
10691 resolved_spawn_profile_summary(profile, &environment_profiles)
10692 })
10693 .collect(),
10694 ),
10695 bundles: Some(
10696 self.bundle_catalog
10697 .read()
10698 .unwrap_or_else(|poisoned| poisoned.into_inner())
10699 .iter()
10700 .map(NodeBundle::receipt)
10701 .collect(),
10702 ),
10703 network_allowlists: {
10706 let catalog = self
10707 .network_allowlist_catalog
10708 .read()
10709 .unwrap_or_else(|poisoned| poisoned.into_inner());
10710 if catalog.is_empty() {
10711 None
10712 } else {
10713 Some(catalog.ids().cloned().collect())
10714 }
10715 },
10716 }),
10717 agent_progress,
10718 }
10719 }
10720
10721 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
10722 assert!(
10723 !matches!(
10724 &event,
10725 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
10726 ),
10727 "terminal frame and agent stream events must use their own dedicated live channel",
10728 );
10729 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10730 let sequence = history
10731 .last_sequence
10732 .checked_add(1)
10733 .expect("node event sequence exhausted");
10734 if history.events.len() == NODE_EVENT_HISTORY_MAX {
10735 if let Some(evicted) = history.events.pop_front() {
10736 history.replay_floor_sequence = evicted
10737 .sequence
10738 .checked_add(1)
10739 .expect("node replay floor sequence exhausted");
10740 history.removed_record_providers.remove(&evicted.sequence);
10741 }
10742 }
10743 match &event {
10744 NodeEvent::HarnessMcpReadCall { .. } => {
10745 panic!("transient harness MCP calls must not enter durable event history")
10746 }
10747 NodeEvent::SessionRecordUpserted { record } => {
10748 history
10749 .record_providers
10750 .insert(record.record_id.clone(), record.provider.clone());
10751 }
10752 NodeEvent::SessionRecordRemoved { record_id } => {
10753 if let Some(provider) = history.record_providers.remove(record_id) {
10754 history.removed_record_providers.insert(sequence, provider);
10755 }
10756 }
10757 NodeEvent::ManagedWorktreeUpserted { .. }
10758 | NodeEvent::ManagedWorktreeRemoved { .. }
10759 | NodeEvent::Control { .. }
10760 | NodeEvent::ControllerChanged { .. }
10761 | NodeEvent::SessionRecordHistorySummarized { .. }
10762 | NodeEvent::WorkspaceAdded { .. }
10763 | NodeEvent::WorkspaceRemoved { .. }
10764 | NodeEvent::ResyncRequired { .. }
10765 | NodeEvent::AgentStream { .. }
10766 | NodeEvent::TerminalFrame { .. } => {}
10767 }
10768 let envelope = NodeEventEnvelope { sequence, event };
10769 history.events.push_back(envelope.clone());
10770 history.last_sequence = sequence;
10771 let _ = self.event_tx.send(envelope.clone());
10772 drop(history);
10773 envelope
10774 }
10775
10776 fn publish_transient(&self, event: NodeEvent) -> bool {
10787 assert!(event.requires_harness_mcp_proxy_capability());
10788 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
10789 let envelope = NodeEventEnvelope { sequence: 0, event };
10790 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
10791 Ok(_) => true,
10792 Err(broadcast::error::SendError(undelivered)) => {
10793 if let Some(NodeEventEnvelope {
10794 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
10795 ..
10796 }) = undelivered.first() {
10797 tracing::warn!(
10803 call_id = call_id.as_str(),
10804 content_type = ?request.content_type,
10805 cause = "no-subscribers",
10806 "harness MCP proxy event could not be published",
10807 );
10808 }
10809 false
10810 }
10811 }
10812 }
10813
10814 fn publish_terminal_frames(&self) {
10815 let snapshot = self.handle.snapshot();
10816 let bindings = self
10817 .session_bindings
10818 .lock()
10819 .unwrap_or_else(|poisoned| poisoned.into_inner());
10820 let candidates = snapshot
10821 .sessions
10822 .iter()
10823 .filter_map(|session| {
10824 let binding = bindings.get(&session.instance_id)?;
10825 if binding.generation != session.generation {
10826 return None;
10827 }
10828 let frame = session.terminal_frame.clone()?;
10829 Some((
10830 SessionAddress {
10831 workspace_id: binding.workspace_id.clone(),
10832 session: SessionKey {
10833 instance_id: session.instance_id,
10834 generation: session.generation,
10835 },
10836 },
10837 frame,
10838 ))
10839 })
10840 .collect();
10841 drop(bindings);
10842 self.publish_terminal_frame_candidates(candidates);
10843 }
10844
10845 fn publish_terminal_frame_candidates(
10846 &self,
10847 candidates: Vec<(SessionAddress, TerminalFrame)>,
10848 ) {
10849 let mut watermarks = self
10850 .terminal_frame_watermarks
10851 .lock()
10852 .unwrap_or_else(|poisoned| poisoned.into_inner());
10853 let mut events = Vec::with_capacity(candidates.len());
10854 for (address, frame) in candidates {
10855 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
10856 let advanced = watermarks
10857 .get(&address.session.instance_id)
10858 .map_or(true, |(current_address, sequence)| {
10859 current_address != &address || frame.sequence > *sequence
10860 });
10861 if !advanced {
10862 continue;
10863 }
10864 if is_first_frame {
10865 if self.controller_state().is_none() {
10873 tracing::warn!(
10874 node_id = %self.node_id,
10875 workspace_id = %address.workspace_id,
10876 session = ?address.session,
10877 terminal_size = ?frame.size,
10878 "session producing output with no controller attached",
10879 );
10880 } else {
10881 tracing::info!(
10882 node_id = %self.node_id,
10883 workspace_id = %address.workspace_id,
10884 session = ?address.session,
10885 terminal_size = ?frame.size,
10886 "session produced its first output",
10887 );
10888 }
10889 }
10890 watermarks.insert(
10891 address.session.instance_id,
10892 (address.clone(), frame.sequence),
10893 );
10894 events.push(NodeEvent::TerminalFrame { address, frame });
10895 }
10896 drop(watermarks);
10897 if events.is_empty() {
10898 return;
10899 }
10900 let mut history = self
10901 .history
10902 .lock()
10903 .unwrap_or_else(|poisoned| poisoned.into_inner());
10904 let mut envelopes = Vec::with_capacity(events.len());
10905 for event in events {
10906 let sequence = history
10907 .last_sequence
10908 .checked_add(1)
10909 .expect("node event sequence exhausted");
10910 history.last_sequence = sequence;
10911 envelopes.push(NodeEventEnvelope { sequence, event });
10912 }
10913 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
10914 drop(history);
10915 }
10916
10917 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
10926 let mut history = self
10927 .history
10928 .lock()
10929 .unwrap_or_else(|poisoned| poisoned.into_inner());
10930 let sequence = history
10931 .last_sequence
10932 .checked_add(1)
10933 .expect("node event sequence exhausted");
10934 history.last_sequence = sequence;
10935 drop(history);
10936 tracing::debug!(
10944 source_sequence = chunk.source_sequence,
10945 kind = agent_stream_chunk_kind_label(&chunk.kind),
10946 envelope_sequence = sequence,
10947 "publishing agent stream chunk",
10948 );
10949 let envelope = NodeEventEnvelope {
10950 sequence,
10951 event: NodeEvent::AgentStream { address, chunk },
10952 };
10953 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
10954 }
10955
10956 fn current_sequence(&self) -> u64 {
10957 self.history
10958 .lock()
10959 .unwrap_or_else(|poisoned| poisoned.into_inner())
10960 .last_sequence
10961 }
10962
10963 fn controller_state(&self) -> Option<ControllerState> {
10964 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10965 let lease = controller.as_ref().copied()?;
10966 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
10967 if remaining.is_zero() {
10968 *controller = None;
10969 return None;
10970 }
10971 Some(ControllerState {
10972 connection_id: lease.connection_id,
10973 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
10974 })
10975 }
10976
10977 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
10978 if role != ClientRole::Operator {
10979 tracing::warn!(
10980 node_id = %self.node_id,
10981 connection_id,
10982 "controller lease rejected: observer connections cannot acquire control",
10983 );
10984 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
10985 }
10986 let lease_ms = if lease_ms == 0 {
10987 DEFAULT_CONTROLLER_LEASE_MS
10988 } else {
10989 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
10990 };
10991 let now = Instant::now();
10992 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10993 if let Some(active) = controller.as_ref() {
10994 if active.connection_id != connection_id && active.expires_at > now {
10995 tracing::warn!(
10996 node_id = %self.node_id,
10997 connection_id,
10998 holder_connection_id = active.connection_id,
10999 "controller lease rejected: another operator holds it",
11000 );
11001 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11002 }
11003 }
11004 let is_new_attach = match controller.as_ref() {
11011 Some(active) => active.connection_id != connection_id,
11012 None => true,
11013 };
11014 *controller = Some(ControllerLease {
11015 connection_id,
11016 expires_at: now + Duration::from_millis(lease_ms),
11017 });
11018 drop(controller);
11019 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11020 if is_new_attach {
11021 tracing::info!(
11022 node_id = %self.node_id,
11023 connection_id,
11024 lease_ms,
11025 "controller lease acquired",
11026 );
11027 } else {
11028 tracing::debug!(
11029 node_id = %self.node_id,
11030 connection_id,
11031 lease_ms,
11032 "controller lease renewed",
11033 );
11034 }
11035 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11036 Ok(state)
11037 }
11038
11039 fn release_controller(&self, connection_id: u64) -> bool {
11040 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11041 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11042 if released {
11043 *controller = None;
11044 }
11045 drop(controller);
11046 if released {
11047 tracing::info!(
11048 node_id = %self.node_id,
11049 connection_id,
11050 "controller lease released",
11051 );
11052 self.publish(NodeEvent::ControllerChanged { controller: None });
11053 }
11054 released
11055 }
11056
11057 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11058 if role != ClientRole::Operator {
11059 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11060 }
11061 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11062 let now = Instant::now();
11063 match controller.as_ref().copied() {
11064 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11065 Some(active) if active.expires_at <= now => {
11066 *controller = None;
11067 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11068 }
11069 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11070 }
11071 }
11072
11073 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11074 if !self
11075 .workspaces
11076 .read()
11077 .unwrap_or_else(|poisoned| poisoned.into_inner())
11078 .contains_key(&address.workspace_id)
11079 {
11080 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11081 }
11082 let binding = self
11083 .session_bindings
11084 .lock()
11085 .unwrap_or_else(|poisoned| poisoned.into_inner())
11086 .get(&address.session.instance_id)
11087 .cloned()
11088 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11089 if binding.workspace_id != address.workspace_id {
11090 return Err(failure(
11091 NodeFailureCode::SessionWorkspaceMismatch,
11092 "session belongs to another workspace",
11093 ));
11094 }
11095 if binding.generation != address.session.generation {
11096 return Err(failure(
11097 NodeFailureCode::StaleGeneration,
11098 &format!(
11099 "stale session generation: expected {}, received {}",
11100 binding.generation.0,
11101 address.session.generation.0,
11102 ),
11103 ));
11104 }
11105 let snapshot = self.handle.snapshot();
11106 let Some(current) = snapshot
11107 .sessions
11108 .iter()
11109 .find(|item| item.instance_id == address.session.instance_id)
11110 else {
11111 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11112 };
11113 if current.generation != address.session.generation {
11114 return Err(failure(
11115 NodeFailureCode::StaleGeneration,
11116 &format!(
11117 "stale session generation: expected {}, received {}",
11118 current.generation.0,
11119 address.session.generation.0,
11120 ),
11121 ));
11122 }
11123 Ok(current.agent_id.clone())
11124 }
11125
11126 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11127 self.dispatch_envelope(self.prepare_command(command))
11128 }
11129
11130 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11131 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11132 CommandEnvelope {
11133 id: CommandId(id),
11134 command,
11135 }
11136 }
11137
11138 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11139 let command_id = envelope.id;
11140 self.handle
11141 .dispatch(envelope)
11142 .map_err(|error| match error {
11143 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11144 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11145 })?;
11146 Ok(command_id)
11147 }
11148
11149 fn record_rejected_command(
11158 &self,
11159 command_id: CommandId,
11160 message: String,
11161 unsupported_transport: bool,
11162 ) {
11163 let now = Instant::now();
11164 let mut reasons = self.rejected_command_reasons.lock()
11165 .unwrap_or_else(|poisoned| poisoned.into_inner());
11166 reasons.retain(|_, reason| reason.expires_at > now);
11167 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11168 if let Some(&oldest) = reasons.keys().next() {
11169 reasons.remove(&oldest);
11170 }
11171 }
11172 reasons.insert(
11173 command_id,
11174 RejectedCommandReason {
11175 message,
11176 unsupported_transport,
11177 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11178 },
11179 );
11180 }
11181
11182 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11189 let now = Instant::now();
11190 let mut reasons = self.rejected_command_reasons.lock()
11191 .unwrap_or_else(|poisoned| poisoned.into_inner());
11192 match reasons.remove(&command_id) {
11193 Some(reason) if reason.expires_at > now => Some(reason),
11194 _ => None,
11195 }
11196 }
11197
11198 async fn dispatch_bounded(
11199 &self,
11200 command: ControlCommand,
11201 timeout_duration: Duration,
11202 ) -> Result<CommandId, NodeFailure> {
11203 let deadline = Instant::now() + timeout_duration;
11204 loop {
11205 match self.dispatch(command.clone()) {
11206 Ok(command_id) => return Ok(command_id),
11207 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11208 sleep(Duration::from_millis(2)).await;
11209 }
11210 Err(error) => return Err(error),
11211 }
11212 }
11213 }
11214
11215 fn internal_session_snapshot(
11216 &self,
11217 address: &SessionAddress,
11218 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11219 self.validate_address(address)?;
11220 self.handle
11221 .snapshot()
11222 .sessions
11223 .clone()
11224 .into_iter()
11225 .find(|session| {
11226 session.instance_id == address.session.instance_id
11227 && session.generation == address.session.generation
11228 })
11229 .ok_or_else(|| {
11230 failure(
11231 NodeFailureCode::UnknownSession,
11232 "session snapshot is unavailable",
11233 )
11234 })
11235 }
11236
11237 fn require_acp_session(
11245 &self,
11246 address: &SessionAddress,
11247 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11248 let snapshot = self.internal_session_snapshot(address)?;
11249 if snapshot.transport != TransportKind::Acp {
11250 return Err(failure(
11251 NodeFailureCode::UnsupportedTransport,
11252 "this verb requires a session spawned over the ACP transport",
11253 ));
11254 }
11255 Ok(snapshot)
11256 }
11257
11258 async fn dispatch_history_bounded(
11259 &self,
11260 address: &SessionAddress,
11261 command: ControlCommand,
11262 operation: HistoryOperation,
11263 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11264 self.validate_address(address)?;
11265 let started_after = self.current_sequence();
11266 let command_id = self
11267 .dispatch_bounded(
11268 command,
11269 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11270 )
11271 .await?;
11272 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11273 let mut scan_after = started_after;
11274 let mut accepted_after = None;
11275 loop {
11276 let events = self
11277 .history
11278 .lock()
11279 .unwrap_or_else(|poisoned| poisoned.into_inner())
11280 .events
11281 .iter()
11282 .filter(|envelope| envelope.sequence > scan_after)
11283 .cloned()
11284 .collect::<Vec<_>>();
11285 for envelope in events {
11286 scan_after = scan_after.max(envelope.sequence);
11287 let NodeEvent::Control {
11288 address: event_address,
11289 event,
11290 } = envelope.event
11291 else {
11292 continue;
11293 };
11294 if event_address != *address {
11295 continue;
11296 }
11297 if event.command_id == Some(command_id) {
11298 match &event.event {
11299 ControlEventKind::CommandRejected { .. } => {
11300 return Err(failure(
11301 NodeFailureCode::InvalidRequest,
11302 "history request was rejected",
11303 ));
11304 }
11305 ControlEventKind::HistoryRequested {
11306 operation: requested,
11307 ..
11308 } if requested == &operation => {
11309 accepted_after = Some(envelope.sequence);
11310 continue;
11311 }
11312 _ => {}
11313 }
11314 }
11315 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11316 match (&operation, &event.event) {
11317 (
11318 HistoryOperation::Discover { .. },
11319 ControlEventKind::HistoryDiscovered { .. },
11320 )
11321 | (
11322 HistoryOperation::Load { .. },
11323 ControlEventKind::HistoryLoaded { .. },
11324 ) => return self.internal_session_snapshot(address),
11325 (_, ControlEventKind::HistoryFailed { .. }) => {
11326 return Err(failure(
11327 NodeFailureCode::BackendOperationFailed,
11328 "history operation failed",
11329 ));
11330 }
11331 _ => {}
11332 }
11333 }
11334 }
11335 if Instant::now() >= deadline {
11336 return Err(failure(
11337 NodeFailureCode::BackendBusy,
11338 if accepted_after.is_some() {
11339 "history operation did not settle before the bounded deadline"
11340 } else {
11341 "history operation was not accepted before the bounded deadline"
11342 },
11343 ));
11344 }
11345 sleep(Duration::from_millis(2)).await;
11346 }
11347 }
11348
11349 async fn discover_history(
11350 &self,
11351 address: &SessionAddress,
11352 limit: u16,
11353 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11354 let query = HistoryQuery {
11355 working_directory: Some(self.workspace_root(&address.workspace_id)?),
11356 limit,
11357 };
11358 query
11359 .validate()
11360 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11361 let snapshot = self
11362 .dispatch_history_bounded(
11363 address,
11364 ControlCommand::DiscoverHistory {
11365 instance_id: address.session.instance_id,
11366 query: query.clone(),
11367 },
11368 HistoryOperation::Discover { query },
11369 )
11370 .await?;
11371 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11372 return Err(failure(
11373 NodeFailureCode::BackendOperationFailed,
11374 "history discovery settled into an invalid state",
11375 ));
11376 }
11377 Ok(snapshot.history.candidates)
11378 }
11379
11380 async fn load_history(
11381 &self,
11382 address: &SessionAddress,
11383 candidate_id: String,
11384 ) -> Result<HistorySessionRecord, NodeFailure> {
11385 validate_candidate_id(&candidate_id)
11386 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11387 let snapshot = self
11388 .dispatch_history_bounded(
11389 address,
11390 ControlCommand::LoadHistory {
11391 instance_id: address.session.instance_id,
11392 candidate_id: candidate_id.clone(),
11393 },
11394 HistoryOperation::Load {
11395 candidate_id: candidate_id.clone(),
11396 },
11397 )
11398 .await?;
11399 if snapshot.history.pending.is_some()
11400 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11401 {
11402 return Err(failure(
11403 NodeFailureCode::BackendOperationFailed,
11404 "history load settled into an invalid state",
11405 ));
11406 }
11407 snapshot.history.loaded.ok_or_else(|| {
11408 failure(
11409 NodeFailureCode::BackendOperationFailed,
11410 "history load produced no bounded session",
11411 )
11412 })
11413 }
11414
11415 async fn export_context_pack_for_session_record(
11422 &self,
11423 record_id: &SessionRecordId,
11424 session: &SessionAddress,
11425 allow_clean_detachment: bool,
11426 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11427 let outcome = self
11433 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11434 .await;
11435 if let Err(failure) = &outcome {
11436 tracing::warn!(
11437 record = %record_id,
11438 code = ?failure.code,
11439 reason = %failure.message,
11440 "session-record context-pack export refused",
11441 );
11442 }
11443 outcome
11444 }
11445
11446 async fn export_context_pack_for_session_record_inner(
11447 &self,
11448 record_id: &SessionRecordId,
11449 session: &SessionAddress,
11450 allow_clean_detachment: bool,
11451 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11452 let step = |name: &'static str| move |failure: NodeFailure| {
11457 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11458 failure
11459 };
11460 let record = self.record(record_id).map_err(step("record-lookup"))?;
11461 let identity = match record.provider_session.clone() {
11477 Some(identity) => {
11478 if identity.key != ProviderSessionKey::SessionId {
11479 return Err(step("provider-identity-key")(failure(
11480 NodeFailureCode::BackendOperationFailed,
11481 "managed session provider identity cannot bind history exactly",
11482 )));
11483 }
11484 Some(identity)
11485 }
11486 None if record.state == ManagedSessionState::IdentityPending => {
11487 return Err(step("provider-session-identity")(failure(
11488 NodeFailureCode::BackendOperationFailed,
11489 "managed session has not yet observed its provider session identity",
11490 )));
11491 }
11492 None => None,
11493 };
11494 self.revalidate_session_record_context_export(
11495 &record,
11496 identity.as_ref(),
11497 session,
11498 allow_clean_detachment,
11499 ).map_err(step("revalidate-before-discovery"))?;
11500 let candidates = self
11501 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11502 .await
11503 .map_err(step("discover-history"))?;
11504 let candidate_id = select_history_candidate(
11509 &candidates,
11510 identity.as_ref().map(|identity| identity.id.as_str()),
11511 ).map_err(step("unique-candidate"))?;
11512 let loaded = self.load_history(session, candidate_id.clone()).await
11513 .map_err(step("load-history"))?;
11514 if let Some(identity) = identity.as_ref() {
11515 if loaded.session_id != identity.id {
11516 return Err(failure(
11517 NodeFailureCode::SessionRecordConflict,
11518 "loaded history identity does not match the managed session record",
11519 ));
11520 }
11521 }
11522 self.revalidate_session_record_context_export(
11523 &record,
11524 identity.as_ref(),
11525 session,
11526 allow_clean_detachment,
11527 )?;
11528 self.revalidate_loaded_history(session, &candidate_id, &loaded)
11529 .map_err(step("revalidate-loaded-history"))?;
11530 let pack = self.materialize_context_pack(session).await
11531 .map_err(step("materialize-context-pack"))?;
11532 self.commit_context_pack_for_session_record(
11533 &record,
11534 identity.as_ref(),
11535 session,
11536 &candidate_id,
11537 &loaded,
11538 pack,
11539 allow_clean_detachment,
11540 )
11541 }
11542
11543 async fn export_context_pack(
11544 &self,
11545 address: &SessionAddress,
11546 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11547 let pack = self.materialize_context_pack(address).await?;
11548 self.commit_context_pack(pack)
11549 }
11550
11551 async fn materialize_context_pack(
11552 &self,
11553 address: &SessionAddress,
11554 ) -> Result<NodeContextPack, NodeFailure> {
11555 let snapshot = self.internal_session_snapshot(address)?;
11556 if !context_pack_source_status_is_usable(&snapshot.status) {
11557 return Err(failure(
11558 NodeFailureCode::InvalidRequest,
11559 "context export requires a running or successfully exited source session",
11560 ));
11561 }
11562 if snapshot.history.pending.is_some()
11563 || snapshot.history.loaded_candidate_id.is_none()
11564 {
11565 return Err(failure(
11566 NodeFailureCode::InvalidRequest,
11567 "context export requires settled loaded history",
11568 ));
11569 }
11570 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11571 failure(
11572 NodeFailureCode::InvalidRequest,
11573 "context export requires settled loaded history",
11574 )
11575 })?;
11576 let source_provider = snapshot.agent_id.clone();
11577 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11578 let repository = self
11579 .context_pack_repository(&address.workspace_id)
11580 .await?;
11581 let current = self.internal_session_snapshot(address)?;
11582 if !context_pack_source_status_is_usable(¤t.status)
11583 || current.agent_id != source_provider
11584 || current.history.pending.is_some()
11585 || current.history.loaded_candidate_id != loaded_candidate_id
11586 || current.history.loaded.as_ref() != Some(&loaded)
11587 {
11588 return Err(failure(
11589 NodeFailureCode::ContextPackMaterializationFailed,
11590 "context source changed during bounded repository capture",
11591 ));
11592 }
11593 NodeContextPack::export_with_repository(
11594 ContextPackLineageReceipt {
11595 source_node_id: self.node_id.clone(),
11596 source_session: address.clone(),
11597 source_provider,
11598 },
11599 &loaded,
11600 Some(repository),
11601 )
11602 .map_err(|_| {
11603 failure(
11604 NodeFailureCode::ContextPackMaterializationFailed,
11605 "bounded context pack export failed",
11606 )
11607 })
11608 }
11609
11610 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
11616 let mut store = self
11617 .context_pack_store
11618 .lock()
11619 .unwrap_or_else(|poisoned| poisoned.into_inner());
11620 let Some(store) = store.as_mut() else {
11621 return Ok(());
11622 };
11623 store.commit(pack).map_err(|_| {
11624 failure(
11625 NodeFailureCode::ContextPackMaterializationFailed,
11626 "durable context pack commit failed",
11627 )
11628 })
11629 }
11630
11631 fn commit_context_pack(
11632 &self,
11633 pack: NodeContextPack,
11634 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11635 self.commit_context_pack_durably(&pack)?;
11636 self.context_catalog
11637 .write()
11638 .unwrap_or_else(|poisoned| poisoned.into_inner())
11639 .insert(pack)
11640 .map_err(|_| {
11641 failure(
11642 NodeFailureCode::ContextPackMaterializationFailed,
11643 "context pack catalog is full",
11644 )
11645 })
11646 }
11647
11648 fn commit_context_pack_for_session_record(
11649 &self,
11650 expected: &ManagedSessionRecord,
11651 identity: Option<&ProviderSessionIdentity>,
11652 session: &SessionAddress,
11653 candidate_id: &str,
11654 loaded: &HistorySessionRecord,
11655 pack: NodeContextPack,
11656 allow_clean_detachment: bool,
11657 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11658 let current_root = self.workspace_root(&expected.workspace_id)?;
11659 let _transaction = self
11660 .state_transaction
11661 .lock()
11662 .unwrap_or_else(|poisoned| poisoned.into_inner());
11663 let records = self
11664 .session_records
11665 .lock()
11666 .unwrap_or_else(|poisoned| poisoned.into_inner());
11667 let current = records.records.get(&expected.record_id).ok_or_else(|| {
11668 failure(
11669 NodeFailureCode::SessionRecordConflict,
11670 "managed session record disappeared during context export",
11671 )
11672 })?;
11673 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
11674 return Err(failure(
11675 NodeFailureCode::SessionRecordConflict,
11676 "managed session record changed during context export",
11677 ));
11678 }
11679 let bindings = self
11680 .session_bindings
11681 .lock()
11682 .unwrap_or_else(|poisoned| poisoned.into_inner());
11683 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
11684 failure(
11685 NodeFailureCode::SessionRecordConflict,
11686 "managed session runtime binding disappeared during context export",
11687 )
11688 })?;
11689 let runtime = self
11690 .handle
11691 .snapshot()
11692 .sessions
11693 .iter()
11694 .find(|snapshot| {
11695 snapshot.instance_id == session.session.instance_id
11696 && snapshot.generation == session.session.generation
11697 })
11698 .cloned()
11699 .ok_or_else(|| {
11700 failure(
11701 NodeFailureCode::SessionRecordConflict,
11702 "managed session runtime disappeared during context export",
11703 )
11704 })?;
11705 let receipt = pack.receipt();
11706 if !session_record_context_export_binding_is_exact(
11707 expected,
11708 current,
11709 identity,
11710 session,
11711 binding,
11712 &runtime.agent_id,
11713 ¤t_root,
11714 allow_clean_detachment,
11715 ) || !session_record_context_export_source_is_usable(
11716 current,
11717 &runtime.status,
11718 allow_clean_detachment,
11719 ) || runtime.history.pending.is_some()
11720 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
11721 || runtime.history.loaded.as_ref() != Some(loaded)
11722 || receipt.lineage.source_node_id != self.node_id
11723 || receipt.lineage.source_session != *session
11724 || receipt.lineage.source_provider != current.provider
11725 || receipt.source_message_count != loaded.message_count
11726 {
11727 return Err(failure(
11728 NodeFailureCode::SessionRecordConflict,
11729 "managed session source changed before context catalog commit",
11730 ));
11731 }
11732 self.commit_context_pack_durably(&pack)?;
11733 self.context_catalog
11734 .write()
11735 .unwrap_or_else(|poisoned| poisoned.into_inner())
11736 .insert(pack)
11737 .map_err(|_| {
11738 failure(
11739 NodeFailureCode::ContextPackMaterializationFailed,
11740 "context pack catalog is full",
11741 )
11742 })
11743 }
11744
11745 async fn context_pack_repository(
11746 &self,
11747 workspace_id: &WorkspaceId,
11748 ) -> Result<ContextPackRepository, NodeFailure> {
11749 let first = self
11750 .context_pack_repository_observation(workspace_id)
11751 .await?;
11752 let second = self
11753 .context_pack_repository_observation(workspace_id)
11754 .await?;
11755 stable_context_pack_repository(first, second)
11756 }
11757
11758 async fn context_pack_repository_observation(
11759 &self,
11760 workspace_id: &WorkspaceId,
11761 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
11762 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
11763 let head = if git.is_repository {
11764 let root = self.workspace_root(workspace_id)?;
11765 match resolve_base_commit_with_timeout(
11766 &root,
11767 "HEAD",
11768 GIT_COMMAND_TIMEOUT_MS,
11769 )
11770 .await
11771 {
11772 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
11773 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
11774 ContextPackRepositoryHead::Unborn
11775 }
11776 Err(_) => {
11777 return Err(failure(
11778 NodeFailureCode::ContextPackMaterializationFailed,
11779 "context pack HEAD observation failed",
11780 ));
11781 }
11782 }
11783 } else {
11784 ContextPackRepositoryHead::NotRepository
11785 };
11786 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
11787 for selected_path in CONTEXT_PACK_SELECTED_FILES {
11788 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
11789 failure(
11790 NodeFailureCode::InvalidRepositoryPath,
11791 "context pack selected file path is invalid",
11792 )
11793 })?;
11794 match self.read_workspace_file(workspace_id.clone(), path).await {
11795 Ok(WorkspaceFileRead {
11796 content: WorkspaceFileContent::Utf8 { text, byte_len },
11797 ..
11798 }) => files.push(ContextPackRepositoryFileSource::utf8(
11799 selected_path,
11800 text,
11801 byte_len,
11802 )),
11803 Ok(WorkspaceFileRead {
11804 content: WorkspaceFileContent::NonUtf8 { byte_len },
11805 ..
11806 }) => files.push(ContextPackRepositoryFileSource::skipped(
11807 selected_path,
11808 ContextPackSelectedFileSkipReason::NonUtf8,
11809 Some(byte_len),
11810 )),
11811 Ok(WorkspaceFileRead {
11812 content: WorkspaceFileContent::TooLarge { .. },
11813 ..
11814 }) => files.push(ContextPackRepositoryFileSource::skipped(
11815 selected_path,
11816 ContextPackSelectedFileSkipReason::TooLarge,
11817 None,
11818 )),
11819 Err(error) => {
11820 let reason = if matches!(
11821 error.code,
11822 NodeFailureCode::InvalidRepositoryPath
11823 | NodeFailureCode::RepositoryFileNotRegular
11824 | NodeFailureCode::RepositoryPathUnsafe
11825 ) {
11826 ContextPackSelectedFileSkipReason::Unsafe
11827 } else {
11828 ContextPackSelectedFileSkipReason::Unavailable
11829 };
11830 files.push(ContextPackRepositoryFileSource::skipped(
11831 selected_path,
11832 reason,
11833 None,
11834 ));
11835 }
11836 }
11837 }
11838 Ok(ContextPackRepositoryObservation { head, git, files })
11839 }
11840
11841 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
11842 let referenced_by_binding = self
11843 .session_bindings
11844 .lock()
11845 .unwrap_or_else(|poisoned| poisoned.into_inner())
11846 .values()
11847 .any(|binding| {
11848 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
11849 });
11850 let referenced_by_record = self
11851 .session_records
11852 .lock()
11853 .unwrap_or_else(|poisoned| poisoned.into_inner())
11854 .records
11855 .values()
11856 .any(|record| record.context_id.as_ref() == Some(context_id));
11857 let referenced_by_materialization = self
11858 .materializations
11859 .lock()
11860 .unwrap_or_else(|poisoned| poisoned.into_inner())
11861 .values()
11862 .any(|ownership| {
11863 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
11864 });
11865 if referenced_by_binding
11866 || referenced_by_record
11867 || referenced_by_materialization
11868 {
11869 return Err(failure(
11870 NodeFailureCode::ContextPackBusy,
11871 "context pack is still referenced",
11872 ));
11873 }
11874 let mut catalog = self.context_catalog
11875 .write()
11876 .unwrap_or_else(|poisoned| poisoned.into_inner());
11877 if catalog.get(context_id).is_none() {
11878 return Err(failure(
11879 NodeFailureCode::UnknownContextPack,
11880 "context pack is unavailable",
11881 ));
11882 }
11883 self.spawn_idempotency
11884 .lock()
11885 .unwrap_or_else(|poisoned| poisoned.into_inner())
11886 .entries
11887 .retain(|_, entry| !entry.value.references_context(context_id));
11888 let removed = catalog.remove(context_id);
11889 debug_assert!(removed.is_some(), "validated context pack remains present");
11890 Ok(())
11891 }
11892
11893 async fn dispatch_input_bounded(
11894 &self,
11895 address: &SessionAddress,
11896 action: InputAction,
11897 ) -> Result<(), NodeFailure> {
11898 let started_after = self.current_sequence();
11899 let command_id = self
11900 .dispatch_bounded(
11901 ControlCommand::SendInput {
11902 instance_id: address.session.instance_id,
11903 action,
11904 },
11905 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11906 )
11907 .await?;
11908 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
11912 let mut scan_after = started_after;
11913 let mut accepted_after = None;
11914 loop {
11915 let events = self
11916 .history
11917 .lock()
11918 .unwrap_or_else(|poisoned| poisoned.into_inner())
11919 .events
11920 .iter()
11921 .filter(|envelope| envelope.sequence > scan_after)
11922 .cloned()
11923 .collect::<Vec<_>>();
11924 for envelope in events {
11925 scan_after = scan_after.max(envelope.sequence);
11926 let NodeEvent::Control {
11927 address: event_address,
11928 event,
11929 } = envelope.event
11930 else {
11931 continue;
11932 };
11933 if event_address != *address {
11934 continue;
11935 }
11936 if event.command_id == Some(command_id) {
11937 match &event.event {
11938 ControlEventKind::CommandRejected { message } => {
11939 return Err(failure(NodeFailureCode::InvalidRequest, message));
11940 }
11941 ControlEventKind::InputRequested { .. } => {
11942 accepted_after = Some(envelope.sequence);
11943 continue;
11944 }
11945 _ => {}
11946 }
11947 }
11948 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11949 match &event.event {
11950 ControlEventKind::InputCompleted { .. } => return Ok(()),
11951 ControlEventKind::InputFailed { message, .. } => {
11952 return Err(failure(
11953 NodeFailureCode::BackendOperationFailed,
11954 message,
11955 ));
11956 }
11957 _ => {}
11958 }
11959 }
11960 }
11961 if Instant::now() >= deadline {
11962 return Err(failure(
11963 NodeFailureCode::BackendBusy,
11964 if accepted_after.is_some() {
11965 "input was accepted but did not settle before the bounded deadline"
11966 } else {
11967 "input was not accepted before the bounded deadline"
11968 },
11969 ));
11970 }
11971 sleep(Duration::from_millis(2)).await;
11972 }
11973 }
11974
11975 async fn dispatch_resize_bounded(
11976 &self,
11977 address: &SessionAddress,
11978 size: gate4agent_types::TerminalSize,
11979 ) -> Result<(), NodeFailure> {
11980 let started_after = self.current_sequence();
11981 let command_id = self
11982 .dispatch_bounded(
11983 ControlCommand::Resize {
11984 instance_id: address.session.instance_id,
11985 size,
11986 },
11987 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11988 )
11989 .await?;
11990 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11991 let mut scan_after = started_after;
11992 let mut accepted_after = None;
11993 loop {
11994 let events = self
11995 .history
11996 .lock()
11997 .unwrap_or_else(|poisoned| poisoned.into_inner())
11998 .events
11999 .iter()
12000 .filter(|envelope| envelope.sequence > scan_after)
12001 .cloned()
12002 .collect::<Vec<_>>();
12003 for envelope in events {
12004 scan_after = scan_after.max(envelope.sequence);
12005 let NodeEvent::Control {
12006 address: event_address,
12007 event,
12008 } = envelope.event
12009 else {
12010 continue;
12011 };
12012 if event_address != *address {
12013 continue;
12014 }
12015 if event.command_id == Some(command_id) {
12016 match &event.event {
12017 ControlEventKind::CommandRejected { message } => {
12018 return Err(failure(NodeFailureCode::InvalidRequest, message));
12019 }
12020 ControlEventKind::ResizeRequested {
12021 size: requested_size,
12022 ..
12023 } if *requested_size == size => {
12024 accepted_after = Some(envelope.sequence);
12025 continue;
12026 }
12027 _ => {}
12028 }
12029 }
12030 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12031 match &event.event {
12032 ControlEventKind::Resized { size: settled_size }
12033 if *settled_size == size =>
12034 {
12035 return Ok(());
12036 }
12037 ControlEventKind::ResizeFailed { message } => {
12038 return Err(failure(
12039 NodeFailureCode::BackendOperationFailed,
12040 message,
12041 ));
12042 }
12043 _ => {}
12044 }
12045 }
12046 }
12047 if Instant::now() >= deadline {
12048 return Err(failure(
12049 NodeFailureCode::BackendBusy,
12050 if accepted_after.is_some() {
12051 "resize was accepted but did not settle before the bounded deadline"
12052 } else {
12053 "resize was not accepted before the bounded deadline"
12054 },
12055 ));
12056 }
12057 sleep(Duration::from_millis(2)).await;
12058 }
12059 }
12060
12061 async fn spawn_session(
12062 &self,
12063 workspace_id: WorkspaceId,
12064 provider: AgentId,
12065 mode: SessionMode,
12066 terminal_size: gate4agent_types::TerminalSize,
12067 initial_prompt: Option<String>,
12068 ) -> Result<SessionAddress, NodeFailure> {
12069 if self.managed_worktrees.lock()
12070 .unwrap_or_else(|poisoned| poisoned.into_inner())
12071 .lease_for_workspace(&workspace_id)
12072 .is_some()
12073 {
12074 return Err(failure(
12075 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12076 "managed worktree targets may only be spawned through their lease request",
12077 ));
12078 }
12079 self
12080 .spawn_session_with_deadline(
12081 workspace_id,
12082 provider,
12083 mode,
12084 terminal_size,
12085 initial_prompt,
12086 None,
12087 None,
12088 None,
12089 None,
12090 None,
12091 SpawnRecordPolicy::ProviderIdentityOnly,
12092 None,
12093 &[],
12094 None,
12095 ApprovalLevel::default(),
12101 None,
12102 None,
12103 )
12104 .await
12105 .map(|(session, _runtime_policy)| session)
12106 }
12107
12108 async fn spawn_session_with_deadline(
12109 &self,
12110 workspace_id: WorkspaceId,
12111 provider: AgentId,
12112 mode: SessionMode,
12113 terminal_size: gate4agent_types::TerminalSize,
12114 initial_prompt: Option<String>,
12115 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12116 bundle: Option<ResolvedBundleReceipt>,
12117 context: Option<ResolvedContextPackReceipt>,
12118 managed_authority: Option<ManagedWorktreeLeaseId>,
12119 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12120 record_policy: SpawnRecordPolicy,
12121 deadline: Option<Instant>,
12122 required_capabilities: &[ProviderRuntimeCapability],
12123 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12124 approval_level: ApprovalLevel,
12125 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12126 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12132 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12133 let reserved_lease = self.managed_worktrees.lock()
12134 .unwrap_or_else(|poisoned| poisoned.into_inner())
12135 .lease_for_workspace(&workspace_id);
12136 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12137 (Some(expected), Some(provided)) if expected == provided => {}
12138 (None, None) => {}
12139 _ => {
12140 return Err(failure(
12141 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12142 "spawn lacks exact managed worktree lease authority",
12143 ));
12144 }
12145 }
12146 self.ensure_workspace_environment_profile_compatible(
12150 &workspace_id,
12151 environment_profile.as_ref(),
12152 )?;
12153 let runtime_requirement = match (mode, initial_prompt.is_some()) {
12154 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12155 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12156 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12157 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12158 };
12159 let transport = match mode {
12165 SessionMode::Pty => TransportKind::Pty,
12166 SessionMode::Inline => TransportKind::Pipe,
12167 SessionMode::Acp => TransportKind::Acp,
12168 };
12169 let pipe_transport = self.provider_supports_pipe_transport(&provider);
12170 let acp_transport = self.provider_supports_acp_transport(&provider);
12171 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12172 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12173 NodeFailureCode::UnsupportedSpawnCapability,
12174 "pre-admitted provider runtime no longer satisfies spawn requirements",
12175 ))?;
12176 runtime_policy
12177 } else if let Some(deadline) = deadline {
12178 let remaining = spawn_deadline_remaining(deadline)?;
12179 timeout(
12180 remaining,
12181 self.admit_provider_runtime(&provider, runtime_requirement),
12182 )
12183 .await
12184 .map_err(|_| {
12185 failure(
12186 NodeFailureCode::SpawnDeadlineExceeded,
12187 "provider admission exceeded the spawn deadline",
12188 )
12189 })??
12190 } else {
12191 self.admit_provider_runtime(&provider, runtime_requirement).await?
12192 };
12193 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12194 let runtime_policy =
12206 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12207 if required_capabilities
12208 .iter()
12209 .any(|capability| !runtime_policy.admits(*capability))
12210 {
12211 return Err(failure(
12212 NodeFailureCode::UnsupportedSpawnCapability,
12213 "provider runtime does not admit a required spawn capability",
12214 ));
12215 }
12216 if let Some(deadline) = deadline {
12217 spawn_deadline_remaining(deadline)?;
12218 }
12219 self.ensure_binding_capacity()?;
12220 let working_directory = self.workspace_root(&workspace_id)?;
12221 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12222 let session = SessionKey {
12223 instance_id,
12224 generation: SessionGeneration(1),
12225 };
12226 let address = SessionAddress {
12227 workspace_id: workspace_id.clone(),
12228 session,
12229 };
12230 tracing::info!(
12238 node_id = %self.node_id,
12239 workspace_id = %workspace_id,
12240 provider = %provider,
12241 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12242 session = ?address.session,
12243 "session spawn request accepted",
12244 );
12245 let mut prepared_spawn = SpawnPreparer::prepare(
12246 self,
12247 SpawnPrepareRequest {
12248 instance_id,
12249 address: &address,
12250 provider: &provider,
12251 mode,
12252 environment_profile: environment_profile.as_ref(),
12253 bundle: bundle.as_ref(),
12254 context: context.as_ref(),
12255 managed_authority: managed_authority.as_ref(),
12256 harness_mcp,
12257 approval_level,
12258 network_allowlist: network_allowlist.as_ref(),
12259 browser_profile_id,
12260 },
12261 )?;
12262 let materialization_id = prepared_spawn.materialization_id.take();
12263 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12264 let record_id = self.bind_spawn_session_with_materialization(
12265 &address,
12266 provider.clone(),
12267 mode,
12268 runtime_policy,
12269 record_policy,
12270 environment_profile,
12271 bundle,
12272 context,
12273 materialization_id,
12274 )?;
12275 prepared_spawn.retain_session_guards();
12276 let agent_id = provider;
12280 let register_command_id = match self.dispatch_bounded(
12281 ControlCommand::Register {
12282 instance_id,
12283 agent_id,
12284 transport,
12285 },
12286 dispatch_timeout,
12287 )
12288 .await {
12289 Ok(command_id) => command_id,
12290 Err(error) => {
12291 if let Some(binding) = self.remove_binding(&address) {
12292 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12293 }
12294 if let Some(record_id) = record_id.as_ref() {
12295 self.discard_record(record_id)?;
12296 }
12297 return Err(spawn_dispatch_error(error, deadline));
12298 }
12299 };
12300 let start_timeout = match spawn_dispatch_timeout(deadline) {
12301 Ok(timeout) => timeout,
12302 Err(error) => {
12303 let recovery = self
12304 .rollback_spawn(
12305 &address,
12306 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12307 )
12308 .await;
12309 return match recovery {
12310 Ok(()) => {
12311 if let Some(record_id) = record_id.as_ref() {
12312 self.discard_record(record_id)?;
12313 }
12314 Err(error)
12315 }
12316 Err(recovery_error) => {
12317 if let Some(record_id) = record_id.as_ref() {
12318 self.mark_record_error(
12319 record_id,
12320 "spawn deadline elapsed and registration recovery failed",
12321 )?;
12322 }
12323 Err(recovery_error)
12324 }
12325 };
12326 }
12327 };
12328 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12329 if let Err(recovery_error) = self
12330 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12331 .await
12332 {
12333 return Err(recovery_error);
12334 }
12335 if let Some(record_id) = record_id.as_ref() {
12336 self.discard_record(record_id)?;
12337 }
12338 return Err(start_error);
12339 }
12340 let start_result = self
12341 .dispatch_bounded(
12342 ControlCommand::Start {
12343 instance_id,
12344 runtime_policy,
12345 request: StartRequest {
12346 working_directory,
12347 terminal_size,
12348 initial_prompt,
12349 session_options: None,
12350 approval_level,
12351 },
12352 },
12353 start_timeout,
12354 )
12355 .await;
12356 let start_command_id = match start_result {
12357 Ok(command_id) => command_id,
12358 Err(start_error) => {
12359 let start_error = spawn_dispatch_error(start_error, deadline);
12360 let recovery = self
12361 .rollback_spawn(
12362 &address,
12363 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12364 )
12365 .await;
12366 return match recovery {
12367 Ok(()) => {
12368 if let Some(record_id) = record_id.as_ref() {
12369 self.discard_record(record_id)?;
12370 }
12371 Err(start_error)
12372 }
12373 Err(recovery_error) => {
12374 let diagnostic = format!(
12375 "start failed and registration recovery failed: {}",
12376 recovery_error.message,
12377 );
12378 if let Some(record_id) = record_id.as_ref() {
12379 self.mark_record_error(record_id, &diagnostic)?;
12380 }
12381 Err(failure(recovery_error.code, &diagnostic))
12382 }
12383 };
12384 }
12385 };
12386
12387 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12388 loop {
12389 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12398 .or_else(|| self.take_rejected_command_reason(start_command_id))
12399 {
12400 self.rollback_spawn(
12401 &address,
12402 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12403 ).await.map_err(|error| {
12404 failure(
12405 error.code,
12406 &format!(
12407 "spawn was rejected ({}) and registration recovery failed: {}",
12408 reason.message, error.message
12409 ),
12410 )
12411 })?;
12412 if let Some(record_id) = record_id.as_ref() {
12413 self.discard_record(record_id)?;
12414 }
12415 let code = if reason.unsupported_transport {
12416 NodeFailureCode::UnsupportedTransport
12417 } else {
12418 NodeFailureCode::BackendOperationFailed
12419 };
12420 return Err(failure(code, &reason.message));
12421 }
12422 if let Some(current) = self
12423 .handle
12424 .snapshot()
12425 .sessions
12426 .iter()
12427 .find(|current| current.instance_id == instance_id)
12428 {
12429 if current.generation != session.generation {
12430 let divergence = failure(
12431 NodeFailureCode::StaleGeneration,
12432 "spawn generation diverged",
12433 );
12434 return match self
12435 .rollback_spawn(
12436 &address,
12437 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12438 )
12439 .await
12440 {
12441 Ok(()) => {
12442 if let Some(record_id) = record_id.as_ref() {
12443 self.discard_record(record_id)?;
12444 }
12445 Err(divergence)
12446 }
12447 Err(recovery_error) => {
12448 if let Some(record_id) = record_id.as_ref() {
12449 self.mark_record_error(
12450 record_id,
12451 "spawn generation diverged and recovery failed",
12452 )?;
12453 }
12454 Err(recovery_error)
12455 }
12456 };
12457 }
12458 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12459 prepared_spawn.retain_launch_guards();
12460 return Ok((address, runtime_policy));
12461 }
12462 }
12463 if Instant::now() >= commit_deadline {
12464 self.rollback_spawn(
12465 &address,
12466 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12467 ).await.map_err(|error| {
12468 failure(
12469 error.code,
12470 &format!(
12471 "start did not commit and registration recovery failed: {}",
12472 error.message
12473 ),
12474 )
12475 })?;
12476 if let Some(record_id) = record_id.as_ref() {
12477 self.discard_record(record_id)?;
12478 }
12479 return Err(failure(
12480 if deadline.is_some() {
12481 NodeFailureCode::SpawnDeadlineExceeded
12482 } else {
12483 NodeFailureCode::BackendBusy
12484 },
12485 "start did not commit before the bounded deadline; registration was removed",
12486 ));
12487 }
12488 sleep(Duration::from_millis(2)).await;
12489 }
12490 }
12491
12492 fn effective_spawn_runtime_policy(
12493 &self,
12494 runtime_policy: ProviderRuntimePolicy,
12495 ) -> ProviderRuntimePolicy {
12496 #[cfg(feature = "fixture")]
12497 let runtime_policy = if self.fixture_semantic_hook_policy {
12498 ProviderRuntimePolicy::new(true, true, true, true, false, true)
12499 .expect("monitoring Hook fixture policy is internally valid")
12500 } else {
12501 runtime_policy
12502 };
12503 runtime_policy
12504 }
12505
12506 fn verify_harness_mcp_before_start(
12507 &self,
12508 prepared: Option<&PreparedHarnessMcpSpawn>,
12509 ) -> Result<(), NodeFailure> {
12510 prepared
12511 .map(PreparedHarnessMcpSpawn::verify_helper_program)
12512 .transpose()
12513 .map(|_| ())
12514 .map_err(harness_mcp_failure)
12515 }
12516
12517 async fn rollback_spawn(
12518 &self,
12519 address: &SessionAddress,
12520 commit_timeout: Duration,
12521 ) -> Result<(), NodeFailure> {
12522 self.dispatch_bounded(
12523 ControlCommand::Remove {
12524 instance_id: address.session.instance_id,
12525 },
12526 commit_timeout,
12527 )
12528 .await?;
12529 self.wait_until_removed(address, commit_timeout).await?;
12530 if let Some(binding) = self.remove_binding(address) {
12531 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
12532 }
12533 Ok(())
12534 }
12535
12536 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12537 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12538 self.dispatch_bounded(
12539 ControlCommand::Remove {
12540 instance_id: address.session.instance_id,
12541 },
12542 commit_timeout,
12543 )
12544 .await?;
12545 self.wait_until_removed(address, commit_timeout).await?;
12546 let removed = self.remove_binding(address);
12547 if let Some(binding) = removed.as_ref() {
12548 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
12549 }
12550 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12551 let snapshot = self.managed_worktrees.lock()
12552 .unwrap_or_else(|poisoned| poisoned.into_inner())
12553 .release_session(
12554 &lease_id,
12555 self.incarnation_id,
12556 address.session.instance_id,
12557 address.session.generation,
12558 unix_time_ms(),
12559 );
12560 if self.persist_state().is_err() {
12561 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12562 }
12563 if let Some(snapshot) = snapshot {
12564 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12565 }
12566 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12567 }
12568 Ok(())
12569 }
12570
12571 async fn wait_until_removed(
12617 &self,
12618 address: &SessionAddress,
12619 commit_timeout: Duration,
12620 ) -> Result<(), NodeFailure> {
12621 let deadline = Instant::now() + commit_timeout;
12622 let mut next_dispatch = Instant::now();
12623 loop {
12624 let snapshot = self.handle.snapshot();
12625 let status = snapshot
12626 .sessions
12627 .iter()
12628 .find(|session| session.instance_id == address.session.instance_id)
12629 .map(|session| session.status.clone());
12630 let Some(status) = status else {
12631 return Ok(());
12632 };
12633 let now = Instant::now();
12634 if now >= deadline {
12635 return Err(failure(
12636 NodeFailureCode::BackendBusy,
12637 "remove did not commit before the bounded deadline; session binding was retained",
12638 ));
12639 }
12640 if status.allows_remove() && now >= next_dispatch {
12641 let _ = self.dispatch(ControlCommand::Remove {
12642 instance_id: address.session.instance_id,
12643 });
12644 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
12645 }
12646 sleep(Duration::from_millis(2)).await;
12647 }
12648 }
12649
12650 fn resync(&self, after_sequence: u64) -> NodeResponse {
12651 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12652 let event_sequence = history.last_sequence;
12653 let oldest_available_sequence = history.replay_floor_sequence;
12654 let events = history
12655 .events
12656 .iter()
12657 .filter(|event| event.sequence > after_sequence)
12658 .cloned()
12659 .collect();
12660 drop(history);
12661 NodeResponse::Resync {
12662 event_sequence,
12663 oldest_available_sequence,
12664 snapshot: self.snapshot(),
12665 events,
12666 }
12667 }
12668}
12669
12670fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
12675 match kind {
12676 AgentStreamChunkKindV1::Text { .. } => "text",
12677 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
12678 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
12679 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
12680 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
12681 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
12682 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
12683 }
12684}
12685
12686async fn accept_connections(
12700 endpoint: &str,
12701 call_home: Option<std::net::SocketAddr>,
12702 shared: Arc<NodeShared>,
12703) -> Result<(), NodeServerError> {
12704 let mut connections = JoinSet::new();
12705 let result = match call_home {
12706 None => accept_connections_inner(endpoint, shared, &mut connections).await,
12707 Some(relay) => {
12708 let listener =
12709 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
12710 let dialler = call_home_loop(relay, Arc::clone(&shared));
12711 tokio::pin!(listener);
12712 tokio::pin!(dialler);
12713 tokio::select! {
12714 result = &mut listener => result,
12715 result = &mut dialler => result,
12716 }
12717 }
12718 };
12719 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
12720 while !connections.is_empty() {
12721 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
12722 if remaining.is_zero()
12723 || timeout(remaining, connections.join_next()).await.is_err()
12724 {
12725 break;
12726 }
12727 }
12728 connections.abort_all();
12729 while connections.join_next().await.is_some() {}
12730 result
12731}
12732
12733const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
12737
12738async fn call_home_loop(
12752 relay: std::net::SocketAddr,
12753 shared: Arc<NodeShared>,
12754) -> Result<(), NodeServerError> {
12755 let mut failures = 0_usize;
12756 loop {
12757 if shared.shutdown.load(Ordering::Acquire) {
12758 return Ok(());
12759 }
12760 let permit = tokio::select! {
12761 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12762 permit.map_err(|_| NodeServerError::PreauthClosed)?
12763 }
12764 _ = shared.shutdown_notify.notified() => return Ok(()),
12765 };
12766 match dial_relay_and_serve(relay, Arc::clone(&shared), permit).await {
12767 Ok(()) => {
12768 failures = 0;
12772 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed");
12773 }
12774 Err(error) => {
12775 failures = failures.saturating_add(1);
12776 tracing::warn!(
12777 node_id = %shared.node_id,
12778 %relay,
12779 cause = %error,
12780 attempt = failures,
12781 "call-home connection failed",
12782 );
12783 let backoff = CALL_HOME_BACKOFFS_MS
12784 [failures.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)];
12785 tokio::select! {
12786 _ = sleep(Duration::from_millis(backoff)) => {}
12787 _ = shared.shutdown_notify.notified() => return Ok(()),
12788 }
12789 }
12790 }
12791 }
12792}
12793
12794async fn dial_relay_and_serve(
12796 relay: std::net::SocketAddr,
12797 shared: Arc<NodeShared>,
12798 preauth_permit: OwnedSemaphorePermit,
12799) -> Result<(), NodeServerError> {
12800 let mut stream = tokio::net::TcpStream::connect(relay)
12801 .await
12802 .map_err(NodeServerError::CallHomeTransport)?;
12803 let _ = stream.set_nodelay(true);
12807 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
12808 .await
12809 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12810 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
12811 serve_connection(stream, shared, preauth_permit).await
12812}
12813
12814async fn accept_connections_inner(
12815 endpoint: &str,
12816 shared: Arc<NodeShared>,
12817 connections: &mut JoinSet<Result<(), NodeServerError>>,
12818) -> Result<(), NodeServerError> {
12819 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
12820 loop {
12821 while connections.try_join_next().is_some() {}
12822 if shared.shutdown.load(Ordering::Acquire) {
12823 return Ok(());
12824 }
12825 let preauth_permit = tokio::select! {
12826 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12827 permit.map_err(|_| NodeServerError::PreauthClosed)?
12828 }
12829 _ = shared.shutdown_notify.notified() => return Ok(()),
12830 };
12831 let server = tokio::select! {
12832 result = listener.accept() => result?,
12833 _ = shared.shutdown_notify.notified() => return Ok(()),
12834 };
12835 let connection_shared = Arc::clone(&shared);
12836 connections.spawn(async move {
12837 serve_connection(server, connection_shared, preauth_permit).await
12838 });
12839 if shared.shutdown.load(Ordering::Acquire) {
12840 return Ok(());
12841 }
12842 }
12843}
12844
12845async fn serve_connection<S>(
12853 mut pipe: S,
12854 shared: Arc<NodeShared>,
12855 preauth_permit: OwnedSemaphorePermit,
12856) -> Result<(), NodeServerError>
12857where
12858 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
12859{
12860 let hello_frame = timeout(
12861 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
12862 read_json_frame_limited_body_timeout(
12863 &mut pipe,
12864 MAX_NODE_HELLO_FRAME_BYTES,
12865 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12866 ),
12867 )
12868 .await
12869 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
12870 let ClientFrame::Hello(hello) = hello_frame else {
12871 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
12872 };
12873 if hello.build_stamp != BUILD_STAMP {
12874 return Err(NodeServerError::Handshake(format!(
12875 "build stamp mismatch: local={BUILD_STAMP} remote={}",
12876 hello.build_stamp,
12877 )));
12878 }
12879 let compatibility = match hello.compatibility.as_ref() {
12880 Some(offer) => {
12881 let mut selected = node_compatibility_support(&shared)?
12882 .negotiate(offer)
12883 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12884 project_negotiated_provider_ids(&mut selected);
12885 Some(selected)
12886 }
12887 None => None,
12888 };
12889 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
12890 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
12891 (Some(offer), Some(selected)) => negotiated_auth_proof(
12892 shared.access_token.as_bytes(),
12893 AuthDirection::Server,
12894 hello.role,
12895 &hello.client_nonce,
12896 &server_nonce,
12897 offer,
12898 selected,
12899 ),
12900 (None, None) => auth_proof(
12901 shared.access_token.as_bytes(),
12902 AuthDirection::Server,
12903 hello.role,
12904 &hello.client_nonce,
12905 &server_nonce,
12906 ),
12907 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
12908 }
12909 .map_err(NodeServerError::Authentication)?;
12910 write_json_frame_limited(
12911 &mut pipe,
12912 &ServerFrame::Challenge(ServerChallenge {
12913 build_stamp: BUILD_STAMP.to_owned(),
12914 server_nonce,
12915 server_proof,
12916 compatibility: compatibility.clone(),
12917 }),
12918 MAX_NODE_HELLO_FRAME_BYTES,
12919 )
12920 .await?;
12921 let authentication = timeout(
12922 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
12923 read_json_frame_limited_body_timeout(
12924 &mut pipe,
12925 MAX_NODE_HELLO_FRAME_BYTES,
12926 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12927 ),
12928 )
12929 .await
12930 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
12931 let ClientFrame::Authenticate(authentication) = authentication else {
12932 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
12933 };
12934 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
12935 (Some(offer), Some(selected)) => negotiated_auth_proof(
12936 shared.access_token.as_bytes(),
12937 AuthDirection::Client,
12938 hello.role,
12939 &hello.client_nonce,
12940 &server_nonce,
12941 offer,
12942 selected,
12943 ),
12944 (None, None) => auth_proof(
12945 shared.access_token.as_bytes(),
12946 AuthDirection::Client,
12947 hello.role,
12948 &hello.client_nonce,
12949 &server_nonce,
12950 ),
12951 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
12952 }
12953 .map_err(NodeServerError::Authentication)?;
12954 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
12955 return Err(NodeServerError::Handshake("access denied".to_owned()));
12956 }
12957 let selected_capabilities = compatibility
12958 .as_ref()
12959 .map(|selected| selected.capabilities.clone())
12960 .unwrap_or_default();
12961 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
12962 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
12963 });
12964 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
12965 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
12966 });
12967 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
12968 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
12969 });
12970 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
12971 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
12972 });
12973 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
12974 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
12975 });
12976 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
12977 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
12978 }) && selected_capabilities.iter().any(|capability| {
12979 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
12980 });
12981 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
12982 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
12983 });
12984 let include_session_bundles = selected_capabilities.iter().any(|capability| {
12985 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
12986 });
12987 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
12988 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
12989 });
12990 let include_agent_progress = selected_capabilities.iter().any(|capability| {
12991 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
12992 });
12993 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
12994 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
12995 });
12996 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
12997 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
12998 });
12999 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13000 .try_acquire_owned()
13001 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13002 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13003 let _release_guard = ControllerReleaseGuard {
13004 shared: Arc::clone(&shared),
13005 connection_id,
13006 };
13007 drop(preauth_permit);
13008 let _authenticated_permit = authenticated_permit;
13009 let mut event_rx = shared.event_tx.subscribe();
13010 let mut terminal_event_rx = terminal_event_subscription(
13011 &shared,
13012 include_terminal_frame_events,
13013 );
13014 let mut agent_stream_event_rx = agent_stream_event_subscription(
13015 &shared,
13016 include_agent_stream_events,
13017 );
13018 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13019 &shared,
13020 include_harness_mcp_proxy,
13021 );
13022 write_json_frame(
13023 &mut pipe,
13024 &ServerFrame::Hello(NodeHello {
13025 build_stamp: BUILD_STAMP.to_owned(),
13026 incarnation_id: shared.incarnation_id,
13027 connection_id,
13028 role: hello.role,
13029 event_sequence: shared.current_sequence(),
13030 controller: shared.controller_state(),
13031 snapshot: snapshot_for_wire(
13032 &shared,
13033 include_provider_runtime_status,
13034 include_open_provider_ids,
13035 include_spawn_profiles,
13036 include_managed_worktrees,
13037 include_child_environment_profiles,
13038 include_session_bundles,
13039 include_history_context_packs,
13040 include_agent_progress,
13041 include_session_task_correlation,
13042 ),
13043 compatibility,
13044 }),
13045 )
13046 .await?;
13047 let (mut reader, mut writer) = tokio::io::split(pipe);
13048 let (frame_tx, mut frame_rx) = mpsc::channel(16);
13049 let reader_task = tokio::spawn(async move {
13050 loop {
13051 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13052 &mut reader,
13053 MAX_NODE_CLIENT_FRAME_BYTES,
13054 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13055 )
13056 .await;
13057 let terminal = frame.is_err();
13058 if frame_tx.send(frame).await.is_err() || terminal {
13059 break;
13060 }
13061 }
13062 });
13063 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13064 let mut pending_events = Vec::new();
13065 let mut resync_required = false;
13066 let mut discard_events_through = 0_u64;
13067 loop {
13068 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13073 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13074 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13075 for _ in 0..durable_drain_budget {
13076 match event_rx.try_recv() {
13077 Ok(event) => queue_connection_event(
13078 &mut pending_events,
13079 event,
13080 discard_events_through,
13081 ),
13082 Err(broadcast::error::TryRecvError::Empty) => break,
13083 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13084 resync_required = true;
13085 break;
13086 }
13087 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13088 }
13089 }
13090 if let Some(receiver) = terminal_event_rx.as_mut() {
13091 match receiver.try_recv() {
13092 Ok(events) => queue_connection_event_batch(
13093 &mut pending_events,
13094 &events,
13095 discard_events_through,
13096 ),
13097 Err(broadcast::error::TryRecvError::Empty) => {}
13098 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13099 resync_required = true;
13100 }
13101 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13102 }
13103 }
13104 if let Some(receiver) = agent_stream_event_rx.as_mut() {
13105 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13115 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13116 for _ in 0..agent_stream_drain_budget {
13117 match receiver.try_recv() {
13118 Ok(events) => queue_connection_event_batch(
13119 &mut pending_events,
13120 &events,
13121 discard_events_through,
13122 ),
13123 Err(broadcast::error::TryRecvError::Empty) => break,
13124 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13125 tracing::warn!(
13126 connection_id,
13127 dropped,
13128 "agent stream event subscription lagged; forcing a resync",
13129 );
13130 resync_required = true;
13131 break;
13132 }
13133 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13134 }
13135 }
13136 }
13137 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13138 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13144 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13145 for _ in 0..harness_mcp_proxy_drain_budget {
13146 match receiver.try_recv() {
13147 Ok(events) => queue_connection_event_batch(
13148 &mut pending_events,
13149 &events,
13150 discard_events_through,
13151 ),
13152 Err(broadcast::error::TryRecvError::Empty) => break,
13153 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13154 tracing::warn!(
13155 connection_id,
13156 dropped,
13157 "harness MCP proxy event subscription lagged; forcing a resync",
13158 );
13159 resync_required = true;
13160 break;
13161 }
13162 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13163 }
13164 }
13165 }
13166 if resync_required {
13167 let event = resync_required_event(&shared);
13168 apply_connection_resync_watermark(
13169 &mut pending_events,
13170 &mut discard_events_through,
13171 event.sequence,
13172 );
13173 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13174 resync_required = false;
13175 continue;
13176 }
13177 if !pending_events.is_empty() {
13178 pending_events.sort_unstable_by_key(|event| event.sequence);
13179 let burst_len = connection_event_burst_len(pending_events.len());
13180 shared
13181 .connection_events_sent
13182 .fetch_add(burst_len as u64, Ordering::Relaxed);
13183 for event in pending_events.drain(..burst_len) {
13184 let event = if include_managed_worktrees {
13185 Some(event)
13186 } else {
13187 project_event_without_managed_worktrees(event)
13188 };
13189 let event = event.and_then(|event| if include_open_provider_ids {
13190 Some(event)
13191 } else {
13192 project_event_legacy_provider_ids(&shared, event)
13193 });
13194 let event = event.map(|event| if include_child_environment_profiles {
13195 event
13196 } else {
13197 project_event_without_child_environment_profile(event)
13198 });
13199 let event = event.map(|event| if include_session_bundles {
13200 event
13201 } else {
13202 project_event_without_session_bundle(event)
13203 });
13204 let event = event.map(|event| {
13205 project_event_history_for_wire(event, include_history_context_packs)
13206 });
13207 let event = event.map(|event| if include_history_context_packs {
13208 event
13209 } else {
13210 project_event_without_context_pack(event)
13211 });
13212 let event = event.map(|event| if include_session_task_correlation {
13213 event
13214 } else {
13215 project_event_without_session_task_binding(event)
13216 });
13217 let exact_harness_controller = hello.role == ClientRole::Operator
13218 && shared.controller_state().is_some_and(|controller| {
13219 controller.connection_id == connection_id
13220 });
13221 let event = event.and_then(|event| {
13222 if include_harness_mcp_proxy && exact_harness_controller {
13223 Some(event)
13224 } else {
13225 project_event_without_harness_mcp_proxy(event)
13226 }
13227 });
13228 if let Some(event) = event {
13229 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13230 }
13231 }
13232 }
13233 tokio::select! {
13234 biased;
13235 frame = frame_rx.recv() => {
13236 let frame = match frame {
13237 Some(frame) => frame,
13238 None => break,
13239 };
13240 let frame = match frame {
13241 Ok(frame) => frame,
13242 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13243 Err(error) => return Err(error.into()),
13244 };
13245 let ClientFrame::Request(request) = frame else {
13246 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13247 };
13248 #[cfg(feature = "fixture")]
13249 let probe_spawn_managed_worktree_v2 = matches!(
13250 &request.request,
13251 NodeRequest::SpawnManagedWorktreeV2 { .. }
13252 );
13253 let requires_open_provider_ids =
13254 request_requires_open_provider_ids(&shared, &request.request);
13255 let requires_child_environment_profile =
13256 request_requires_child_environment_profile(&shared, &request.request);
13257 let requires_session_bundle =
13258 request_requires_session_bundle(&shared, &request.request);
13259 let requires_history_context_pack =
13260 request_requires_history_context_pack(&shared, &request.request);
13261 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13262 ResponseEnvelope {
13263 request_id: request.request_id,
13264 result: Err(failure(
13265 NodeFailureCode::UnsupportedCapability,
13266 "open provider IDs were not negotiated",
13267 )),
13268 }
13269 } else if requires_child_environment_profile
13270 && !include_child_environment_profiles
13271 {
13272 ResponseEnvelope {
13273 request_id: request.request_id,
13274 result: Err(failure(
13275 NodeFailureCode::UnsupportedCapability,
13276 "child environment profiles were not negotiated",
13277 )),
13278 }
13279 } else if requires_session_bundle && !include_session_bundles {
13280 ResponseEnvelope {
13281 request_id: request.request_id,
13282 result: Err(failure(
13283 NodeFailureCode::UnsupportedCapability,
13284 "session bundles were not negotiated",
13285 )),
13286 }
13287 } else if requires_history_context_pack && !include_history_context_packs {
13288 ResponseEnvelope {
13289 request_id: request.request_id,
13290 result: Err(failure(
13291 NodeFailureCode::UnsupportedCapability,
13292 "history context packs were not negotiated",
13293 )),
13294 }
13295 } else if request_uses_unnegotiated_capability(
13296 &request.request,
13297 &selected_capabilities,
13298 )
13299 {
13300 ResponseEnvelope {
13301 request_id: request.request_id,
13302 result: Err(failure(
13303 NodeFailureCode::UnsupportedCapability,
13304 "request capability was not negotiated",
13305 )),
13306 }
13307 } else {
13308 process_request(&shared, connection_id, hello.role, request).await
13309 };
13310 #[cfg(feature = "fixture")]
13311 if probe_spawn_managed_worktree_v2 {
13312 shared
13313 .fixture_spawn_managed_worktree_v2_failure_probe
13314 .record(reply.result.as_ref().err().map(|failure| failure.code));
13315 }
13316 if !include_provider_runtime_status {
13317 clear_response_provider_runtime_status(&mut reply);
13318 }
13319 if !include_managed_worktrees {
13320 project_response_without_managed_worktrees(&mut reply);
13321 }
13322 if !include_open_provider_ids {
13323 project_response_legacy_provider_ids(&shared, &mut reply);
13324 }
13325 if !include_child_environment_profiles {
13326 project_response_without_child_environment_profile(&mut reply);
13327 }
13328 if !include_session_bundles {
13329 project_response_without_session_bundle(&mut reply);
13330 }
13331 project_response_history_for_wire(
13332 &mut reply,
13333 include_history_context_packs,
13334 );
13335 if !include_history_context_packs {
13336 project_response_without_context_pack(&mut reply);
13337 }
13338 if !include_agent_progress {
13339 project_response_without_agent_progress(&mut reply);
13340 }
13341 if !include_session_task_correlation {
13342 project_response_without_session_task_binding(&mut reply);
13343 }
13344 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13345 }
13346 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13347 event = event_rx.recv() => {
13348 match event {
13349 Ok(event) => queue_connection_event(
13350 &mut pending_events,
13351 event,
13352 discard_events_through,
13353 ),
13354 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13355 Err(broadcast::error::RecvError::Closed) => break,
13356 }
13357 }
13358 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13359 match events {
13360 Ok(events) => queue_connection_event_batch(
13361 &mut pending_events,
13362 &events,
13363 discard_events_through,
13364 ),
13365 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13366 Err(broadcast::error::RecvError::Closed) => break,
13367 }
13368 }
13369 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13370 match events {
13371 Ok(events) => queue_connection_event_batch(
13372 &mut pending_events,
13373 &events,
13374 discard_events_through,
13375 ),
13376 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13377 Err(broadcast::error::RecvError::Closed) => break,
13378 }
13379 }
13380 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13381 match events {
13382 Ok(events) => queue_connection_event_batch(
13383 &mut pending_events,
13384 &events,
13385 discard_events_through,
13386 ),
13387 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13388 Err(broadcast::error::RecvError::Closed) => break,
13389 }
13390 }
13391 _ = shared.shutdown_notify.notified() => break,
13392 }
13393 }
13394 Ok(())
13395}
13396
13397async fn receive_terminal_event_batch(
13398 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13399) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13400 match receiver {
13401 Some(receiver) => receiver.recv().await,
13402 None => std::future::pending().await,
13403 }
13404}
13405
13406fn terminal_event_subscription(
13407 shared: &NodeShared,
13408 enabled: bool,
13409) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13410 enabled.then(|| shared.terminal_event_tx.subscribe())
13411}
13412
13413fn agent_stream_event_subscription(
13417 shared: &NodeShared,
13418 enabled: bool,
13419) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13420 enabled.then(|| shared.agent_stream_event_tx.subscribe())
13421}
13422
13423fn harness_mcp_proxy_event_subscription(
13428 shared: &NodeShared,
13429 enabled: bool,
13430) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13431 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13432}
13433
13434fn queue_connection_event(
13435 pending: &mut Vec<NodeEventEnvelope>,
13436 event: NodeEventEnvelope,
13437 discard_through: u64,
13438) {
13439 if event.sequence <= discard_through
13440 && !event.event.requires_harness_mcp_proxy_capability()
13441 {
13442 return;
13443 }
13444 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13445 if let Some(index) = pending.iter().position(|current| {
13446 matches!(
13447 ¤t.event,
13448 NodeEvent::TerminalFrame {
13449 address: current_address,
13450 ..
13451 } if current_address == address
13452 )
13453 }) {
13454 pending.remove(index);
13455 }
13456 }
13457 pending.push(event);
13458}
13459
13460fn project_event_without_harness_mcp_proxy(
13461 envelope: NodeEventEnvelope,
13462) -> Option<NodeEventEnvelope> {
13463 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13464}
13465
13466fn queue_connection_event_batch(
13467 pending: &mut Vec<NodeEventEnvelope>,
13468 events: &[NodeEventEnvelope],
13469 discard_through: u64,
13470) {
13471 for event in events {
13472 queue_connection_event(pending, event.clone(), discard_through);
13473 }
13474}
13475
13476fn apply_connection_resync_watermark(
13477 pending: &mut Vec<NodeEventEnvelope>,
13478 discard_through: &mut u64,
13479 marker_sequence: u64,
13480) {
13481 *discard_through = (*discard_through).max(marker_sequence);
13482 pending.retain(|event| event.sequence > *discard_through);
13483}
13484
13485fn connection_event_burst_len(pending_len: usize) -> usize {
13486 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13487}
13488
13489fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13490 let history = shared
13491 .history
13492 .lock()
13493 .unwrap_or_else(|poisoned| poisoned.into_inner());
13494 let sequence = history.last_sequence;
13495 let oldest_available_sequence = history.replay_floor_sequence;
13496 NodeEventEnvelope {
13497 sequence,
13498 event: NodeEvent::ResyncRequired {
13499 oldest_available_sequence,
13500 },
13501 }
13502}
13503
13504fn node_compatibility_support(
13505 shared: &NodeShared,
13506) -> Result<NodeCompatibilitySupport, NodeServerError> {
13507 let mut support = node_compatibility_support_for_manifest(
13508 &shared.provider_contracts,
13509 &shared.provider_adapter_contracts,
13510 )?;
13511 if shared
13512 .delivery_store
13513 .lock()
13514 .unwrap_or_else(|poisoned| poisoned.into_inner())
13515 .is_some()
13516 {
13517 support.capabilities.push(
13518 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13519 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13520 );
13521 }
13522 if shared.harness_mcp_registry.is_some() {
13523 support.capabilities.push(
13524 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
13525 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13526 );
13527 }
13528 Ok(support)
13529}
13530
13531fn node_compatibility_support_for_manifest(
13532 provider_contracts: &[ProviderContractSupport],
13533 provider_adapter_contracts: &[ProviderAdapterContractSupport],
13534) -> Result<NodeCompatibilitySupport, NodeServerError> {
13535 Ok(NodeCompatibilitySupport {
13536 build_stamp: BUILD_STAMP.to_owned(),
13537 capabilities: baseline_capabilities()?,
13538 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
13539 path_semantics: platform::path_semantics(),
13540 local_transport: platform::local_transport(),
13541 state_schema: StateSchemaSupport {
13542 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
13543 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13544 },
13545 provider_contracts: provider_contracts.to_vec(),
13546 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
13547 })
13548}
13549
13550fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
13551 let capabilities = [
13552 NODE_COMPATIBILITY_METADATA_CAPABILITY,
13553 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
13554 NODE_REPOSITORY_PATH_CAPABILITY,
13555 NODE_WORKSPACE_FILE_READ_CAPABILITY,
13556 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
13557 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
13558 NODE_GIT_READ_CAPABILITY,
13559 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
13560 NODE_PROVIDER_ID_OPEN_CAPABILITY,
13561 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
13562 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
13563 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
13564 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
13565 NODE_ACP_CONTROL_CAPABILITY,
13566 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
13567 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
13568 NODE_WORKTREE_SELECTION_CAPABILITY,
13569 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
13570 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
13571 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
13572 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
13573 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
13574 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
13575 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
13576 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
13577 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
13578 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
13579 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
13580 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
13581 ]
13582 .into_iter()
13583 .map(|capability| {
13584 CapabilityId::new(capability)
13585 .map_err(|error| NodeServerError::Handshake(error.to_string()))
13586 })
13587 .collect::<Result<Vec<_>, _>>()?;
13588 #[cfg(windows)]
13589 let capabilities = {
13590 let mut capabilities = capabilities;
13591 capabilities.push(
13592 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
13593 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13594 );
13595 capabilities
13596 };
13597 Ok(capabilities)
13598}
13599
13600fn request_uses_unnegotiated_capability(
13601 request: &NodeRequest,
13602 selected_capabilities: &[CapabilityId],
13603) -> bool {
13604 let selected = |required: &str| {
13605 selected_capabilities
13606 .iter()
13607 .any(|capability| capability.as_str() == required)
13608 };
13609 request
13610 .required_capability()
13611 .is_some_and(|required| !selected(required))
13612 || (request.requires_worktree_selection_capability()
13613 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
13614 || (request.requires_spawn_spec_defaults_overrides_capability()
13615 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
13616 || (request.requires_spawn_profile_revision_capability()
13617 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
13618 || (request.requires_child_environment_profile_capability()
13619 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
13620 || (request.requires_session_bundle_materialization_capability()
13621 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
13622 || (request.requires_history_context_pack_capability()
13623 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
13624}
13625
13626fn snapshot_for_wire(
13627 shared: &NodeShared,
13628 include_provider_runtime_status: bool,
13629 include_open_provider_ids: bool,
13630 include_spawn_profiles: bool,
13631 include_managed_worktrees: bool,
13632 include_child_environment_profiles: bool,
13633 include_session_bundles: bool,
13634 include_history_context_packs: bool,
13635 include_agent_progress: bool,
13636 include_session_task_correlation: bool,
13637) -> NodeSnapshot {
13638 let mut snapshot = shared.snapshot();
13639 if !include_provider_runtime_status {
13640 snapshot.provider_runtime_statuses.clear();
13641 }
13642 if !include_open_provider_ids {
13643 project_snapshot_legacy_provider_ids(&mut snapshot);
13644 }
13645 if !include_managed_worktrees {
13646 snapshot.managed_worktrees.clear();
13647 for workspace in &mut snapshot.workspaces {
13648 workspace.managed_worktree_profiles = None;
13649 }
13650 }
13651 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
13652 if !include_spawn_profiles {
13653 inventory.spawn_profiles = None;
13654 }
13655 if !include_session_bundles {
13656 inventory.bundles = None;
13657 }
13658 if inventory.spawn_profiles.is_none()
13659 && inventory.bundles.is_none()
13660 && inventory.network_allowlists.is_none()
13661 {
13662 snapshot.launch_inventory = None;
13663 }
13664 }
13665 if !include_child_environment_profiles {
13666 clear_snapshot_child_environment_profiles(&mut snapshot);
13667 }
13668 if !include_session_bundles {
13669 clear_snapshot_session_bundles(&mut snapshot);
13670 }
13671 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
13672 if !include_history_context_packs {
13673 clear_snapshot_context_packs(&mut snapshot);
13674 }
13675 if !include_agent_progress {
13676 snapshot.agent_progress.clear();
13677 }
13678 if !include_session_task_correlation {
13679 clear_snapshot_session_task_bindings(&mut snapshot);
13680 }
13681 snapshot
13682}
13683
13684fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
13685 for record in &mut snapshot.session_records {
13686 record.task_binding = None;
13687 }
13688}
13689
13690fn project_event_without_session_task_binding(
13691 mut envelope: NodeEventEnvelope,
13692) -> NodeEventEnvelope {
13693 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
13694 record.task_binding = None;
13695 }
13696 envelope
13697}
13698
13699fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
13700 let Ok(response) = reply.result.as_mut() else { return };
13701 match response {
13702 NodeResponse::Snapshot { snapshot, .. } => {
13703 clear_snapshot_session_task_bindings(snapshot);
13704 }
13705 NodeResponse::Resync { snapshot, events, .. } => {
13706 clear_snapshot_session_task_bindings(snapshot);
13707 for event in events {
13708 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
13709 record.task_binding = None;
13710 }
13711 }
13712 }
13713 NodeResponse::SessionRecordUpdated { record }
13714 | NodeResponse::ProviderSessionIndexed { record }
13715 | NodeResponse::NativeSessionIndexed { record, .. }
13716 | NodeResponse::SessionRecordResumed { record, .. } => {
13717 record.task_binding = None;
13718 }
13719 _ => {}
13720 }
13721}
13722
13723fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
13724 let Ok(response) = reply.result.as_mut() else { return };
13725 match response {
13726 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
13727 snapshot.agent_progress.clear();
13728 }
13729 _ => {}
13730 }
13731}
13732
13733fn project_event_without_managed_worktrees(
13734 mut envelope: NodeEventEnvelope,
13735) -> Option<NodeEventEnvelope> {
13736 if matches!(
13737 envelope.event,
13738 NodeEvent::ManagedWorktreeUpserted { .. }
13739 | NodeEvent::ManagedWorktreeRemoved { .. }
13740 ) {
13741 return None;
13742 }
13743 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
13744 workspace.managed_worktree_profiles = None;
13745 }
13746 Some(envelope)
13747}
13748
13749fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
13750 if matches!(
13751 reply.result,
13752 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
13753 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
13754 ) {
13755 reply.result = Err(failure(
13756 NodeFailureCode::UnsupportedCapability,
13757 "managed worktree capability was not negotiated",
13758 ));
13759 return;
13760 }
13761 let Ok(response) = reply.result.as_mut() else { return };
13762 match response {
13763 NodeResponse::Snapshot { snapshot, .. } => {
13764 snapshot.managed_worktrees.clear();
13765 for workspace in &mut snapshot.workspaces {
13766 workspace.managed_worktree_profiles = None;
13767 }
13768 }
13769 NodeResponse::Resync { snapshot, events, .. } => {
13770 snapshot.managed_worktrees.clear();
13771 for workspace in &mut snapshot.workspaces {
13772 workspace.managed_worktree_profiles = None;
13773 }
13774 *events = std::mem::take(events)
13775 .into_iter()
13776 .filter_map(project_event_without_managed_worktrees)
13777 .collect();
13778 }
13779 NodeResponse::WorkspaceRegistered { workspace }
13780 | NodeResponse::StandaloneWorkspaceCreated { workspace }
13781 | NodeResponse::WorktreeCreated { workspace, .. } => {
13782 workspace.managed_worktree_profiles = None;
13783 }
13784 NodeResponse::WorkspaceInspected { inspection } => {
13785 inspection.git.managed_worktree = None;
13786 }
13787 _ => {}
13788 }
13789}
13790
13791fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
13792 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
13793 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13794 });
13795 if includes_open_provider_ids {
13796 return;
13797 }
13798 compatibility
13799 .provider_contracts
13800 .retain(|contract| provider_id_is_legacy(&contract.provider));
13801 compatibility
13802 .provider_adapter_contracts
13803 .retain(|contract| provider_id_is_legacy(&contract.provider));
13804}
13805
13806fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
13807 if let Some(spec) = match request {
13808 NodeRequest::SpawnSpec { spec }
13809 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13810 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13811 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13812 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13813 _ => None,
13814 } {
13815 return shared
13816 .resolve_spawn_spec(spec)
13817 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
13818 }
13819 request_requires_open_provider_ids_with(
13820 request,
13821 |session| shared.validate_address(session).ok(),
13822 |record_id| shared.record(record_id).ok().map(|record| record.provider),
13823 )
13824}
13825
13826fn request_requires_child_environment_profile(
13827 shared: &NodeShared,
13828 request: &NodeRequest,
13829) -> bool {
13830 if let Some(spec) = match request {
13831 NodeRequest::SpawnSpec { spec }
13832 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13833 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13834 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13835 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13836 _ => None,
13837 } {
13838 return shared
13839 .resolve_spawn_spec(spec)
13840 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
13841 }
13842 match request {
13843 NodeRequest::Resume { session, .. }
13844 | NodeRequest::Prompt { session, .. }
13845 | NodeRequest::Paste { session, .. }
13846 | NodeRequest::Input { session, .. }
13847 | NodeRequest::TerminalBytes { session, .. }
13848 | NodeRequest::TerminalControl { session, .. }
13849 | NodeRequest::Resize { session, .. }
13850 | NodeRequest::Interrupt { session }
13851 | NodeRequest::Stop { session, .. }
13852 | NodeRequest::Remove { session }
13853 | NodeRequest::ResolveInteraction { session, .. }
13854 | NodeRequest::SetSessionMode { session, .. }
13855 | NodeRequest::SetSessionConfigOption { session, .. }
13856 | NodeRequest::SetSessionModel { session, .. }
13857 | NodeRequest::DiscoverHistory { session, .. }
13858 | NodeRequest::LoadHistory { session, .. }
13859 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13860 | NodeRequest::ExportContextPack { session } => shared
13861 .session_bindings
13862 .lock()
13863 .unwrap_or_else(|poisoned| poisoned.into_inner())
13864 .get(&session.session.instance_id)
13865 .filter(|binding| {
13866 binding.workspace_id == session.workspace_id
13867 && binding.generation == session.session.generation
13868 })
13869 .map_or(true, |binding| binding.environment_profile.is_some()),
13870 NodeRequest::RenameSessionRecord { record_id, .. }
13871 | NodeRequest::SetSessionTask { record_id, .. }
13872 | NodeRequest::ResumeSessionRecord { record_id, .. }
13873 | NodeRequest::ForgetSessionRecord { record_id } => shared
13874 .record(record_id)
13875 .map_or(true, |record| record.environment_profile.is_some()),
13876 NodeRequest::ArmHarnessMcpReservation { .. }
13877 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13878 | NodeRequest::ActivateHarnessMcpReservation { .. }
13879 | NodeRequest::AbortHarnessMcpReservation { .. }
13880 | NodeRequest::PutHarnessMcpReplyChunk { .. }
13881 | NodeRequest::RejectHarnessMcpCall { .. }
13882 | NodeRequest::Snapshot
13883 | NodeRequest::Resync { .. }
13884 | NodeRequest::BeginDeliveryStage { .. }
13885 | NodeRequest::PutDeliveryBlobChunk { .. }
13886 | NodeRequest::CommitDeliveryStage { .. }
13887 | NodeRequest::AbortDeliveryStage { .. }
13888 | NodeRequest::BrowseHostDirectories { .. }
13889 | NodeRequest::InspectWorkspace { .. }
13890 | NodeRequest::ReadWorkspaceFile { .. }
13891 | NodeRequest::WriteWorkspaceFile { .. }
13892 | NodeRequest::CreateWorkspaceFile { .. }
13893 | NodeRequest::CreateWorkspaceDirectory { .. }
13894 | NodeRequest::ReadGitHistory { .. }
13895 | NodeRequest::ReadGitDiff { .. }
13896 | NodeRequest::AcquireController { .. }
13897 | NodeRequest::ReleaseController
13898 | NodeRequest::Spawn { .. }
13899 | NodeRequest::SpawnSpec { .. }
13900 | NodeRequest::RegisterWorkspace { .. }
13901 | NodeRequest::CreateStandaloneWorkspace { .. }
13902 | NodeRequest::UnregisterWorkspace { .. }
13903 | NodeRequest::CreateWorktree { .. }
13904 | NodeRequest::RemoveWorktree { .. }
13905 | NodeRequest::SpawnManagedWorktree { .. }
13906 | NodeRequest::SpawnManagedWorktreeV2 { .. }
13907 | NodeRequest::CleanupManagedWorktree { .. }
13908 | NodeRequest::IndexProviderSession { .. }
13909 | NodeRequest::IndexNativeSession { .. }
13910 | NodeRequest::CatalogNativeSessions { .. }
13911 | NodeRequest::PageNativeSessions { .. }
13912 | NodeRequest::PreviewNativeSession { .. }
13913 | NodeRequest::PreviewSessionRecord { .. }
13914 | NodeRequest::ForgetContextPack { .. }
13915 | NodeRequest::ResolveDurableContextPack { .. }
13916 | NodeRequest::ReadContextPack { .. }
13917 | NodeRequest::Shutdown => false,
13918 }
13919}
13920
13921fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
13922 if let Some(spec) = match request {
13923 NodeRequest::SpawnSpec { spec }
13924 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13925 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13926 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13927 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13928 _ => None,
13929 } {
13930 return shared
13931 .resolve_spawn_spec(spec)
13932 .is_ok_and(|resolved| resolved.bundle_id.is_some());
13933 }
13934 match request {
13935 NodeRequest::Resume { session, .. }
13936 | NodeRequest::Prompt { session, .. }
13937 | NodeRequest::Paste { session, .. }
13938 | NodeRequest::Input { session, .. }
13939 | NodeRequest::TerminalBytes { session, .. }
13940 | NodeRequest::TerminalControl { session, .. }
13941 | NodeRequest::Resize { session, .. }
13942 | NodeRequest::Interrupt { session }
13943 | NodeRequest::Stop { session, .. }
13944 | NodeRequest::Remove { session }
13945 | NodeRequest::ResolveInteraction { session, .. }
13946 | NodeRequest::SetSessionMode { session, .. }
13947 | NodeRequest::SetSessionConfigOption { session, .. }
13948 | NodeRequest::SetSessionModel { session, .. }
13949 | NodeRequest::DiscoverHistory { session, .. }
13950 | NodeRequest::LoadHistory { session, .. }
13951 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13952 | NodeRequest::ExportContextPack { session } => shared
13953 .session_bindings
13954 .lock()
13955 .unwrap_or_else(|poisoned| poisoned.into_inner())
13956 .get(&session.session.instance_id)
13957 .filter(|binding| {
13958 binding.workspace_id == session.workspace_id
13959 && binding.generation == session.session.generation
13960 })
13961 .map_or(true, |binding| binding.bundle.is_some()),
13962 NodeRequest::RenameSessionRecord { record_id, .. }
13963 | NodeRequest::SetSessionTask { record_id, .. }
13964 | NodeRequest::ResumeSessionRecord { record_id, .. }
13965 | NodeRequest::ForgetSessionRecord { record_id } => shared
13966 .record(record_id)
13967 .map_or(true, |record| record.bundle.is_some()),
13968 NodeRequest::ArmHarnessMcpReservation { .. }
13969 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13970 | NodeRequest::ActivateHarnessMcpReservation { .. }
13971 | NodeRequest::AbortHarnessMcpReservation { .. }
13972 | NodeRequest::PutHarnessMcpReplyChunk { .. }
13973 | NodeRequest::RejectHarnessMcpCall { .. }
13974 | NodeRequest::Snapshot
13975 | NodeRequest::Resync { .. }
13976 | NodeRequest::BeginDeliveryStage { .. }
13977 | NodeRequest::PutDeliveryBlobChunk { .. }
13978 | NodeRequest::CommitDeliveryStage { .. }
13979 | NodeRequest::AbortDeliveryStage { .. }
13980 | NodeRequest::BrowseHostDirectories { .. }
13981 | NodeRequest::InspectWorkspace { .. }
13982 | NodeRequest::ReadWorkspaceFile { .. }
13983 | NodeRequest::WriteWorkspaceFile { .. }
13984 | NodeRequest::CreateWorkspaceFile { .. }
13985 | NodeRequest::CreateWorkspaceDirectory { .. }
13986 | NodeRequest::ReadGitHistory { .. }
13987 | NodeRequest::ReadGitDiff { .. }
13988 | NodeRequest::AcquireController { .. }
13989 | NodeRequest::ReleaseController
13990 | NodeRequest::Spawn { .. }
13991 | NodeRequest::SpawnSpec { .. }
13992 | NodeRequest::RegisterWorkspace { .. }
13993 | NodeRequest::CreateStandaloneWorkspace { .. }
13994 | NodeRequest::UnregisterWorkspace { .. }
13995 | NodeRequest::CreateWorktree { .. }
13996 | NodeRequest::RemoveWorktree { .. }
13997 | NodeRequest::SpawnManagedWorktree { .. }
13998 | NodeRequest::SpawnManagedWorktreeV2 { .. }
13999 | NodeRequest::CleanupManagedWorktree { .. }
14000 | NodeRequest::IndexProviderSession { .. }
14001 | NodeRequest::IndexNativeSession { .. }
14002 | NodeRequest::CatalogNativeSessions { .. }
14003 | NodeRequest::PageNativeSessions { .. }
14004 | NodeRequest::PreviewNativeSession { .. }
14005 | NodeRequest::PreviewSessionRecord { .. }
14006 | NodeRequest::ForgetContextPack { .. }
14007 | NodeRequest::ResolveDurableContextPack { .. }
14008 | NodeRequest::ReadContextPack { .. }
14009 | NodeRequest::Shutdown => false,
14010 }
14011}
14012
14013fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14014 if request.requires_history_context_pack_capability() {
14015 return true;
14016 }
14017 if let Some(spec) = match request {
14018 NodeRequest::SpawnSpec { spec }
14019 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14020 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14021 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14022 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14023 _ => None,
14024 } {
14025 return shared
14026 .resolve_spawn_spec(spec)
14027 .map_or(true, |resolved| resolved.context_id.is_some());
14028 }
14029 match request {
14030 NodeRequest::Resume { session, .. }
14031 | NodeRequest::Prompt { session, .. }
14032 | NodeRequest::Paste { session, .. }
14033 | NodeRequest::Input { session, .. }
14034 | NodeRequest::TerminalBytes { session, .. }
14035 | NodeRequest::TerminalControl { session, .. }
14036 | NodeRequest::Resize { session, .. }
14037 | NodeRequest::Interrupt { session }
14038 | NodeRequest::Stop { session, .. }
14039 | NodeRequest::Remove { session }
14040 | NodeRequest::ResolveInteraction { session, .. }
14041 | NodeRequest::SetSessionMode { session, .. }
14042 | NodeRequest::SetSessionConfigOption { session, .. }
14043 | NodeRequest::SetSessionModel { session, .. } => shared
14044 .session_bindings
14045 .lock()
14046 .unwrap_or_else(|poisoned| poisoned.into_inner())
14047 .get(&session.session.instance_id)
14048 .filter(|binding| {
14049 binding.workspace_id == session.workspace_id
14050 && binding.generation == session.session.generation
14051 })
14052 .map_or(true, |binding| binding.context.is_some()),
14053 NodeRequest::RenameSessionRecord { record_id, .. }
14054 | NodeRequest::SetSessionTask { record_id, .. }
14055 | NodeRequest::ResumeSessionRecord { record_id, .. }
14056 | NodeRequest::ForgetSessionRecord { record_id } => shared
14057 .record(record_id)
14058 .map_or(true, |record| record.context.is_some()),
14059 NodeRequest::ArmHarnessMcpReservation { .. }
14060 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14061 | NodeRequest::ActivateHarnessMcpReservation { .. }
14062 | NodeRequest::AbortHarnessMcpReservation { .. }
14063 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14064 | NodeRequest::RejectHarnessMcpCall { .. }
14065 | NodeRequest::Snapshot
14066 | NodeRequest::Resync { .. }
14067 | NodeRequest::BeginDeliveryStage { .. }
14068 | NodeRequest::PutDeliveryBlobChunk { .. }
14069 | NodeRequest::CommitDeliveryStage { .. }
14070 | NodeRequest::AbortDeliveryStage { .. }
14071 | NodeRequest::BrowseHostDirectories { .. }
14072 | NodeRequest::InspectWorkspace { .. }
14073 | NodeRequest::ReadWorkspaceFile { .. }
14074 | NodeRequest::WriteWorkspaceFile { .. }
14075 | NodeRequest::CreateWorkspaceFile { .. }
14076 | NodeRequest::CreateWorkspaceDirectory { .. }
14077 | NodeRequest::ReadGitHistory { .. }
14078 | NodeRequest::ReadGitDiff { .. }
14079 | NodeRequest::AcquireController { .. }
14080 | NodeRequest::ReleaseController
14081 | NodeRequest::Spawn { .. }
14082 | NodeRequest::SpawnSpec { .. }
14083 | NodeRequest::RegisterWorkspace { .. }
14084 | NodeRequest::CreateStandaloneWorkspace { .. }
14085 | NodeRequest::UnregisterWorkspace { .. }
14086 | NodeRequest::CreateWorktree { .. }
14087 | NodeRequest::RemoveWorktree { .. }
14088 | NodeRequest::SpawnManagedWorktree { .. }
14089 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14090 | NodeRequest::CleanupManagedWorktree { .. }
14091 | NodeRequest::IndexProviderSession { .. }
14092 | NodeRequest::IndexNativeSession { .. }
14093 | NodeRequest::CatalogNativeSessions { .. }
14094 | NodeRequest::PageNativeSessions { .. }
14095 | NodeRequest::PreviewNativeSession { .. }
14096 | NodeRequest::PreviewSessionRecord { .. }
14097 | NodeRequest::DiscoverHistory { .. }
14098 | NodeRequest::LoadHistory { .. }
14099 | NodeRequest::ExportContextPackForSessionRecord { .. }
14100 | NodeRequest::ExportContextPack { .. }
14101 | NodeRequest::ForgetContextPack { .. }
14102 | NodeRequest::ResolveDurableContextPack { .. }
14103 | NodeRequest::ReadContextPack { .. }
14104 | NodeRequest::Shutdown => false,
14105 }
14106}
14107
14108fn request_requires_open_provider_ids_with(
14109 request: &NodeRequest,
14110 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14111 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14112) -> bool {
14113 match request {
14114 NodeRequest::Spawn { provider, .. } => !provider_id_is_legacy(provider),
14115 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14116 NodeRequest::CatalogNativeSessions { route, .. }
14117 | NodeRequest::PageNativeSessions { route, .. } => {
14118 !provider_id_is_legacy(&route.provider)
14119 }
14120 NodeRequest::PreviewNativeSession { selection, .. }
14121 | NodeRequest::IndexNativeSession { selection, .. } => {
14122 !provider_id_is_legacy(&selection.route.provider)
14123 }
14124 NodeRequest::SpawnSpec { .. }
14125 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14126 NodeRequest::SpawnManagedWorktree { .. }
14127 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14128 NodeRequest::Resume { session, .. }
14129 | NodeRequest::Prompt { session, .. }
14130 | NodeRequest::Paste { session, .. }
14131 | NodeRequest::Input { session, .. }
14132 | NodeRequest::TerminalBytes { session, .. }
14133 | NodeRequest::TerminalControl { session, .. }
14134 | NodeRequest::Resize { session, .. }
14135 | NodeRequest::Interrupt { session }
14136 | NodeRequest::Stop { session, .. }
14137 | NodeRequest::Remove { session }
14138 | NodeRequest::ResolveInteraction { session, .. }
14139 | NodeRequest::SetSessionMode { session, .. }
14140 | NodeRequest::SetSessionConfigOption { session, .. }
14141 | NodeRequest::SetSessionModel { session, .. }
14142 | NodeRequest::DiscoverHistory { session, .. }
14143 | NodeRequest::LoadHistory { session, .. }
14144 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14145 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14146 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14147 NodeRequest::RenameSessionRecord { record_id, .. }
14148 | NodeRequest::SetSessionTask { record_id, .. }
14149 | NodeRequest::ResumeSessionRecord { record_id, .. }
14150 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14151 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14152 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14153 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14154 NodeRequest::ArmHarnessMcpReservation { .. }
14155 | NodeRequest::ActivateHarnessMcpReservation { .. }
14156 | NodeRequest::AbortHarnessMcpReservation { .. }
14157 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14158 | NodeRequest::RejectHarnessMcpCall { .. }
14159 | NodeRequest::Snapshot
14160 | NodeRequest::Resync { .. }
14161 | NodeRequest::BeginDeliveryStage { .. }
14162 | NodeRequest::PutDeliveryBlobChunk { .. }
14163 | NodeRequest::CommitDeliveryStage { .. }
14164 | NodeRequest::AbortDeliveryStage { .. }
14165 | NodeRequest::BrowseHostDirectories { .. }
14166 | NodeRequest::InspectWorkspace { .. }
14167 | NodeRequest::ReadWorkspaceFile { .. }
14168 | NodeRequest::WriteWorkspaceFile { .. }
14169 | NodeRequest::CreateWorkspaceFile { .. }
14170 | NodeRequest::CreateWorkspaceDirectory { .. }
14171 | NodeRequest::ReadGitHistory { .. }
14172 | NodeRequest::ReadGitDiff { .. }
14173 | NodeRequest::AcquireController { .. }
14174 | NodeRequest::ReleaseController
14175 | NodeRequest::RegisterWorkspace { .. }
14176 | NodeRequest::CreateStandaloneWorkspace { .. }
14177 | NodeRequest::UnregisterWorkspace { .. }
14178 | NodeRequest::CreateWorktree { .. }
14179 | NodeRequest::RemoveWorktree { .. }
14180 | NodeRequest::CleanupManagedWorktree { .. }
14181 | NodeRequest::Shutdown => false,
14182 NodeRequest::ForgetContextPack { .. } => true,
14183 NodeRequest::ResolveDurableContextPack { .. }
14189 | NodeRequest::ReadContextPack { .. } => true,
14190 }
14191}
14192
14193fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14194 snapshot.enabled_providers.retain(provider_id_is_legacy);
14195 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14196 snapshot
14197 .provider_runtime_statuses
14198 .as_slice()
14199 .iter()
14200 .filter(|status| provider_id_is_legacy(status.provider()))
14201 .cloned(),
14202 )
14203 .expect("a filtered provider runtime projection remains valid");
14204 snapshot
14205 .session_records
14206 .retain(|record| provider_id_is_legacy(&record.provider));
14207 for workspace in &mut snapshot.workspaces {
14208 workspace
14209 .sessions
14210 .retain(|session| provider_id_is_legacy(&session.agent_id));
14211 }
14212}
14213
14214fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14215 for record in &mut snapshot.session_records {
14216 record.environment_profile = None;
14217 }
14218 if let Some(profiles) = snapshot
14219 .launch_inventory
14220 .as_mut()
14221 .and_then(|inventory| inventory.spawn_profiles.as_mut())
14222 {
14223 for profile in profiles {
14224 profile.environment_profile = None;
14225 }
14226 }
14227}
14228
14229fn project_event_without_child_environment_profile(
14230 mut envelope: NodeEventEnvelope,
14231) -> NodeEventEnvelope {
14232 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14233 record.environment_profile = None;
14234 }
14235 envelope
14236}
14237
14238fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14239 let contains_environment_profile = match reply.result.as_ref() {
14240 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14241 receipt.environment_profile.is_some()
14242 }
14243 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14244 receipt.spawn.environment_profile.is_some()
14245 }
14246 _ => false,
14247 };
14248 if contains_environment_profile {
14249 reply.result = Err(failure(
14250 NodeFailureCode::UnsupportedCapability,
14251 "child environment profiles were not negotiated",
14252 ));
14253 return;
14254 }
14255 let Ok(response) = reply.result.as_mut() else {
14256 return;
14257 };
14258 match response {
14259 NodeResponse::Snapshot { snapshot, .. } => {
14260 clear_snapshot_child_environment_profiles(snapshot);
14261 }
14262 NodeResponse::Resync { snapshot, events, .. } => {
14263 clear_snapshot_child_environment_profiles(snapshot);
14264 for event in events {
14265 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14266 record.environment_profile = None;
14267 }
14268 }
14269 }
14270 NodeResponse::SessionRecordUpdated { record }
14271 | NodeResponse::ProviderSessionIndexed { record }
14272 | NodeResponse::NativeSessionIndexed { record, .. }
14273 | NodeResponse::SessionRecordResumed { record, .. } => {
14274 record.environment_profile = None;
14275 }
14276 NodeResponse::Armed { .. }
14277 | NodeResponse::Spawned { .. }
14278 | NodeResponse::Activated { .. }
14279 | NodeResponse::Aborted { .. }
14280 | NodeResponse::ReplyChunkAccepted { .. }
14281 | NodeResponse::CallRejected { .. }
14282 | NodeResponse::DeliveryStageBegun { .. }
14283 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14284 | NodeResponse::DeliveryCommitted { .. }
14285 | NodeResponse::DeliveryStageAborted { .. }
14286 | NodeResponse::WorkspaceInspected { .. }
14287 | NodeResponse::HostDirectoriesBrowsed { .. }
14288 | NodeResponse::WorkspaceFileRead { .. }
14289 | NodeResponse::WorkspaceFileWritten { .. }
14290 | NodeResponse::WorkspaceFileCreated { .. }
14291 | NodeResponse::WorkspaceDirectoryCreated { .. }
14292 | NodeResponse::GitHistoryRead { .. }
14293 | NodeResponse::GitDiffRead { .. }
14294 | NodeResponse::Controller { .. }
14295 | NodeResponse::SpawnAccepted { .. }
14296 | NodeResponse::SpawnSpecAccepted { .. }
14297 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14298 | NodeResponse::ManagedWorktreeCleanup { .. }
14299 | NodeResponse::NativeSessionsCataloged { .. }
14300 | NodeResponse::NativeSessionsPaged { .. }
14301 | NodeResponse::NativeSessionPreviewed { .. }
14302 | NodeResponse::SessionRecordPreviewed { .. }
14303 | NodeResponse::HistoryDiscovered { .. }
14304 | NodeResponse::HistoryLoaded { .. }
14305 | NodeResponse::ContextPackForSessionRecordExported { .. }
14306 | NodeResponse::ContextPackExported { .. }
14307 | NodeResponse::ContextPackForgotten { .. }
14308 | NodeResponse::DurableContextPackResolved { .. }
14309 | NodeResponse::ContextPackBytesRead { .. }
14310 | NodeResponse::SessionRecordForgotten { .. }
14311 | NodeResponse::WorkspaceRegistered { .. }
14312 | NodeResponse::StandaloneWorkspaceCreated { .. }
14313 | NodeResponse::WorkspaceUnregistered { .. }
14314 | NodeResponse::WorktreeCreated { .. }
14315 | NodeResponse::WorktreeRemoved { .. }
14316 | NodeResponse::Accepted
14317 | NodeResponse::ShuttingDown => {}
14318 }
14319}
14320
14321fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14322 for record in &mut snapshot.session_records {
14323 record.bundle = None;
14324 }
14325}
14326
14327fn project_event_without_session_bundle(
14328 mut envelope: NodeEventEnvelope,
14329) -> NodeEventEnvelope {
14330 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14331 record.bundle = None;
14332 }
14333 envelope
14334}
14335
14336fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14337 let contains_bundle = match reply.result.as_ref() {
14338 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14339 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14340 receipt.spawn.bundle.is_some()
14341 }
14342 _ => false,
14343 };
14344 if contains_bundle {
14345 reply.result = Err(failure(
14346 NodeFailureCode::UnsupportedCapability,
14347 "session bundles were not negotiated",
14348 ));
14349 return;
14350 }
14351 let Ok(response) = reply.result.as_mut() else {
14352 return;
14353 };
14354 match response {
14355 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14356 NodeResponse::Resync { snapshot, events, .. } => {
14357 clear_snapshot_session_bundles(snapshot);
14358 for event in events {
14359 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14360 record.bundle = None;
14361 }
14362 }
14363 }
14364 NodeResponse::SessionRecordUpdated { record }
14365 | NodeResponse::ProviderSessionIndexed { record }
14366 | NodeResponse::NativeSessionIndexed { record, .. }
14367 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14368 NodeResponse::Armed { .. }
14369 | NodeResponse::Spawned { .. }
14370 | NodeResponse::Activated { .. }
14371 | NodeResponse::Aborted { .. }
14372 | NodeResponse::ReplyChunkAccepted { .. }
14373 | NodeResponse::CallRejected { .. }
14374 | NodeResponse::DeliveryStageBegun { .. }
14375 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14376 | NodeResponse::DeliveryCommitted { .. }
14377 | NodeResponse::DeliveryStageAborted { .. }
14378 | NodeResponse::WorkspaceInspected { .. }
14379 | NodeResponse::HostDirectoriesBrowsed { .. }
14380 | NodeResponse::WorkspaceFileRead { .. }
14381 | NodeResponse::WorkspaceFileWritten { .. }
14382 | NodeResponse::WorkspaceFileCreated { .. }
14383 | NodeResponse::WorkspaceDirectoryCreated { .. }
14384 | NodeResponse::GitHistoryRead { .. }
14385 | NodeResponse::GitDiffRead { .. }
14386 | NodeResponse::Controller { .. }
14387 | NodeResponse::SpawnAccepted { .. }
14388 | NodeResponse::SpawnSpecAccepted { .. }
14389 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14390 | NodeResponse::ManagedWorktreeCleanup { .. }
14391 | NodeResponse::NativeSessionsCataloged { .. }
14392 | NodeResponse::NativeSessionsPaged { .. }
14393 | NodeResponse::NativeSessionPreviewed { .. }
14394 | NodeResponse::SessionRecordPreviewed { .. }
14395 | NodeResponse::HistoryDiscovered { .. }
14396 | NodeResponse::HistoryLoaded { .. }
14397 | NodeResponse::ContextPackForSessionRecordExported { .. }
14398 | NodeResponse::ContextPackExported { .. }
14399 | NodeResponse::ContextPackForgotten { .. }
14400 | NodeResponse::DurableContextPackResolved { .. }
14401 | NodeResponse::ContextPackBytesRead { .. }
14402 | NodeResponse::SessionRecordForgotten { .. }
14403 | NodeResponse::WorkspaceRegistered { .. }
14404 | NodeResponse::StandaloneWorkspaceCreated { .. }
14405 | NodeResponse::WorkspaceUnregistered { .. }
14406 | NodeResponse::WorktreeCreated { .. }
14407 | NodeResponse::WorktreeRemoved { .. }
14408 | NodeResponse::Accepted
14409 | NodeResponse::ShuttingDown => {}
14410 }
14411}
14412
14413fn project_snapshot_history_for_wire(
14414 snapshot: &mut NodeSnapshot,
14415 include_history_metadata: bool,
14416) {
14417 for workspace in &mut snapshot.workspaces {
14418 for session in &mut workspace.sessions {
14419 if include_history_metadata {
14420 session.history.loaded = None;
14421 } else {
14422 session.history = Default::default();
14423 }
14424 }
14425 }
14426}
14427
14428fn project_event_history_for_wire(
14429 mut envelope: NodeEventEnvelope,
14430 include_history_metadata: bool,
14431) -> NodeEventEnvelope {
14432 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14433 for session in &mut workspace.sessions {
14434 if include_history_metadata {
14435 session.history.loaded = None;
14436 } else {
14437 session.history = Default::default();
14438 }
14439 }
14440 }
14441 envelope
14442}
14443
14444fn project_response_history_for_wire(
14445 reply: &mut ResponseEnvelope,
14446 include_history_metadata: bool,
14447) {
14448 let Ok(response) = reply.result.as_mut() else {
14449 return;
14450 };
14451 match response {
14452 NodeResponse::Snapshot { snapshot, .. } => {
14453 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14454 }
14455 NodeResponse::Resync {
14456 snapshot, events, ..
14457 } => {
14458 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14459 for event in events {
14460 *event = project_event_history_for_wire(
14461 event.clone(),
14462 include_history_metadata,
14463 );
14464 }
14465 }
14466 NodeResponse::WorkspaceRegistered { workspace }
14467 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14468 | NodeResponse::WorktreeCreated { workspace, .. } => {
14469 for session in &mut workspace.sessions {
14470 if include_history_metadata {
14471 session.history.loaded = None;
14472 } else {
14473 session.history = Default::default();
14474 }
14475 }
14476 }
14477 _ => {}
14478 }
14479}
14480
14481fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14482 for record in &mut snapshot.session_records {
14483 record.context_id = None;
14484 record.context = None;
14485 record.exported_context = None;
14486 }
14487}
14488
14489fn project_event_without_context_pack(
14490 mut envelope: NodeEventEnvelope,
14491) -> NodeEventEnvelope {
14492 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14493 record.context_id = None;
14494 record.context = None;
14495 record.exported_context = None;
14496 }
14497 envelope
14498}
14499
14500fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14501 let contains_context = match reply.result.as_ref() {
14502 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14503 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14504 receipt.spawn.context.is_some()
14505 }
14506 Ok(NodeResponse::HistoryDiscovered { .. })
14507 | Ok(NodeResponse::HistoryLoaded { .. })
14508 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14509 | Ok(NodeResponse::ContextPackExported { .. })
14510 | Ok(NodeResponse::ContextPackForgotten { .. })
14511 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14512 _ => false,
14513 };
14514 if contains_context {
14515 reply.result = Err(failure(
14516 NodeFailureCode::UnsupportedCapability,
14517 "history context packs were not negotiated",
14518 ));
14519 return;
14520 }
14521 let Ok(response) = reply.result.as_mut() else {
14522 return;
14523 };
14524 match response {
14525 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
14526 NodeResponse::Resync {
14527 snapshot, events, ..
14528 } => {
14529 clear_snapshot_context_packs(snapshot);
14530 for event in events {
14531 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14532 record.context_id = None;
14533 record.context = None;
14534 record.exported_context = None;
14535 }
14536 }
14537 }
14538 NodeResponse::SessionRecordUpdated { record }
14539 | NodeResponse::ProviderSessionIndexed { record }
14540 | NodeResponse::NativeSessionIndexed { record, .. }
14541 | NodeResponse::SessionRecordResumed { record, .. } => {
14542 record.context_id = None;
14543 record.context = None;
14544 record.exported_context = None;
14545 }
14546 _ => {}
14547 }
14548}
14549
14550fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
14551 let contains_open_record = match reply.result.as_ref() {
14552 Ok(NodeResponse::SessionRecordUpdated { record })
14553 | Ok(NodeResponse::ProviderSessionIndexed { record })
14554 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
14555 !provider_id_is_legacy(&record.provider)
14556 }
14557 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
14558 !provider_id_is_legacy(&selection.route.provider)
14559 || !provider_id_is_legacy(&record.provider)
14560 }
14561 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14562 !provider_id_is_legacy(&receipt.provider)
14563 }
14564 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14565 !provider_id_is_legacy(&receipt.spawn.provider)
14566 }
14567 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
14568 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
14569 !provider_id_is_legacy(&route.provider)
14570 }
14571 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
14572 !provider_id_is_legacy(&selection.route.provider)
14573 }
14574 _ => false,
14575 };
14576 if contains_open_record {
14577 reply.result = Err(failure(
14578 NodeFailureCode::UnsupportedCapability,
14579 "open provider IDs were not negotiated",
14580 ));
14581 return;
14582 }
14583 let Ok(response) = reply.result.as_mut() else {
14584 return;
14585 };
14586 match response {
14587 NodeResponse::Snapshot { snapshot, .. } => {
14588 project_snapshot_legacy_provider_ids(snapshot);
14589 }
14590 NodeResponse::Resync { snapshot, events, .. } => {
14591 project_snapshot_legacy_provider_ids(snapshot);
14592 *events = std::mem::take(events)
14593 .into_iter()
14594 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
14595 .collect();
14596 }
14597 NodeResponse::WorkspaceRegistered { workspace }
14598 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14599 | NodeResponse::WorktreeCreated { workspace, .. } => {
14600 workspace
14601 .sessions
14602 .retain(|session| provider_id_is_legacy(&session.agent_id));
14603 }
14604 NodeResponse::Armed { .. }
14605 | NodeResponse::Spawned { .. }
14606 | NodeResponse::Activated { .. }
14607 | NodeResponse::Aborted { .. }
14608 | NodeResponse::ReplyChunkAccepted { .. }
14609 | NodeResponse::CallRejected { .. }
14610 | NodeResponse::DeliveryStageBegun { .. }
14611 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14612 | NodeResponse::DeliveryCommitted { .. }
14613 | NodeResponse::DeliveryStageAborted { .. }
14614 | NodeResponse::WorkspaceInspected { .. }
14615 | NodeResponse::HostDirectoriesBrowsed { .. }
14616 | NodeResponse::WorkspaceFileRead { .. }
14617 | NodeResponse::WorkspaceFileWritten { .. }
14618 | NodeResponse::WorkspaceFileCreated { .. }
14619 | NodeResponse::WorkspaceDirectoryCreated { .. }
14620 | NodeResponse::GitHistoryRead { .. }
14621 | NodeResponse::GitDiffRead { .. }
14622 | NodeResponse::Controller { .. }
14623 | NodeResponse::SpawnAccepted { .. }
14624 | NodeResponse::SpawnSpecAccepted { .. }
14625 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14626 | NodeResponse::ManagedWorktreeCleanup { .. }
14627 | NodeResponse::NativeSessionsCataloged { .. }
14628 | NodeResponse::NativeSessionsPaged { .. }
14629 | NodeResponse::NativeSessionPreviewed { .. }
14630 | NodeResponse::SessionRecordPreviewed { .. }
14631 | NodeResponse::HistoryDiscovered { .. }
14632 | NodeResponse::HistoryLoaded { .. }
14633 | NodeResponse::ContextPackForSessionRecordExported { .. }
14634 | NodeResponse::ContextPackExported { .. }
14635 | NodeResponse::ContextPackForgotten { .. }
14636 | NodeResponse::DurableContextPackResolved { .. }
14637 | NodeResponse::ContextPackBytesRead { .. }
14638 | NodeResponse::SessionRecordUpdated { .. }
14639 | NodeResponse::ProviderSessionIndexed { .. }
14640 | NodeResponse::NativeSessionIndexed { .. }
14641 | NodeResponse::SessionRecordResumed { .. }
14642 | NodeResponse::SessionRecordForgotten { .. }
14643 | NodeResponse::WorkspaceUnregistered { .. }
14644 | NodeResponse::WorktreeRemoved { .. }
14645 | NodeResponse::Accepted
14646 | NodeResponse::ShuttingDown => {}
14647 }
14648}
14649
14650fn project_event_legacy_provider_ids(
14651 shared: &NodeShared,
14652 mut envelope: NodeEventEnvelope,
14653) -> Option<NodeEventEnvelope> {
14654 let include = match &mut envelope.event {
14655 NodeEvent::HarnessMcpReadCall { .. } => true,
14656 NodeEvent::Control { address, .. }
14657 | NodeEvent::AgentStream { address, .. }
14658 | NodeEvent::TerminalFrame { address, .. } => shared
14659 .validate_address(address)
14660 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
14661 NodeEvent::WorkspaceAdded { workspace } => {
14662 workspace
14663 .sessions
14664 .retain(|session| provider_id_is_legacy(&session.agent_id));
14665 true
14666 }
14667 NodeEvent::SessionRecordUpserted { record } => {
14668 provider_id_is_legacy(&record.provider)
14669 }
14670 NodeEvent::SessionRecordHistorySummarized { .. } => false,
14671 NodeEvent::SessionRecordRemoved { .. } => shared
14672 .history
14673 .lock()
14674 .unwrap_or_else(|poisoned| poisoned.into_inner())
14675 .removed_record_providers
14676 .get(&envelope.sequence)
14677 .is_some_and(provider_id_is_legacy),
14678 NodeEvent::ControllerChanged { .. }
14679 | NodeEvent::ManagedWorktreeUpserted { .. }
14680 | NodeEvent::ManagedWorktreeRemoved { .. }
14681 | NodeEvent::WorkspaceRemoved { .. }
14682 | NodeEvent::ResyncRequired { .. } => true,
14683 };
14684 include.then_some(envelope)
14685}
14686
14687fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
14688 let Ok(response) = reply.result.as_mut() else {
14689 return;
14690 };
14691 match response {
14692 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14693 snapshot.provider_runtime_statuses.clear();
14694 }
14695 NodeResponse::Armed { .. }
14696 | NodeResponse::Spawned { .. }
14697 | NodeResponse::Activated { .. }
14698 | NodeResponse::Aborted { .. }
14699 | NodeResponse::ReplyChunkAccepted { .. }
14700 | NodeResponse::CallRejected { .. }
14701 | NodeResponse::DeliveryStageBegun { .. }
14702 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14703 | NodeResponse::DeliveryCommitted { .. }
14704 | NodeResponse::DeliveryStageAborted { .. }
14705 | NodeResponse::WorkspaceInspected { .. }
14706 | NodeResponse::HostDirectoriesBrowsed { .. }
14707 | NodeResponse::WorkspaceFileRead { .. }
14708 | NodeResponse::WorkspaceFileWritten { .. }
14709 | NodeResponse::WorkspaceFileCreated { .. }
14710 | NodeResponse::WorkspaceDirectoryCreated { .. }
14711 | NodeResponse::GitHistoryRead { .. }
14712 | NodeResponse::GitDiffRead { .. }
14713 | NodeResponse::Controller { .. }
14714 | NodeResponse::SpawnAccepted { .. }
14715 | NodeResponse::SpawnSpecAccepted { .. }
14716 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14717 | NodeResponse::ManagedWorktreeCleanup { .. }
14718 | NodeResponse::NativeSessionsCataloged { .. }
14719 | NodeResponse::NativeSessionsPaged { .. }
14720 | NodeResponse::NativeSessionPreviewed { .. }
14721 | NodeResponse::SessionRecordPreviewed { .. }
14722 | NodeResponse::HistoryDiscovered { .. }
14723 | NodeResponse::HistoryLoaded { .. }
14724 | NodeResponse::ContextPackForSessionRecordExported { .. }
14725 | NodeResponse::ContextPackExported { .. }
14726 | NodeResponse::ContextPackForgotten { .. }
14727 | NodeResponse::DurableContextPackResolved { .. }
14728 | NodeResponse::ContextPackBytesRead { .. }
14729 | NodeResponse::SessionRecordUpdated { .. }
14730 | NodeResponse::ProviderSessionIndexed { .. }
14731 | NodeResponse::NativeSessionIndexed { .. }
14732 | NodeResponse::SessionRecordResumed { .. }
14733 | NodeResponse::SessionRecordForgotten { .. }
14734 | NodeResponse::WorkspaceRegistered { .. }
14735 | NodeResponse::StandaloneWorkspaceCreated { .. }
14736 | NodeResponse::WorkspaceUnregistered { .. }
14737 | NodeResponse::WorktreeCreated { .. }
14738 | NodeResponse::WorktreeRemoved { .. }
14739 | NodeResponse::Accepted
14740 | NodeResponse::ShuttingDown => {}
14741 }
14742}
14743
14744struct ControllerReleaseGuard {
14745 shared: Arc<NodeShared>,
14746 connection_id: u64,
14747}
14748
14749impl Drop for ControllerReleaseGuard {
14750 fn drop(&mut self) {
14751 self.shared.release_controller(self.connection_id);
14752 }
14753}
14754
14755struct AbortTaskOnDrop(AbortHandle);
14756
14757impl Drop for AbortTaskOnDrop {
14758 fn drop(&mut self) {
14759 self.0.abort();
14760 }
14761}
14762
14763fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
14768 std::env::var(name)
14769 .ok()
14770 .and_then(|value| value.parse::<u64>().ok())
14771 .filter(|value| *value > 0)
14772 .unwrap_or(default)
14773 .min(max)
14774}
14775
14776fn workspace_inspection_time_budget_ms() -> u64 {
14777 env_config_bounded(
14778 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
14779 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
14780 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
14781 )
14782}
14783
14784fn workspace_inspection_entry_cap() -> usize {
14785 env_config_bounded(
14786 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
14787 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
14788 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
14789 ) as usize
14790}
14791
14792struct WorkspaceWalkBudget {
14798 deadline: Instant,
14799 entry_cap: usize,
14800 entries_visited: u64,
14801 time_budget_exceeded: bool,
14802 entry_cap_exceeded: bool,
14803}
14804
14805impl WorkspaceWalkBudget {
14806 fn new(deadline: Instant, entry_cap: usize) -> Self {
14807 Self {
14808 deadline,
14809 entry_cap,
14810 entries_visited: 0,
14811 time_budget_exceeded: false,
14812 entry_cap_exceeded: false,
14813 }
14814 }
14815
14816 fn is_exhausted(&self) -> bool {
14817 self.time_budget_exceeded || self.entry_cap_exceeded
14818 }
14819
14820 fn record_visit(&mut self) -> bool {
14823 self.entries_visited += 1;
14824 if self.entries_visited as usize >= self.entry_cap {
14825 self.entry_cap_exceeded = true;
14826 }
14827 if Instant::now() >= self.deadline {
14828 self.time_budget_exceeded = true;
14829 }
14830 self.is_exhausted()
14831 }
14832}
14833
14834fn collect_workspace_entries(
14835 root: &Path,
14836 deadline: Instant,
14837 entry_cap: usize,
14838) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
14839 let mut entries = Vec::new();
14840 let mut truncated = false;
14841 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
14842 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
14843 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
14847 (entries, truncated, budget)
14848}
14849
14850fn workspace_inspection_truncation(
14854 walk_budget: &WorkspaceWalkBudget,
14855 git_time_budget_exceeded: bool,
14856 elapsed: Duration,
14857) -> Option<WorkspaceInspectionTruncationV1> {
14858 if !walk_budget.time_budget_exceeded
14859 && !walk_budget.entry_cap_exceeded
14860 && !git_time_budget_exceeded
14861 {
14862 return None;
14863 }
14864 Some(WorkspaceInspectionTruncationV1 {
14865 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
14866 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
14867 git_time_budget_exceeded,
14868 entries_visited: walk_budget.entries_visited,
14869 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
14870 })
14871}
14872
14873fn walk_workspace_directory(
14874 directory: &Path,
14875 relative_directory: Option<&RepositoryPath>,
14876 depth: usize,
14877 entries: &mut Vec<WorkspaceEntry>,
14878 truncated: &mut bool,
14879 budget: &mut WorkspaceWalkBudget,
14880) {
14881 if budget.is_exhausted() {
14882 *truncated = true;
14883 return;
14884 }
14885 let read_dir = match std::fs::read_dir(directory) {
14886 Ok(read_dir) => read_dir,
14887 Err(_) => {
14888 *truncated = true;
14889 return;
14890 }
14891 };
14892 let mut children = Vec::new();
14893 for child in read_dir {
14894 match child {
14895 Ok(child) => children.push(child),
14896 Err(_) => *truncated = true,
14897 }
14898 }
14899 children.sort_by_key(|child| child.file_name());
14900 for child in children {
14901 if budget.record_visit() {
14902 *truncated = true;
14903 return;
14904 }
14905 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
14906 *truncated = true;
14907 return;
14908 }
14909 let file_type = match child.file_type() {
14910 Ok(file_type) => file_type,
14911 Err(_) => {
14912 *truncated = true;
14913 continue;
14914 }
14915 };
14916 if file_type.is_symlink() {
14917 continue;
14918 }
14919 let name = match child.file_name().into_string() {
14920 Ok(name) => name,
14921 Err(_) => {
14922 *truncated = true;
14923 continue;
14924 }
14925 };
14926 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
14927 continue;
14928 }
14929 let relative_path = match relative_directory {
14930 None => windows_repository_path(name),
14931 Some(parent) => parent
14932 .as_utf8()
14933 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
14934 };
14935 let Some(relative_path) = relative_path else {
14936 *truncated = true;
14937 continue;
14938 };
14939 let kind = if file_type.is_dir() {
14940 WorkspaceEntryKind::Directory
14941 } else if file_type.is_file() {
14942 WorkspaceEntryKind::File
14943 } else {
14944 continue;
14945 };
14946 entries.push(WorkspaceEntry {
14947 relative_path: relative_path.clone(),
14948 kind,
14949 });
14950 if file_type.is_dir() {
14951 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
14952 walk_workspace_directory(
14953 &child.path(),
14954 Some(&relative_path),
14955 depth + 1,
14956 entries,
14957 truncated,
14958 budget,
14959 );
14960 } else {
14961 *truncated = true;
14962 }
14963 }
14964 }
14965}
14966
14967fn windows_repository_path(value: String) -> Option<RepositoryPath> {
14968 if value.contains('\\') {
14969 return None;
14970 }
14971 RepositoryPath::utf8(value).ok()
14972}
14973
14974fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
14975 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
14976}
14977
14978fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
14987 if name.eq_ignore_ascii_case(".git")
14988 || name.eq_ignore_ascii_case("target")
14989 || name.eq_ignore_ascii_case("node_modules")
14990 {
14991 return true;
14992 }
14993 path.join("CACHEDIR.TAG").is_file()
14994}
14995
14996fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
14997 matches!(
14998 status,
14999 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15000 )
15001}
15002
15003#[derive(Clone, Debug, Eq, PartialEq)]
15004enum ContextPackRepositoryHead {
15005 NotRepository,
15006 Unborn,
15007 Commit(String),
15008}
15009
15010#[derive(Clone, Debug, Eq, PartialEq)]
15011struct ContextPackRepositoryObservation {
15012 head: ContextPackRepositoryHead,
15013 git: GitSnapshot,
15014 files: Vec<ContextPackRepositoryFileSource>,
15015}
15016
15017fn stable_context_pack_repository(
15018 first: ContextPackRepositoryObservation,
15019 second: ContextPackRepositoryObservation,
15020) -> Result<ContextPackRepository, NodeFailure> {
15021 if first != second {
15022 return Err(failure(
15023 NodeFailureCode::ContextPackMaterializationFailed,
15024 "context repository changed during bounded capture",
15025 ));
15026 }
15027 Ok(ContextPackRepository::from_sources(
15028 &second.git,
15029 second.files,
15030 ))
15031}
15032
15033fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15040 if Instant::now() < deadline {
15041 return false;
15042 }
15043 append_git_diagnostic(
15044 snapshot,
15045 &format!("{stage} skipped: workspace inspection time budget exhausted"),
15046 );
15047 true
15048}
15049
15050async fn run_git_bounded_with_deadline(
15059 root: &str,
15060 arguments: &[&str],
15061 output_limit: usize,
15062 deadline: Instant,
15063) -> io::Result<GitCommandOutput> {
15064 let remaining = deadline.checked_duration_since(Instant::now());
15065 let timeout_ms = remaining
15066 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15067 .unwrap_or(0)
15068 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15069 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15070}
15071
15072async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15079 let mut snapshot = GitSnapshot {
15080 is_repository: false,
15081 branch: None,
15082 status: Vec::new(),
15083 recent_commits: Vec::new(),
15084 worktrees: Vec::new(),
15085 managed_worktree: None,
15086 truncated: false,
15087 diagnostic: None,
15088 };
15089 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15090 return (snapshot, true);
15091 }
15092 let repository = match run_git_bounded_with_deadline(
15093 root,
15094 &["rev-parse", "--is-inside-work-tree"],
15095 4 * 1_024,
15096 deadline,
15097 )
15098 .await
15099 {
15100 Ok(output) => output,
15101 Err(error) => {
15102 append_git_diagnostic(
15103 &mut snapshot,
15104 &format!("git inspection unavailable: {error}"),
15105 );
15106 return (snapshot, false);
15107 }
15108 };
15109 snapshot.truncated |= repository.truncated;
15110 if repository.timed_out {
15111 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15112 return (snapshot, false);
15113 }
15114 if !repository.success
15115 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15116 {
15117 let stderr = String::from_utf8_lossy(&repository.stderr);
15118 if !stderr.to_ascii_lowercase().contains("not a git repository") {
15119 append_git_diagnostic(&mut snapshot, stderr.trim());
15120 }
15121 return (snapshot, false);
15122 }
15123 snapshot.is_repository = true;
15124
15125 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15126 return (snapshot, true);
15127 }
15128 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15129 Ok(output) => {
15130 snapshot.truncated |= output.truncated;
15131 if output.timed_out {
15132 append_git_diagnostic(&mut snapshot, "git branch query timed out");
15133 } else if output.success {
15134 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15135 if !branch.is_empty() {
15136 snapshot.branch = Some(truncate_git_field(
15137 &branch,
15138 MAX_REPOSITORY_PATH_BYTES,
15139 &mut snapshot.truncated,
15140 ));
15141 }
15142 }
15143 }
15144 Err(error) => append_git_diagnostic(
15145 &mut snapshot,
15146 &format!("git branch query failed: {error}"),
15147 ),
15148 }
15149 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15150 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15151 snapshot.truncated |= output.truncated;
15152 if output.success && !output.timed_out {
15153 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15154 if !head.is_empty() {
15155 snapshot.branch = Some(format!("detached:{head}"));
15156 }
15157 }
15158 }
15159 }
15160
15161 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15162 return (snapshot, true);
15163 }
15164 match run_git_bounded_with_deadline(
15165 root,
15166 &[
15167 "status",
15168 "--porcelain=v1",
15169 "-z",
15170 "--renames",
15171 "--untracked-files=normal",
15172 "--",
15173 ".",
15174 ],
15175 GIT_OUTPUT_MAX_BYTES,
15176 deadline,
15177 )
15178 .await
15179 {
15180 Ok(output) => {
15181 snapshot.truncated |= output.truncated;
15182 if output.timed_out {
15183 append_git_diagnostic(&mut snapshot, "git status timed out");
15184 } else if output.success {
15185 parse_git_status(&output.stdout, &mut snapshot);
15186 } else {
15187 append_git_diagnostic(
15188 &mut snapshot,
15189 String::from_utf8_lossy(&output.stderr).trim(),
15190 );
15191 }
15192 }
15193 Err(error) => append_git_diagnostic(
15194 &mut snapshot,
15195 &format!("git status failed: {error}"),
15196 ),
15197 }
15198
15199 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15200 return (snapshot, true);
15201 }
15202 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15203 match run_git_bounded_with_deadline(
15204 root,
15205 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15206 16 * 1_024,
15207 deadline,
15208 )
15209 .await
15210 {
15211 Ok(output) => {
15212 snapshot.truncated |= output.truncated;
15213 if output.timed_out {
15214 append_git_diagnostic(&mut snapshot, "git log timed out");
15215 } else if output.success {
15216 parse_git_commits(&output.stdout, &mut snapshot);
15217 } else {
15218 let stderr = String::from_utf8_lossy(&output.stderr);
15219 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15220 append_git_diagnostic(&mut snapshot, stderr.trim());
15221 }
15222 }
15223 }
15224 Err(error) => append_git_diagnostic(
15225 &mut snapshot,
15226 &format!("git log failed: {error}"),
15227 ),
15228 }
15229
15230 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15231 return (snapshot, true);
15232 }
15233 match list_git_worktrees_with_deadline(root, deadline).await {
15234 Ok(worktrees) => {
15235 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15236 }
15237 Err(error) => append_git_diagnostic(
15238 &mut snapshot,
15239 &format!("git worktree list failed: {}", error.message),
15240 ),
15241 }
15242 (snapshot, false)
15243}
15244
15245fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15246 let mut remaining = output;
15247 while !remaining.is_empty() {
15248 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15249 snapshot.truncated = true;
15250 break;
15251 }
15252 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15253 snapshot.truncated = true;
15254 break;
15255 };
15256 let record = &remaining[..record_end];
15257 remaining = &remaining[record_end + 1..];
15258 let has_previous_path = record
15259 .get(..2)
15260 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15261 let previous_path_bytes = if has_previous_path {
15262 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15263 snapshot.truncated = true;
15264 break;
15265 };
15266 let previous = &remaining[..previous_end];
15267 remaining = &remaining[previous_end + 1..];
15268 Some(previous)
15269 } else {
15270 None
15271 };
15272 if record.len() < 4
15273 || record[2] != b' '
15274 || !is_git_status_code(record[0])
15275 || !is_git_status_code(record[1])
15276 {
15277 snapshot.truncated = true;
15278 continue;
15279 }
15280 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15281 snapshot.truncated = true;
15282 continue;
15283 };
15284 let previous_path = match previous_path_bytes {
15285 Some(previous) => {
15286 let Some(previous) = windows_repository_path_from_bytes(previous) else {
15287 snapshot.truncated = true;
15288 continue;
15289 };
15290 Some(previous)
15291 }
15292 None => None,
15293 };
15294 snapshot.status.push(GitStatusEntry {
15295 index_status: (record[0] as char).to_string(),
15296 worktree_status: (record[1] as char).to_string(),
15297 path,
15298 previous_path,
15299 });
15300 }
15301}
15302
15303fn is_git_status_code(byte: u8) -> bool {
15304 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15305}
15306
15307fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15308 for line in String::from_utf8_lossy(output).lines() {
15309 let Some((id, summary)) = line.split_once('\u{1f}') else {
15310 continue;
15311 };
15312 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15313 snapshot.truncated = true;
15314 break;
15315 }
15316 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15317 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15318 snapshot.recent_commits.push(GitCommitSummary { id, summary });
15319 }
15320}
15321
15322fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15323 if value.len() <= max_bytes {
15324 return value.to_owned();
15325 }
15326 *truncated = true;
15327 let mut boundary = max_bytes;
15328 while boundary > 0 && !value.is_char_boundary(boundary) {
15329 boundary -= 1;
15330 }
15331 value[..boundary].to_owned()
15332}
15333
15334fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15335 if message.is_empty() {
15336 return;
15337 }
15338 let normalized = message
15339 .chars()
15340 .map(|character| if character.is_control() { ' ' } else { character })
15341 .collect::<String>();
15342 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15343 if !diagnostic.is_empty() {
15344 diagnostic.push_str("; ");
15345 }
15346 diagnostic.push_str(normalized.trim());
15347 snapshot.diagnostic = Some(truncate_git_field(
15348 &diagnostic,
15349 GIT_DIAGNOSTIC_MAX_BYTES,
15350 &mut snapshot.truncated,
15351 ));
15352}
15353
15354async fn run_git_bounded(
15355 root: &str,
15356 arguments: &[&str],
15357 output_limit: usize,
15358) -> io::Result<GitCommandOutput> {
15359 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15360}
15361
15362async fn read_git_history_bounded(
15363 root: &str,
15364 path: Option<&RepositoryPath>,
15365 before: Option<&GitObjectId>,
15366 limit: u16,
15367) -> Result<GitHistoryPage, NodeFailure> {
15368 let path = path
15369 .map(|path| {
15370 path.as_utf8().ok_or_else(|| {
15371 failure(
15372 NodeFailureCode::InvalidRepositoryPath,
15373 "git history path must use UTF-8",
15374 )
15375 })
15376 })
15377 .transpose()?
15378 .unwrap_or(".");
15379 let count = (usize::from(limit) + 1).to_string();
15380 let start = before
15381 .map(|revision| format!("{}^", revision.as_str()))
15382 .unwrap_or_else(|| "HEAD".to_owned());
15383 let arguments = [
15384 "--no-pager",
15385 "log",
15386 "--no-decorate",
15387 "--date=iso-strict",
15388 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15389 "-n",
15390 count.as_str(),
15391 start.as_str(),
15392 "--",
15393 path,
15394 ];
15395 let output = run_git_bounded(root, &arguments, 256 * 1_024)
15396 .await
15397 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15398 if output.timed_out {
15399 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15400 }
15401 if !output.success {
15402 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15403 }
15404 let mut commits = parse_git_history(&output.stdout);
15405 let has_more = commits.len() > usize::from(limit) || output.truncated;
15406 commits.truncate(usize::from(limit));
15407 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15408 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15409}
15410
15411fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15412 String::from_utf8_lossy(output)
15413 .split('\u{1e}')
15414 .filter_map(|record| {
15415 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15416 if fields.len() != 9 {
15417 return None;
15418 }
15419 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15420 let parents = if fields[1].is_empty() {
15421 Vec::new()
15422 } else {
15423 fields[1]
15424 .split(' ')
15425 .map(|value| GitObjectId::new(value.to_owned()))
15426 .collect::<Result<Vec<_>, _>>()
15427 .ok()?
15428 };
15429 Some(GitCommitDetails {
15430 id,
15431 parents,
15432 subject: bounded_git_text(fields[2], 512),
15433 author_name: bounded_git_text(fields[3], 256),
15434 author_email: bounded_git_text(fields[4], 320),
15435 authored_at: bounded_git_text(fields[5], 64),
15436 committer_name: bounded_git_text(fields[6], 256),
15437 committer_email: bounded_git_text(fields[7], 320),
15438 committed_at: bounded_git_text(fields[8], 64),
15439 signature_status: GitSignatureStatus::NoSignature,
15440 signer: None,
15441 })
15442 })
15443 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15444 .collect()
15445}
15446
15447fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15448 let normalized = value
15449 .chars()
15450 .map(|character| if character.is_control() { ' ' } else { character })
15451 .collect::<String>();
15452 if normalized.len() <= max_bytes {
15453 return normalized;
15454 }
15455 let mut boundary = max_bytes;
15456 while boundary > 0 && !normalized.is_char_boundary(boundary) {
15457 boundary -= 1;
15458 }
15459 normalized[..boundary].to_owned()
15460}
15461
15462async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15463 let path = request.path.as_ref().map(|path| {
15464 path.as_utf8()
15465 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15466 }).transpose()?;
15467 let mut arguments = match &request.mode {
15468 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15469 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15470 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15471 };
15472 if let GitDiffMode::Commit { revision } = &request.mode {
15473 arguments.push(revision.as_str());
15474 }
15475 arguments.push("--");
15476 if let Some(path) = path {
15477 arguments.push(path);
15478 }
15479 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15480 .await
15481 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15482 if output.timed_out {
15483 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15484 }
15485 if !output.success {
15486 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15487 }
15488 Ok(GitDiff {
15489 mode: request.mode,
15490 path: request.path,
15491 text: String::from_utf8_lossy(&output.stdout).into_owned(),
15492 truncated: output.truncated,
15493 })
15494}
15495
15496async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15497 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15498 ResponseEnvelope { request_id: envelope.request_id, result }
15499}
15500
15501async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15502 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15503 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15504 }
15505 let read_only = matches!(
15506 &request,
15507 NodeRequest::Snapshot
15508 | NodeRequest::Resync { .. }
15509 | NodeRequest::BrowseHostDirectories { .. }
15510 | NodeRequest::InspectWorkspace { .. }
15511 | NodeRequest::ReadWorkspaceFile { .. }
15512 | NodeRequest::ReadGitHistory { .. }
15513 | NodeRequest::ReadGitDiff { .. }
15514 | NodeRequest::CatalogNativeSessions { .. }
15515 | NodeRequest::PageNativeSessions { .. }
15516 | NodeRequest::PreviewNativeSession { .. }
15517 | NodeRequest::PreviewSessionRecord { .. }
15518 );
15519 let _mutation_guard = if read_only {
15520 None
15521 } else {
15522 let guard = shared.mutation_gate.lock().await;
15523 if shared.shutdown.load(Ordering::Acquire) {
15524 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
15525 }
15526 Some(guard)
15527 };
15528 match request {
15529 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
15530 event_sequence: shared.current_sequence(),
15531 controller: shared.controller_state(),
15532 snapshot: shared.snapshot(),
15533 }),
15534 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
15535 NodeRequest::ArmHarnessMcpReservation {
15536 reservation_id,
15537 activation_digest,
15538 spawn_spec,
15539 launch,
15540 expires_at_unix_ms,
15541 } => {
15542 shared.require_controller(connection_id, role)?;
15543 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15544 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15545 })?;
15546 let expires_at_unix_ms = registry
15547 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
15548 .await
15549 .map_err(harness_mcp_failure)?;
15550 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
15551 }
15552 NodeRequest::SpawnSpecWithHarnessMcp {
15553 reservation_id,
15554 activation_digest,
15555 spec,
15556 deadline_unix_ms,
15557 } => {
15558 shared.require_controller(connection_id, role)?;
15559 let receipt = shared
15560 .spawn_from_spec_with_harness_mcp(
15561 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
15562 )
15563 .await?;
15564 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
15565 }
15566 NodeRequest::ActivateHarnessMcpReservation {
15567 reservation_id,
15568 activation_digest,
15569 record_id,
15570 session,
15571 } => {
15572 shared.require_controller(connection_id, role)?;
15573 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
15574 current.instance_id == session.session.instance_id
15575 && current.generation == session.session.generation
15576 }).and_then(|current| current.process_id).ok_or_else(|| {
15577 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
15578 })?;
15579 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15580 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15581 })?.activate(
15582 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
15583 ).map_err(harness_mcp_failure)?;
15584 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
15585 }
15586 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
15587 shared.require_controller(connection_id, role)?;
15588 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15589 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15590 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
15591 if let Some(instance_id) = instance_id {
15592 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
15593 }
15594 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
15595 }
15596 NodeRequest::PutHarnessMcpReplyChunk {
15597 reservation_id, activation_digest, record_id, session, call_id,
15598 offset, final_chunk, chunk_hex,
15599 } => {
15600 shared.require_controller(connection_id, role)?;
15601 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15602 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15603 })?.put_reply_chunk(
15604 &reservation_id, &activation_digest, &record_id, &session, &call_id,
15605 offset, final_chunk, &chunk_hex,
15606 ).map_err(harness_mcp_failure)?;
15607 Ok(NodeResponse::ReplyChunkAccepted {
15608 reservation_id, activation_digest, record_id, session, call_id,
15609 next_offset, completed,
15610 })
15611 }
15612 NodeRequest::RejectHarnessMcpCall {
15613 reservation_id, activation_digest, record_id, session, call_id, reason,
15614 } => {
15615 shared.require_controller(connection_id, role)?;
15616 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15617 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15618 })?.reject_call(
15619 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
15620 ).map_err(harness_mcp_failure)?;
15621 Ok(NodeResponse::CallRejected {
15622 reservation_id, activation_digest, record_id, session, call_id,
15623 })
15624 }
15625 NodeRequest::BeginDeliveryStage { manifest } => {
15626 shared.require_controller(connection_id, role)?;
15627 shared.begin_delivery_stage(manifest)
15628 }
15629 NodeRequest::PutDeliveryBlobChunk {
15630 stage_id,
15631 blob_digest,
15632 offset,
15633 chunk_hex,
15634 } => {
15635 shared.require_controller(connection_id, role)?;
15636 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
15637 }
15638 NodeRequest::CommitDeliveryStage { stage_id } => {
15639 shared.require_controller(connection_id, role)?;
15640 let receipt = shared.commit_delivery_stage(stage_id)?;
15641 Ok(NodeResponse::DeliveryCommitted { receipt })
15642 }
15643 NodeRequest::AbortDeliveryStage { stage_id } => {
15644 shared.require_controller(connection_id, role)?;
15645 shared.abort_delivery_stage(&stage_id)?;
15646 Ok(NodeResponse::DeliveryStageAborted { stage_id })
15647 }
15648 NodeRequest::BrowseHostDirectories { directory, after } => {
15649 let listing = shared.browse_host_directories(directory, after).await?;
15650 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
15651 }
15652 NodeRequest::InspectWorkspace { workspace_id } => {
15653 let inspection = shared.inspect_workspace(workspace_id).await?;
15654 Ok(NodeResponse::WorkspaceInspected { inspection })
15655 }
15656 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
15657 let file = shared.read_workspace_file(workspace_id, path).await?;
15658 Ok(NodeResponse::WorkspaceFileRead { file })
15659 }
15660 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
15661 shared.require_controller(connection_id, role)?;
15662 let file = shared
15663 .write_workspace_file(workspace_id, path, expected_revision, text)
15664 .await?;
15665 Ok(NodeResponse::WorkspaceFileWritten { file })
15666 }
15667 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
15668 shared.require_controller(connection_id, role)?;
15669 let file = shared.create_workspace_file(workspace_id, path).await?;
15670 Ok(NodeResponse::WorkspaceFileCreated { file })
15671 }
15672 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
15673 shared.require_controller(connection_id, role)?;
15674 let entry = shared
15675 .create_workspace_directory(workspace_id.clone(), path)
15676 .await?;
15677 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
15678 }
15679 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
15680 if role != ClientRole::Operator {
15681 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
15682 }
15683 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
15684 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
15685 }
15686 let page = shared
15687 .read_git_history(workspace_id.clone(), path, before, limit)
15688 .await?;
15689 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
15690 }
15691 NodeRequest::ReadGitDiff { workspace_id, request } => {
15692 if role != ClientRole::Operator {
15693 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
15694 }
15695 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
15696 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
15697 }
15698 NodeRequest::CatalogNativeSessions { route, limit } => {
15699 if role != ClientRole::Operator {
15700 return Err(failure(
15701 NodeFailureCode::ObserverReadOnly,
15702 "native session catalog requires an operator connection",
15703 ));
15704 }
15705 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
15706 return Err(failure(
15707 NodeFailureCode::InvalidRequest,
15708 "native session catalog limit is invalid",
15709 ));
15710 }
15711 let (entries, summary) = shared
15712 .catalog_native_sessions(route.clone(), limit)
15713 .await?;
15714 Ok(NodeResponse::NativeSessionsCataloged {
15715 route,
15716 entries,
15717 summary: Some(summary),
15718 })
15719 }
15720 NodeRequest::PageNativeSessions {
15721 route,
15722 window,
15723 catalog_revision,
15724 recent_cutoff_unix_ms,
15725 after_selection_id,
15726 limit,
15727 } => {
15728 if role != ClientRole::Operator {
15729 return Err(failure(
15730 NodeFailureCode::ObserverReadOnly,
15731 "native session catalog paging requires an operator connection",
15732 ));
15733 }
15734 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
15735 || after_selection_id
15736 .as_deref()
15737 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
15738 {
15739 return Err(failure(
15740 NodeFailureCode::InvalidRequest,
15741 "native session catalog page request is invalid",
15742 ));
15743 }
15744 let page = shared
15745 .page_native_sessions(
15746 route.clone(),
15747 window,
15748 catalog_revision,
15749 recent_cutoff_unix_ms,
15750 after_selection_id,
15751 limit,
15752 )
15753 .await?;
15754 Ok(NodeResponse::NativeSessionsPaged {
15755 route,
15756 page,
15757 })
15758 }
15759 NodeRequest::PreviewNativeSession {
15760 selection,
15761 message_limit,
15762 } => {
15763 if role != ClientRole::Operator {
15764 return Err(failure(
15765 NodeFailureCode::ObserverReadOnly,
15766 "native session preview requires an operator connection",
15767 ));
15768 }
15769 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15770 .contains(&message_limit)
15771 || selection.validate().is_err()
15772 {
15773 return Err(failure(
15774 NodeFailureCode::InvalidRequest,
15775 "native session preview request is invalid",
15776 ));
15777 }
15778 let preview = shared
15779 .preview_native_session(selection.clone(), message_limit)
15780 .await?;
15781 Ok(NodeResponse::NativeSessionPreviewed {
15782 selection,
15783 preview,
15784 })
15785 }
15786 NodeRequest::IndexNativeSession {
15787 selection,
15788 display_name,
15789 } => {
15790 shared.require_controller(connection_id, role)?;
15791 let record = shared
15792 .index_native_session(selection.clone(), display_name)
15793 .await?;
15794 Ok(NodeResponse::NativeSessionIndexed { selection, record })
15795 }
15796 NodeRequest::PreviewSessionRecord {
15797 record_id,
15798 message_limit,
15799 } => {
15800 if role != ClientRole::Operator {
15801 return Err(failure(
15802 NodeFailureCode::ObserverReadOnly,
15803 "native session preview requires an operator connection",
15804 ));
15805 }
15806 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15807 .contains(&message_limit)
15808 {
15809 return Err(failure(
15810 NodeFailureCode::InvalidRequest,
15811 "native session preview request is invalid",
15812 ));
15813 }
15814 let record = shared.record(&record_id)?;
15815 let working_directory = shared.workspace_root(&record.workspace_id)?;
15816 if !platform::roots_equal(
15817 &working_directory,
15818 windows_path_text(&record.canonical_root),
15819 ) {
15820 return Err(failure(
15821 NodeFailureCode::SessionRecordConflict,
15822 "managed session workspace root changed; refusing to preview another directory",
15823 ));
15824 }
15825 let identity = record.provider_session.clone().ok_or_else(|| {
15826 failure(
15827 NodeFailureCode::BackendOperationFailed,
15828 "native session preview is unavailable for this session record",
15829 )
15830 })?;
15831 if identity.key != ProviderSessionKey::SessionId {
15832 return Err(failure(
15833 NodeFailureCode::BackendOperationFailed,
15834 "native session preview is unavailable for this session identity",
15835 ));
15836 }
15837 let preview = shared
15838 .preview_native_session_by_session_id(
15839 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
15840 )
15841 .await?;
15842 if preview.session_id != identity.id {
15843 return Err(failure(
15844 NodeFailureCode::SessionRecordConflict,
15845 "native session preview identity changed while loading",
15846 ));
15847 }
15848 shared.revalidate_session_record_preview(&record, &identity)?;
15849 let preview: gate4agent_types::SessionRecordPreview = preview.into();
15850 shared.publish(NodeEvent::SessionRecordHistorySummarized {
15851 record_id: record_id.clone(),
15852 summary: SessionHistorySummaryV1::from(&preview),
15853 });
15854 Ok(NodeResponse::SessionRecordPreviewed {
15855 record_id,
15856 preview,
15857 })
15858 }
15859 NodeRequest::AcquireController { lease_ms } => {
15860 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
15861 Ok(NodeResponse::Controller { controller: Some(controller) })
15862 }
15863 NodeRequest::ReleaseController => {
15864 shared.release_controller(connection_id);
15865 Ok(NodeResponse::Controller { controller: shared.controller_state() })
15866 }
15867 NodeRequest::RegisterWorkspace { workspace_id, root } => {
15868 shared.require_controller(connection_id, role)?;
15869 let root = require_windows_path(root)?;
15870 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
15871 let workspace = shared.register_workspace(workspace_id, root).await?;
15872 Ok(NodeResponse::WorkspaceRegistered { workspace })
15873 }
15874 NodeRequest::CreateStandaloneWorkspace {
15875 workspace_id,
15876 root,
15877 initial_branch,
15878 } => {
15879 shared.require_controller(connection_id, role)?;
15880 let root = require_windows_path(root)?;
15881 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
15882 let workspace = shared
15883 .create_standalone_workspace(workspace_id, root, initial_branch)
15884 .await?;
15885 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
15886 }
15887 NodeRequest::UnregisterWorkspace { workspace_id } => {
15888 shared.require_controller(connection_id, role)?;
15889 shared.reject_managed_reservation(Some(&workspace_id), None)?;
15890 shared.unregister_workspace(&workspace_id)?;
15891 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
15892 }
15893 NodeRequest::CreateWorktree {
15894 source_workspace_id,
15895 workspace_id,
15896 target_root,
15897 branch,
15898 base,
15899 } => {
15900 shared.require_controller(connection_id, role)?;
15901 let target_root = require_windows_path(target_root)?;
15902 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
15903 let (worktree, workspace) = shared
15904 .create_worktree(
15905 source_workspace_id,
15906 workspace_id,
15907 target_root,
15908 branch,
15909 base,
15910 )
15911 .await?;
15912 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
15913 }
15914 NodeRequest::RemoveWorktree {
15915 source_workspace_id,
15916 target_root,
15917 } => {
15918 shared.require_controller(connection_id, role)?;
15919 let native_target_root = require_windows_path(target_root.clone())?;
15920 shared.reject_managed_reservation(None, Some(&native_target_root))?;
15921 let workspace_id = shared
15922 .remove_worktree(source_workspace_id, native_target_root)
15923 .await?;
15924 Ok(NodeResponse::WorktreeRemoved {
15925 target_root,
15926 workspace_id,
15927 })
15928 }
15929 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
15930 shared.require_controller(connection_id, role)?;
15931 if !terminal_size.is_valid() {
15932 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
15933 }
15934 if let Some(prompt) = initial_prompt.as_deref() {
15935 validate_node_text("spawn initial prompt", prompt)?;
15936 }
15937 let session = shared
15938 .spawn_session(workspace_id, provider, mode, terminal_size, initial_prompt)
15939 .await?;
15940 Ok(NodeResponse::SpawnAccepted { session })
15941 }
15942 NodeRequest::SpawnSpec { spec } => {
15943 shared.require_controller(connection_id, role)?;
15944 let receipt = shared.spawn_from_spec(spec).await?;
15945 Ok(NodeResponse::SpawnSpecAccepted { receipt })
15946 }
15947 NodeRequest::SpawnManagedWorktree { request } => {
15948 shared.require_controller(connection_id, role)?;
15949 let receipt = shared.spawn_managed_worktree(request).await?;
15950 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
15951 }
15952 NodeRequest::SpawnManagedWorktreeV2 { request } => {
15953 shared.require_controller(connection_id, role)?;
15954 let receipt = shared.spawn_managed_worktree_v2(request).await?;
15955 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
15956 }
15957 NodeRequest::CleanupManagedWorktree { lease_id } => {
15958 shared.require_controller(connection_id, role)?;
15959 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
15960 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
15961 }
15962 NodeRequest::DiscoverHistory { session, limit } => {
15963 controlled_session(shared, connection_id, role, &session)?;
15964 let candidates = shared.discover_history(&session, limit).await?;
15965 Ok(NodeResponse::HistoryDiscovered {
15966 session,
15967 candidates,
15968 })
15969 }
15970 NodeRequest::LoadHistory {
15971 session,
15972 candidate_id,
15973 } => {
15974 controlled_session(shared, connection_id, role, &session)?;
15975 let loaded = shared
15976 .load_history(&session, candidate_id)
15977 .await?;
15978 Ok(NodeResponse::HistoryLoaded {
15979 session,
15980 session_id: loaded.session_id,
15981 message_count: loaded.message_count,
15982 completed_turn_count: loaded.completed_turn_count,
15983 })
15984 }
15985 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
15986 shared.require_controller(connection_id, role)?;
15987 let context = shared
15988 .export_context_pack_for_session_record(&record_id, &session, false)
15989 .await?;
15990 Ok(NodeResponse::ContextPackForSessionRecordExported {
15991 record_id,
15992 session,
15993 context,
15994 })
15995 }
15996 NodeRequest::ExportContextPack { session } => {
15997 controlled_session(shared, connection_id, role, &session)?;
15998 let context = shared.export_context_pack(&session).await?;
15999 Ok(NodeResponse::ContextPackExported { context })
16000 }
16001 NodeRequest::ForgetContextPack { context_id } => {
16002 shared.require_controller(connection_id, role)?;
16003 shared.forget_context_pack(&context_id)?;
16004 Ok(NodeResponse::ContextPackForgotten { context_id })
16005 }
16006 NodeRequest::ResolveDurableContextPack { context_id } => {
16007 let context = shared
16011 .context_catalog
16012 .read()
16013 .unwrap_or_else(|poisoned| poisoned.into_inner())
16014 .get(&context_id)
16015 .map(|pack| pack.receipt().clone())
16016 .ok_or_else(|| {
16017 failure(
16018 NodeFailureCode::UnknownContextPack,
16019 "context pack is not present",
16020 )
16021 })?;
16022 Ok(NodeResponse::DurableContextPackResolved { context })
16023 }
16024 NodeRequest::ReadContextPack { digest } => {
16025 let (id, byte_len, bytes) = shared
16030 .context_catalog
16031 .read()
16032 .unwrap_or_else(|poisoned| poisoned.into_inner())
16033 .get_by_digest(&digest)
16034 .map(|pack| {
16035 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16036 })
16037 .ok_or_else(|| {
16038 failure(
16039 NodeFailureCode::UnknownContextPack,
16040 &format!("context pack {digest} not on this node"),
16041 )
16042 })?;
16043 tracing::debug!(
16044 digest = digest.as_str(),
16045 byte_len,
16046 "read context pack bytes",
16047 );
16048 Ok(NodeResponse::ContextPackBytesRead {
16049 pack: ContextPackBytesRead {
16050 digest,
16051 id,
16052 byte_len,
16053 bytes,
16054 },
16055 })
16056 }
16057 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16058 let provider = controlled_session(shared, connection_id, role, &session)?;
16059 if let Some(prompt) = initial_prompt.as_deref() {
16060 validate_node_text("resume initial prompt", prompt)?;
16061 }
16062 let runtime_requirement = if initial_prompt.is_some() {
16063 ProviderRuntimeRequirement::ResumeWithPrompt
16064 } else {
16065 ProviderRuntimeRequirement::Resume
16066 };
16067 shared.require_session_runtime_policy(&session, runtime_requirement)?;
16068 let runtime_policy = shared
16069 .admit_provider_runtime(&provider, runtime_requirement)
16070 .await?;
16071 let working_directory = shared.bound_workspace_root(&session)?;
16072 let request = ResumeLaunchRequest {
16073 working_directory,
16074 terminal_size,
16075 initial_prompt,
16076 };
16077 request
16078 .validate()
16079 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16080 let command = shared.prepare_command(ControlCommand::Resume {
16081 instance_id: session.session.instance_id,
16082 target: ResumeTarget::CurrentProvider,
16083 runtime_policy,
16084 request,
16085 });
16086 shared.arm_resume(&session, command.id, runtime_policy)?;
16087 let dispatch = shared.dispatch_envelope(command);
16088 if let Err(error) = dispatch {
16089 shared.clear_armed_resume(&session);
16090 return Err(error);
16091 }
16092 Ok(NodeResponse::Accepted)
16093 }
16094 NodeRequest::RenameSessionRecord {
16095 record_id,
16096 display_name,
16097 } => {
16098 shared.require_controller(connection_id, role)?;
16099 let record = shared.rename_session_record(&record_id, display_name)?;
16100 Ok(NodeResponse::SessionRecordUpdated { record })
16101 }
16102 NodeRequest::SetSessionTask {
16103 record_id,
16104 expected_revision,
16105 target,
16106 } => {
16107 shared.require_controller(connection_id, role)?;
16108 let record = shared.set_session_task(&record_id, expected_revision, target)?;
16109 Ok(NodeResponse::SessionRecordUpdated { record })
16110 }
16111 NodeRequest::IndexProviderSession {
16112 workspace_id,
16113 provider,
16114 identity,
16115 display_name,
16116 } => {
16117 shared.require_controller(connection_id, role)?;
16118 let record = shared.index_provider_session(
16119 workspace_id,
16120 provider,
16121 identity,
16122 display_name,
16123 )?;
16124 Ok(NodeResponse::ProviderSessionIndexed { record })
16125 }
16126 NodeRequest::ResumeSessionRecord {
16127 record_id,
16128 terminal_size,
16129 initial_prompt,
16130 } => {
16131 shared.require_controller(connection_id, role)?;
16132 if let Some(prompt) = initial_prompt.as_deref() {
16133 validate_node_text("managed resume initial prompt", prompt)?;
16134 }
16135 let (record, session) = shared
16136 .resume_session_record(
16137 &record_id,
16138 terminal_size,
16139 initial_prompt,
16140 )
16141 .await?;
16142 Ok(NodeResponse::SessionRecordResumed { record, session })
16143 }
16144 NodeRequest::ForgetSessionRecord { record_id } => {
16145 shared.require_controller(connection_id, role)?;
16146 shared.forget_session_record(&record_id).await?;
16147 Ok(NodeResponse::SessionRecordForgotten { record_id })
16148 }
16149 NodeRequest::Prompt { session, text } => {
16150 let agent_id = controlled_session(shared, connection_id, role, &session)?;
16151 validate_node_text("prompt", &text)?;
16152 shared.require_session_runtime_policy(
16153 &session,
16154 ProviderRuntimeRequirement::SemanticPrompt,
16155 )?;
16156 let framing = prompt_framing(&agent_id);
16157 shared
16158 .dispatch_input_bounded(
16159 &session,
16160 InputAction::SubmitPrompt(PromptPayload { text, framing }),
16161 )
16162 .await?;
16163 Ok(NodeResponse::Accepted)
16164 }
16165 NodeRequest::Paste { session, text } => {
16166 controlled_session(shared, connection_id, role, &session)?;
16167 validate_node_text("paste", &text)?;
16168 shared.require_session_runtime_policy(
16169 &session,
16170 ProviderRuntimeRequirement::SemanticPrompt,
16171 )?;
16172 shared
16173 .dispatch_input_bounded(
16174 &session,
16175 InputAction::InsertDraft(PromptPayload {
16176 text,
16177 framing: PromptFraming::BracketedPaste,
16178 }),
16179 )
16180 .await?;
16181 Ok(NodeResponse::Accepted)
16182 }
16183 NodeRequest::Input { session, text } => {
16184 controlled_session(shared, connection_id, role, &session)?;
16185 validate_node_text("terminal input", &text)?;
16186 shared
16187 .dispatch_input_bounded(
16188 &session,
16189 InputAction::TerminalText(TerminalText { text }),
16190 )
16191 .await?;
16192 Ok(NodeResponse::Accepted)
16193 }
16194 NodeRequest::TerminalBytes { session, bytes } => {
16195 controlled_session(shared, connection_id, role, &session)?;
16196 let action = terminal_bytes_action(bytes)?;
16197 shared
16198 .dispatch_input_bounded(&session, action)
16199 .await?;
16200 Ok(NodeResponse::Accepted)
16201 }
16202 NodeRequest::TerminalControl { session, control } => {
16203 controlled_session(shared, connection_id, role, &session)?;
16204 shared
16205 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16206 .await?;
16207 Ok(NodeResponse::Accepted)
16208 }
16209 NodeRequest::Resize { session, size } => {
16210 controlled_session(shared, connection_id, role, &session)?;
16211 if !size.is_valid() {
16212 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16213 }
16214 shared.dispatch_resize_bounded(&session, size).await?;
16215 Ok(NodeResponse::Accepted)
16216 }
16217 NodeRequest::Interrupt { session } => {
16218 controlled_session(shared, connection_id, role, &session)?;
16219 shared
16220 .dispatch_input_bounded(
16221 &session,
16222 InputAction::TerminalControl(TerminalControl::Interrupt),
16223 )
16224 .await?;
16225 Ok(NodeResponse::Accepted)
16226 }
16227 NodeRequest::Stop { session, force } => {
16228 controlled_session(shared, connection_id, role, &session)?;
16229 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16230 Ok(NodeResponse::Accepted)
16231 }
16232 NodeRequest::Remove { session } => {
16233 controlled_session(shared, connection_id, role, &session)?;
16234 shared.remove_session(&session).await?;
16235 Ok(NodeResponse::Accepted)
16236 }
16237 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16238 shared.require_controller(connection_id, role)?;
16239 let snapshot = shared.require_acp_session(&session)?;
16240 let interaction = snapshot
16241 .provider
16242 .interactions
16243 .iter()
16244 .find(|interaction| {
16245 interaction_correlation(
16246 session.session.instance_id,
16247 session.session.generation,
16248 interaction.id.0,
16249 ) == correlation_id
16250 })
16251 .ok_or_else(|| failure(
16252 NodeFailureCode::InvalidRequest,
16253 "unknown interaction correlation",
16254 ))?;
16255 if interaction.status != ProviderInteractionStatus::Pending {
16256 return Err(failure(
16257 NodeFailureCode::BackendOperationFailed,
16258 "interaction has already been resolved",
16259 ));
16260 }
16261 response
16262 .validate_for(interaction.interaction_kind)
16263 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16264 let interaction_id = interaction.id;
16265 let response_kind = response.kind();
16266 let response_outcome = response.outcome();
16267 shared.dispatch(ControlCommand::ResolveInteraction {
16268 instance_id: session.session.instance_id,
16269 generation: session.session.generation,
16270 interaction_id,
16271 response,
16272 })?;
16273 tracing::info!(
16279 node_id = %shared.node_id,
16280 session = ?session.session,
16281 correlation_id = %correlation_id,
16282 interaction_id = interaction_id.0,
16283 response_kind = ?response_kind,
16284 outcome = ?response_outcome,
16285 "interaction resolved",
16286 );
16287 Ok(NodeResponse::Accepted)
16288 }
16289 NodeRequest::SetSessionMode { session, mode_id } => {
16290 shared.require_controller(connection_id, role)?;
16291 shared.require_acp_session(&session)?;
16292 shared.dispatch(ControlCommand::SetSessionMode {
16293 instance_id: session.session.instance_id,
16294 mode_id,
16295 })?;
16296 Ok(NodeResponse::Accepted)
16297 }
16298 NodeRequest::SetSessionConfigOption {
16299 session,
16300 option_id,
16301 value_json,
16302 } => {
16303 shared.require_controller(connection_id, role)?;
16304 shared.require_acp_session(&session)?;
16305 shared.dispatch(ControlCommand::SetSessionConfigOption {
16306 instance_id: session.session.instance_id,
16307 option_id,
16308 value_json,
16309 })?;
16310 Ok(NodeResponse::Accepted)
16311 }
16312 NodeRequest::SetSessionModel { session, model_id } => {
16313 shared.require_controller(connection_id, role)?;
16314 shared.require_acp_session(&session)?;
16315 shared.dispatch(ControlCommand::SetSessionModel {
16316 instance_id: session.session.instance_id,
16317 model_id,
16318 })?;
16319 Ok(NodeResponse::Accepted)
16320 }
16321 NodeRequest::Shutdown => {
16322 shared.require_controller(connection_id, role)?;
16323 shared.begin_shutdown_locked().await?;
16324 Ok(NodeResponse::ShuttingDown)
16325 }
16326 }
16327}
16328
16329fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16330 shared.require_controller(connection_id, role)?;
16331 shared.validate_address(session)
16332}
16333
16334fn select_history_candidate(
16340 candidates: &[HistoryCandidateSummary],
16341 provider_session_id: Option<&str>,
16342) -> Result<String, NodeFailure> {
16343 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16344 Some(id) => candidate.session_id_hint == id,
16345 None => true,
16346 });
16347 let candidate = matches.next().ok_or_else(|| {
16348 failure(
16349 NodeFailureCode::SessionRecordConflict,
16350 if provider_session_id.is_some() {
16351 "no history candidate matches the managed provider session"
16352 } else {
16353 "no history candidate exists for the managed session"
16354 },
16355 )
16356 })?;
16357 if matches.next().is_some() {
16358 return Err(failure(
16359 NodeFailureCode::SessionRecordConflict,
16360 if provider_session_id.is_some() {
16361 "history identity is ambiguous for the managed provider session"
16362 } else {
16363 "history is ambiguous without a verified provider session identity"
16364 },
16365 ));
16366 }
16367 Ok(candidate.id.clone())
16368}
16369
16370fn resolved_spawn_profile_summary(
16371 profile: &SpawnProfileDefaults,
16372 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16373) -> Option<SpawnProfileSummary> {
16374 let environment_profile = match profile.environment_profile_id.as_ref() {
16375 None => None,
16376 Some(profile_id) => {
16377 let binding = environment_profiles.get(profile_id)?;
16378 if binding.provider != profile.provider
16379 || binding.native_profile_id(profile.mode).is_none()
16380 {
16381 return None;
16382 }
16383 Some(ResolvedEnvironmentProfileReceipt {
16384 profile_id: binding.id.clone(),
16385 profile_revision: binding.revision.clone(),
16386 network_allowlist: None,
16387 browser_profile_id: None,
16388 })
16389 }
16390 };
16391 Some(SpawnProfileSummary {
16392 id: profile.profile_id.clone(),
16393 revision: profile.revision.clone(),
16394 environment_profile,
16395 })
16396}
16397
16398fn session_record_export_target_matches(
16417 expected: &ManagedSessionRecord,
16418 current: &ManagedSessionRecord,
16419 session: &SessionAddress,
16420 allow_clean_detachment: bool,
16421) -> bool {
16422 if current == expected && current.active_session.as_ref() == Some(session) {
16423 return current.state == ManagedSessionState::Live;
16424 }
16425 allow_clean_detachment
16426 && current.state == ManagedSessionState::Dormant
16427 && current.active_session.is_none()
16428 && current.record_id == expected.record_id
16429 && current.display_name == expected.display_name
16430 && current.provider == expected.provider
16431 && current.mode == expected.mode
16432 && current.workspace_id == expected.workspace_id
16433 && current.canonical_root == expected.canonical_root
16434 && current.provider_session == expected.provider_session
16435 && current.environment_profile == expected.environment_profile
16436 && current.bundle == expected.bundle
16437 && current.context_id == expected.context_id
16438 && current.context == expected.context
16439 && current.exported_context == expected.exported_context
16440 && current.task_binding == expected.task_binding
16441 && current.created_at_unix_ms == expected.created_at_unix_ms
16442 && current.last_error == expected.last_error
16443}
16444
16445fn session_record_context_export_binding_is_exact(
16446 expected: &ManagedSessionRecord,
16447 current: &ManagedSessionRecord,
16448 identity: Option<&ProviderSessionIdentity>,
16449 session: &SessionAddress,
16450 binding: &SessionBinding,
16451 provider: &AgentId,
16452 current_root: &str,
16453 allow_clean_detachment: bool,
16454) -> bool {
16455 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
16456 && current.provider_session.as_ref() == identity
16457 && current.workspace_id == session.workspace_id
16458 && provider == ¤t.provider
16459 && binding.workspace_id == session.workspace_id
16460 && binding.generation == session.session.generation
16461 && binding.record_id.as_ref() == Some(¤t.record_id)
16462 && platform::roots_equal(
16463 current_root,
16464 windows_path_text(¤t.canonical_root),
16465 )
16466}
16467
16468fn session_record_context_export_source_is_usable(
16469 record: &ManagedSessionRecord,
16470 status: &SessionStatus,
16471 allow_clean_detachment: bool,
16472) -> bool {
16473 (record.state == ManagedSessionState::Live
16474 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
16475 && context_pack_source_status_is_usable(status)
16476}
16477
16478fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
16479 PromptFraming::BracketedPaste
16480}
16481
16482fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
16483 if text.len() > MAX_NODE_TEXT_BYTES {
16484 return Err(failure(
16485 NodeFailureCode::InvalidRequest,
16486 &format!(
16487 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
16488 text.len(),
16489 ),
16490 ));
16491 }
16492 Ok(())
16493}
16494
16495fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
16496 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
16497 return Err(failure(
16498 NodeFailureCode::InvalidRequest,
16499 &format!(
16500 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
16501 bytes.len(),
16502 ),
16503 ));
16504 }
16505 Ok(())
16506}
16507
16508fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
16509 validate_terminal_bytes(&bytes)?;
16510 Ok(InputAction::TerminalBytes(bytes))
16511}
16512
16513fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
16514 if root.is_empty()
16515 || root.len() > MAX_WORKSPACE_ROOT_BYTES
16516 || root.chars().any(char::is_control)
16517 {
16518 return Err(failure(
16519 NodeFailureCode::InvalidWorkspaceRoot,
16520 &format!(
16521 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
16522 ),
16523 ));
16524 }
16525 Ok(())
16526}
16527
16528fn canonical_standalone_workspace_candidate(
16529 workspace_id: &WorkspaceId,
16530 root: &str,
16531) -> Result<String, NodeServerError> {
16532 let path = Path::new(root);
16533 match std::fs::symlink_metadata(path) {
16534 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
16535 .map(|workspace| workspace.canonical_root),
16536 Err(error) if error.kind() == io::ErrorKind::NotFound => {
16537 if !path.is_absolute() {
16538 return Err(NodeServerError::InvalidWorkspaceRoot {
16539 workspace_id: workspace_id.clone(),
16540 path: root.to_owned(),
16541 message: "path is not absolute".to_owned(),
16542 });
16543 }
16544 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16545 workspace_id: workspace_id.clone(),
16546 path: root.to_owned(),
16547 message: "path has no standalone directory name".to_owned(),
16548 })?;
16549 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16550 workspace_id: workspace_id.clone(),
16551 path: root.to_owned(),
16552 message: "path has no parent directory".to_owned(),
16553 })?;
16554 let canonical = std::fs::canonicalize(parent)
16555 .map(|parent| parent.join(name))
16556 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
16557 workspace_id: workspace_id.clone(),
16558 path: root.to_owned(),
16559 message: error.to_string(),
16560 })?;
16561 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
16562 NodeServerError::InvalidWorkspaceRoot {
16563 workspace_id: workspace_id.clone(),
16564 path: path.to_string_lossy().into_owned(),
16565 message: "canonical path is not valid Unicode".to_owned(),
16566 }
16567 })?;
16568 let canonical_root = platform::normalize_canonical_root(canonical_root);
16569 validate_workspace_root(workspace_id, &canonical_root)?;
16570 Ok(canonical_root)
16571 }
16572 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
16573 workspace_id: workspace_id.clone(),
16574 path: root.to_owned(),
16575 message: error.to_string(),
16576 }),
16577 }
16578}
16579
16580fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
16581 let Some(revision) = revision else {
16582 return Ok(());
16583 };
16584 if revision.is_empty()
16585 || revision.len() > MAX_REPOSITORY_PATH_BYTES
16586 || revision.chars().any(char::is_control)
16587 {
16588 return Err(failure(
16589 NodeFailureCode::InvalidRequest,
16590 &format!(
16591 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
16592 ),
16593 ));
16594 }
16595 Ok(())
16596}
16597
16598fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16599 let code = match error.kind {
16600 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
16601 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
16602 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
16603 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
16604 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
16605 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
16606 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
16607 };
16608 failure(code, &error.message)
16609}
16610
16611fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
16612 let code = match error.kind {
16613 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
16614 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
16615 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
16616 NodeFailureCode::BackendOperationFailed
16617 }
16618 StandaloneWorkspaceErrorKind::RecoveryRequired => {
16619 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
16620 }
16621 };
16622 failure(code, error.message)
16623}
16624
16625fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16626 let (code, message) = match error.kind {
16627 GitWorktreeErrorKind::Invalid => (
16628 NodeFailureCode::InvalidRequest,
16629 "managed Git input is invalid",
16630 ),
16631 GitWorktreeErrorKind::NotRepository => (
16632 NodeFailureCode::NotGitRepository,
16633 "managed source is not a Git repository",
16634 ),
16635 GitWorktreeErrorKind::Conflict => (
16636 NodeFailureCode::WorktreeConflict,
16637 "managed Git identity conflicts with existing repository state",
16638 ),
16639 GitWorktreeErrorKind::Protected => (
16640 NodeFailureCode::ManagedWorktreeOwnershipConflict,
16641 "managed worktree ownership validation failed",
16642 ),
16643 GitWorktreeErrorKind::Dirty => (
16644 NodeFailureCode::WorktreeDirty,
16645 "managed worktree contains uncommitted changes",
16646 ),
16647 GitWorktreeErrorKind::Locked => (
16648 NodeFailureCode::WorktreeLocked,
16649 "managed worktree is locked",
16650 ),
16651 GitWorktreeErrorKind::Failed => (
16652 NodeFailureCode::BackendOperationFailed,
16653 "managed Git operation failed",
16654 ),
16655 };
16656 NodeFailure { code, message: message.to_owned() }
16657}
16658
16659fn managed_spawn_receipt(
16660 resolved: &ResolvedSpawnSpec,
16661 incarnation_id: NodeIncarnationId,
16662 session: SessionAddress,
16663 lease: ManagedWorktreeLeaseSnapshot,
16664 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
16665 bundle: Option<ResolvedBundleReceipt>,
16666 context: Option<ResolvedContextPackReceipt>,
16667) -> ManagedWorktreeSpawnReceipt {
16668 let mut managed = resolved.clone();
16669 managed.target.worktree_id = Some(lease.workspace_id.clone());
16670 ManagedWorktreeSpawnReceipt {
16671 spawn: managed.receipt_with_materialization(
16672 incarnation_id,
16673 session,
16674 environment_profile,
16675 bundle,
16676 context,
16677 ),
16678 lease,
16679 }
16680}
16681
16682fn validate_selected_worktree(
16683 source_root: &str,
16684 selected_root: &str,
16685 worktrees: &[NativeGitWorktreeSnapshot],
16686) -> Result<(), NodeFailure> {
16687 if !worktrees
16688 .iter()
16689 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
16690 {
16691 return Err(failure(
16692 NodeFailureCode::WorktreeProtected,
16693 "source workspace is not an authoritative non-bare Git worktree root",
16694 ));
16695 }
16696 if !worktrees.iter().any(|worktree| {
16697 worktree_paths_equal(&worktree.path, selected_root)
16698 && !worktree.is_main
16699 && !worktree.is_bare
16700 }) {
16701 return Err(failure(
16702 NodeFailureCode::WorktreeProtected,
16703 "selected workspace is not an eligible Git worktree in the source repository",
16704 ));
16705 }
16706 Ok(())
16707}
16708
16709fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
16710 let code = match error.kind() {
16711 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16712 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16713 NodeFailureCode::RepositoryPathUnsafe
16714 }
16715 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16716 WorkspaceFileReadErrorKind::NotRegularFile => {
16717 NodeFailureCode::RepositoryFileNotRegular
16718 }
16719 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16720 NodeFailureCode::RepositoryFileReadFailed
16721 }
16722 WorkspaceFileReadErrorKind::RevisionConflict => {
16723 NodeFailureCode::RepositoryFileRevisionConflict
16724 }
16725 WorkspaceFileReadErrorKind::AlreadyExists
16726 | WorkspaceFileReadErrorKind::ParentNotFound
16727 | WorkspaceFileReadErrorKind::ParentNotDirectory
16728 | WorkspaceFileReadErrorKind::Canceled => {
16729 NodeFailureCode::RepositoryFileReadFailed
16730 }
16731 };
16732 failure(code, "repository file read failed")
16733}
16734
16735fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
16736 let code = match error.kind() {
16737 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16738 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16739 NodeFailureCode::RepositoryPathUnsafe
16740 }
16741 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16742 WorkspaceFileReadErrorKind::NotRegularFile => {
16743 NodeFailureCode::RepositoryFileNotRegular
16744 }
16745 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16746 NodeFailureCode::RepositoryFileWriteFailed
16747 }
16748 WorkspaceFileReadErrorKind::RevisionConflict => {
16749 NodeFailureCode::RepositoryFileRevisionConflict
16750 }
16751 WorkspaceFileReadErrorKind::AlreadyExists
16752 | WorkspaceFileReadErrorKind::ParentNotFound
16753 | WorkspaceFileReadErrorKind::ParentNotDirectory
16754 | WorkspaceFileReadErrorKind::Canceled => {
16755 NodeFailureCode::RepositoryFileWriteFailed
16756 }
16757 };
16758 failure(code, "repository file write failed")
16759}
16760
16761async fn settle_workspace_entry_create<T>(
16762 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
16763 commit_state: Arc<AtomicU8>,
16764 deadline: Duration,
16765 timeout_message: &'static str,
16766 task_failure_message: &'static str,
16767) -> Result<T, NodeFailure> {
16768 let joined = match timeout(deadline, &mut task).await {
16769 Ok(joined) => joined,
16770 Err(_) => {
16771 if commit_state
16772 .compare_exchange(
16773 WORKSPACE_ENTRY_CREATE_PENDING,
16774 WORKSPACE_ENTRY_CREATE_CANCELED,
16775 Ordering::AcqRel,
16776 Ordering::Acquire,
16777 )
16778 .is_ok()
16779 {
16780 return Err(failure(
16781 NodeFailureCode::RepositoryEntryCreateTimedOut,
16782 timeout_message,
16783 ));
16784 }
16785 debug_assert_eq!(
16786 commit_state.load(Ordering::Acquire),
16787 WORKSPACE_ENTRY_CREATE_COMMITTING,
16788 );
16789 task.await
16790 }
16791 };
16792 joined
16793 .map_err(|_| {
16794 failure(
16795 NodeFailureCode::RepositoryEntryCreateFailed,
16796 task_failure_message,
16797 )
16798 })?
16799 .map_err(workspace_entry_create_failure)
16800}
16801
16802fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
16803 let code = match error.kind() {
16804 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16805 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16806 NodeFailureCode::RepositoryPathUnsafe
16807 }
16808 WorkspaceFileReadErrorKind::AlreadyExists => {
16809 NodeFailureCode::RepositoryEntryAlreadyExists
16810 }
16811 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
16812 NodeFailureCode::RepositoryParentNotFound
16813 }
16814 WorkspaceFileReadErrorKind::ParentNotDirectory
16815 | WorkspaceFileReadErrorKind::NotRegularFile => {
16816 NodeFailureCode::RepositoryParentNotDirectory
16817 }
16818 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16819 NodeFailureCode::RepositoryEntryCreateFailed
16820 }
16821 WorkspaceFileReadErrorKind::RevisionConflict => {
16822 NodeFailureCode::RepositoryEntryCreateFailed
16823 }
16824 WorkspaceFileReadErrorKind::Canceled => {
16825 NodeFailureCode::RepositoryEntryCreateTimedOut
16826 }
16827 };
16828 failure(code, "repository entry create failed")
16829}
16830
16831fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
16832 let value = digest(&SHA256, bytes)
16833 .as_ref()
16834 .iter()
16835 .map(|byte| format!("{byte:02x}"))
16836 .collect();
16837 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
16838}
16839
16840fn managed_spawn_request_digest_v2(
16841 request: &ManagedWorktreeSpawnRequestV2,
16842) -> Result<String, NodeFailure> {
16843 let encoded = serde_json::to_vec(request).map_err(|_| {
16844 failure(
16845 NodeFailureCode::InvalidRequest,
16846 "managed worktree V2 request could not be canonicalized",
16847 )
16848 })?;
16849 let mut value = String::with_capacity(71);
16850 value.push_str("sha256:");
16851 for byte in digest(&SHA256, &encoded).as_ref() {
16852 use std::fmt::Write as _;
16853 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
16854 }
16855 Ok(value)
16856}
16857
16858fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
16859 let code = match error {
16860 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
16861 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
16862 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
16863 NativeSessionPreviewError::UnsupportedProvider
16864 | NativeSessionPreviewError::PreviewUnavailable
16865 | NativeSessionPreviewError::SessionNotFound
16866 | NativeSessionPreviewError::AmbiguousSession => {
16867 NodeFailureCode::BackendOperationFailed
16868 }
16869 };
16870 failure(code, "native session preview failed")
16871}
16872
16873fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
16874 let code = match error.kind() {
16875 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
16876 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
16877 };
16878 failure(code, "host directory browse failed")
16879}
16880
16881fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
16882 let code = match error {
16883 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
16884 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
16885 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
16886 NodeFailureCode::DeliveryStageConflict
16887 }
16888 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
16889 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
16890 NodeFailureCode::DeliveryChunkOutOfOrder
16891 }
16892 DeliveryStoreError::BlobDigestMismatch => {
16893 NodeFailureCode::DeliveryBlobDigestMismatch
16894 }
16895 DeliveryStoreError::BundleDigestMismatch => {
16896 NodeFailureCode::DeliveryBundleDigestMismatch
16897 }
16898 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
16899 DeliveryStoreError::Unavailable
16900 | DeliveryStoreError::Corrupt
16901 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
16902 };
16903 failure(code, "delivery operation failed")
16904}
16905
16906fn validate_workspace_root(
16907 workspace_id: &WorkspaceId,
16908 canonical_root: &str,
16909) -> Result<(), NodeServerError> {
16910 #[cfg(windows)]
16911 if canonical_root.starts_with(r"\\") {
16912 return Err(NodeServerError::InvalidWorkspaceRoot {
16913 workspace_id: workspace_id.clone(),
16914 path: canonical_root.to_owned(),
16915 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
16916 });
16917 }
16918 if !platform::workspace_root_supported(canonical_root)
16919 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
16920 {
16921 return Err(NodeServerError::InvalidWorkspaceRoot {
16922 workspace_id: workspace_id.clone(),
16923 path: canonical_root.to_owned(),
16924 message: "canonical path must be absolute, bounded, and free of control characters"
16925 .to_owned(),
16926 });
16927 }
16928 Ok(())
16929}
16930
16931fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
16932 NodeFailure {
16933 code,
16934 message: node_failure_category(code).to_owned(),
16935 }
16936}
16937
16938fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
16939 let code = match error {
16940 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
16941 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
16942 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
16943 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
16944 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
16945 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
16946 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
16947 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
16948 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
16949 };
16950 failure(code, "harness MCP proxy operation failed")
16951}
16952
16953static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
16957
16958fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
16965 let trace = launch.trace.as_ref()?;
16966 let trace_dir = std::env::var(&trace.dir_env).ok()?;
16967 if trace_dir.is_empty() {
16968 return None;
16969 }
16970 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
16971 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
16972 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
16973}
16974
16975fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
16976 let remaining = deadline.saturating_duration_since(Instant::now());
16977 if remaining.is_zero() {
16978 Err(failure(
16979 NodeFailureCode::SpawnDeadlineExceeded,
16980 "spawn deadline elapsed",
16981 ))
16982 } else {
16983 Ok(remaining)
16984 }
16985}
16986
16987fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
16988 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
16989 match deadline {
16990 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
16991 None => Ok(bounded),
16992 }
16993}
16994
16995fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
16996 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
16997 failure(
16998 NodeFailureCode::SpawnDeadlineExceeded,
16999 "spawn dispatch exceeded the spawn deadline",
17000 )
17001 } else {
17002 error
17003 }
17004}
17005
17006fn spawn_runtime_capabilities(
17007 capabilities: &SpawnRequiredCapabilities,
17008) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17009 capabilities
17010 .iter()
17011 .map(|capability| match capability.as_str() {
17012 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17013 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17014 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17015 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17016 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17017 }
17018 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17019 _ => Err(failure(
17020 NodeFailureCode::UnsupportedSpawnCapability,
17021 "spawn required capability is unknown",
17022 )),
17023 })
17024 .collect()
17025}
17026
17027fn persistence_failure(_error: io::Error) -> NodeFailure {
17028 NodeFailure {
17029 code: NodeFailureCode::BackendOperationFailed,
17030 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17031 }
17032}
17033
17034fn node_failure_category(code: NodeFailureCode) -> &'static str {
17035 match code {
17036 NodeFailureCode::InvalidRequest => "invalid-request",
17037 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17038 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17039 NodeFailureCode::ReservationNotFound => "reservation-not-found",
17040 NodeFailureCode::ReservationConflict => "reservation-conflict",
17041 NodeFailureCode::ReservationExpired => "reservation-expired",
17042 NodeFailureCode::BindingMismatch => "binding-mismatch",
17043 NodeFailureCode::NotActivated => "not-activated",
17044 NodeFailureCode::CallNotFound => "call-not-found",
17045 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17046 NodeFailureCode::ResponseTooLarge => "response-too-large",
17047 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17048 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17049 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17050 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17051 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17052 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17053 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17054 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17055 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17056 NodeFailureCode::Unauthorized => "unauthorized",
17057 NodeFailureCode::ObserverReadOnly => "observer-read-only",
17058 NodeFailureCode::ControllerBusy => "controller-busy",
17059 NodeFailureCode::ControllerRequired => "controller-required",
17060 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17061 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17062 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17063 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17064 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17065 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17066 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17067 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17068 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17069 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17070 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17071 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17072 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17073 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17074 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17075 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17076 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17077 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17078 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17079 NodeFailureCode::GitReadFailed => "git-read-failed",
17080 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17081 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17082 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17083 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17084 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17085 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17086 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17087 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17088 NodeFailureCode::TurnInFlight => "turn-in-flight",
17089 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17090 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17091 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17092 "unsupported-network-allowlist-mapping"
17093 }
17094 NodeFailureCode::BrowserStationProbeUnavailable => {
17095 "browser-station-probe-unavailable"
17096 }
17097 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17098 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17099 NodeFailureCode::EnvironmentProfileBindingMismatch => {
17100 "environment-profile-binding-mismatch"
17101 }
17102 NodeFailureCode::UnknownBundle => "unknown-bundle",
17103 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17104 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17105 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17106 NodeFailureCode::ContextPackBusy => "context-pack-busy",
17107 NodeFailureCode::ContextPackMaterializationFailed => {
17108 "context-pack-materialization-failed"
17109 }
17110 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17111 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17112 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17113 NodeFailureCode::WorkspaceBusy => "workspace-busy",
17114 NodeFailureCode::LastWorkspace => "last-workspace",
17115 NodeFailureCode::NotGitRepository => "not-git-repository",
17116 NodeFailureCode::WorktreeConflict => "worktree-conflict",
17117 NodeFailureCode::WorktreeProtected => "worktree-protected",
17118 NodeFailureCode::WorktreeDirty => "worktree-dirty",
17119 NodeFailureCode::WorktreeLocked => "worktree-locked",
17120 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17121 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17122 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17123 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17124 "managed-worktree-profile-revision-mismatch"
17125 }
17126 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17127 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17128 "standalone-workspace-recovery-required"
17129 }
17130 NodeFailureCode::UnknownSession => "unknown-session",
17131 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17132 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17133 NodeFailureCode::SessionRecordBusy => "session-record-busy",
17134 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17135 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17136 NodeFailureCode::WorkspaceRegistrationRequired => {
17137 "workspace-registration-required"
17138 }
17139 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17140 NodeFailureCode::StaleGeneration => "stale-generation",
17141 NodeFailureCode::BackendBusy => "backend-busy",
17142 NodeFailureCode::BackendDisconnected => "backend-disconnected",
17143 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17144 NodeFailureCode::ShuttingDown => "shutting-down",
17145 }
17146}
17147
17148fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17149 NodeServerError::DurableState(io::Error::new(error.kind(), category))
17150}
17151
17152fn durable_state_load_error(error: io::Error) -> NodeServerError {
17153 let category = match session_registry::state_load_refusal(&error) {
17154 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17155 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17156 }
17157 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17158 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17159 }
17160 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17161 DURABLE_STATE_CONFLICT_ERROR
17162 }
17163 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17164 };
17165 durable_state_server_error(error, category)
17166}
17167
17168fn unix_time_ms() -> u64 {
17169 std::time::SystemTime::now()
17170 .duration_since(std::time::UNIX_EPOCH)
17171 .unwrap_or_default()
17172 .as_millis()
17173 .min(u64::MAX as u128) as u64
17174}
17175
17176fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17177 if record.provider_session.is_some() {
17178 ManagedSessionState::Dormant
17179 } else {
17180 ManagedSessionState::Unavailable
17181 }
17182}
17183
17184fn active_record_state(
17185 identity_admitted: bool,
17186 provider_session_is_known: bool,
17187) -> ManagedSessionState {
17188 if identity_admitted && !provider_session_is_known {
17189 ManagedSessionState::IdentityPending
17190 } else {
17191 ManagedSessionState::Live
17192 }
17193}
17194
17195fn managed_worktree_record_holders(
17196 records: &[ManagedSessionRecord],
17197) -> Vec<(SessionRecordId, WorkspaceId)> {
17198 records
17199 .iter()
17200 .filter(|record| record.provider_session.is_some())
17201 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17202 .collect()
17203}
17204
17205fn managed_spawn_record_ownership(
17206 binding: &SessionBinding,
17207 effective_runtime_policy: ProviderRuntimePolicy,
17208) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17209 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17210 if effective_runtime_policy.provider_session_identity {
17211 (binding.record_id.clone(), None)
17212 } else {
17213 (None, binding.record_id.clone())
17214 }
17215}
17216
17217#[derive(Debug, Error)]
17218pub enum NodeServerError {
17219 #[error("node endpoint must be a bounded absolute local transport path")]
17220 InvalidEndpoint,
17221 #[error("node access token must contain 1..=4096 bytes")]
17222 InvalidAccessToken,
17223 #[error("node HTTP observer must listen on a loopback address: {0}")]
17224 InvalidApiListen(std::net::SocketAddr),
17225 #[error("node browser bridge must listen on a loopback address: {0}")]
17226 InvalidBridgeListen(std::net::SocketAddr),
17227 #[error("node browser bridge token must contain 1..=4096 bytes")]
17228 InvalidBridgeToken,
17229 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17230 BridgeUnderlayRequiresBridgeListen,
17231 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17232 BridgeUnderlayRequiresBridgeToken,
17233 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17234 InvalidBridgeUnderlayToken,
17235 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17236 InvalidBridgeUnderlayKey,
17237 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17238 InvalidCallHome(std::net::SocketAddr),
17239 #[error("node could not reach its call-home relay: {0}")]
17240 CallHomeTransport(#[source] std::io::Error),
17241 #[error("node durable state path must be an absolute file path: {0}")]
17242 InvalidStatePath(String),
17243 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17244 InvalidHarnessMcpHelper,
17245 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17246 InvalidNetworkAllowlistCatalogPath(String),
17247 #[error("network allowlist catalog could not be read: {0}")]
17248 NetworkAllowlistCatalogIo(#[source] std::io::Error),
17249 #[error("network allowlist catalog line {line}: {message}")]
17250 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17251 #[error("network allowlist catalog: {0}")]
17252 InvalidNetworkAllowlistCatalog(String),
17253 #[error("network allowlist catalog exceeds the {max}-entry limit")]
17254 NetworkAllowlistCatalogCapacity { max: usize },
17255 #[error("the local state directory is unavailable; supply an explicit state path")]
17256 LocalStateDirectoryUnavailable,
17257 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17258 LocalRuntimeDirectoryUnavailable,
17259 #[error("node durable state failed: {0}")]
17260 DurableState(io::Error),
17261 #[error("node requires at least one configured workspace")]
17262 NoWorkspaces,
17263 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17264 InvalidWorkspaceRoot {
17265 workspace_id: WorkspaceId,
17266 path: String,
17267 message: String,
17268 },
17269 #[error("duplicate workspace ID: {0}")]
17270 DuplicateWorkspaceId(WorkspaceId),
17271 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17272 DuplicateWorkspaceRoot {
17273 first: WorkspaceId,
17274 second: WorkspaceId,
17275 root: String,
17276 },
17277 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17278 ManagedWorktreeProfileRequired(WorkspaceId),
17279 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17280 ManagedWorktreeProfileModeMismatch(WorkspaceId),
17281 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17282 DuplicateManagedWorktreeProfile {
17283 workspace_id: WorkspaceId,
17284 profile_id: WorktreeProfileId,
17285 },
17286 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17287 ManagedWorktreeProfileCapacity {
17288 workspace_id: WorkspaceId,
17289 max: usize,
17290 },
17291 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17292 InvalidManagedWorktreeProfile {
17293 workspace_id: WorkspaceId,
17294 profile_id: WorktreeProfileId,
17295 message: String,
17296 },
17297 #[error("duplicate node environment profile: {0}")]
17298 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17299 #[error("node environment profile provider is unavailable: {0}")]
17300 EnvironmentProfileProviderUnavailable(AgentId),
17301 #[error("node environment profile capacity is {max}")]
17302 EnvironmentProfileCapacity { max: usize },
17303 #[error("node environment profiles reuse native profile ID '{0}'")]
17304 DuplicateNativeEnvironmentProfile(String),
17305 #[error("node native environment profile is invalid: {0}")]
17306 NativeEnvironmentProfile(String),
17307 #[error("node session-environment materialization root must be absolute")]
17308 InvalidSessionEnvironmentRoot,
17309 #[error("node session-environment materializer is unavailable")]
17310 SessionEnvironmentMaterializerRequired,
17311 #[error("node session-environment materializer failed to initialize")]
17312 SessionEnvironmentMaterializer,
17313 #[error("node bundle catalog is invalid: {0}")]
17314 BundleCatalog(String),
17315 #[error("node delivery store failed to initialize: {0}")]
17316 DeliveryStore(String),
17317 #[error("node context pack store failed to initialize")]
17318 ContextPackStore,
17319 #[error("active agent registry failed: {0}")]
17320 Registry(String),
17321 #[error("node provider contract manifest is invalid: {0}")]
17322 ProviderContractManifest(String),
17323 #[error("named pipe I/O failed: {0}")]
17324 Io(#[from] io::Error),
17325 #[error("node HTTP observer failed: {0}")]
17326 HttpApi(io::Error),
17327 #[error("node browser bridge failed: {0}")]
17328 BridgeApi(io::Error),
17329 #[error(transparent)]
17330 Frame(#[from] FrameError),
17331 #[error("node handshake failed: {0}")]
17332 Handshake(String),
17333 #[error("node authentication frame was not received before the bounded deadline")]
17334 AuthenticationTimedOut,
17335 #[error("node authentication primitive failed: {0}")]
17336 Authentication(String),
17337 #[error("node incarnation identity generation failed: {0}")]
17338 IncarnationIdentity(String),
17339 #[error("node pre-authentication limiter is closed")]
17340 PreauthClosed,
17341 #[error("node authenticated connection limit was reached")]
17342 AuthenticatedConnectionLimit,
17343 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17344 SignalStreamClosed,
17345 #[error("node stop-all dispatch failed: {0:?}")]
17346 ShutdownDispatch(NodeFailure),
17347 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17348 ShutdownTimedOut { active_native_sessions: usize },
17349}
17350
17351impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17352 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17353 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17354 match error {
17355 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17356 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17357 E::InvalidEntry { line, message } => {
17358 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17359 }
17360 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17361 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17362 }
17363 }
17364}
17365
17366#[cfg(test)]
17367mod agent_stream_blocked_tests {
17368 use super::*;
17369 use gate4agent_types::{
17370 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17371 ProviderSource,
17372 };
17373
17374 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17375 ProviderSource {
17376 family,
17377 binding: AdapterBinding::new(
17378 AdapterId::new(adapter).unwrap(),
17379 "fixture/v1",
17380 AdapterVerification::SyntheticFixture,
17381 )
17382 .unwrap(),
17383 }
17384 }
17385
17386 fn provider_control_event_at(
17387 family: AdapterFamily,
17388 adapter: &str,
17389 source_sequence: u64,
17390 provider_event: ProviderEvent,
17391 ) -> ControlEvent {
17392 ControlEvent {
17393 sequence: 41,
17394 command_id: None,
17395 instance_id: AgentInstanceId(7),
17396 generation: SessionGeneration(3),
17397 event: ControlEventKind::ProviderEvent {
17398 sequence: 8,
17399 source: provider_source(family, adapter),
17400 source_sequence,
17401 event: provider_event,
17402 },
17403 }
17404 }
17405
17406 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17407 provider_control_event_at(family, "fixture", 9, provider_event)
17408 }
17409
17410 fn denied_host_request(
17411 by: ProviderHostDecisionAuthority,
17412 reason: Option<String>,
17413 ) -> ControlEvent {
17414 provider_control_event_at(
17415 AdapterFamily::Acp,
17416 "claude-code",
17417 2,
17418 ProviderEvent::HostRequestObserved {
17419 method: "terminal/create".to_owned(),
17420 params_json: String::new(),
17421 decision: ProviderHostRequestDecision::Denied { by },
17422 outcome: ProviderHostRequestOutcome::Executed,
17423 reason,
17424 },
17425 )
17426 }
17427
17428 fn blocked(
17429 event: &ControlEvent,
17430 ) -> (
17431 Option<String>,
17432 String,
17433 BlockAuthorityV1,
17434 Option<String>,
17435 String,
17436 Option<String>,
17437 ) {
17438 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17439 chunk.validate().expect("minted chunk must validate");
17440 let AgentStreamChunkKindV1::Blocked {
17441 correlation_id,
17442 tool_class,
17443 authority,
17444 reason_kind,
17445 reason,
17446 help,
17447 } = chunk.kind
17448 else {
17449 panic!("expected AgentStreamChunkKindV1::Blocked");
17450 };
17451 (correlation_id, tool_class, authority, reason_kind, reason, help)
17452 }
17453
17454 #[test]
17458 fn the_enabled_provider_matrix_is_exact() {
17459 let registry = active_registry().unwrap();
17460 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
17461 assert_eq!(
17462 providers
17463 .iter()
17464 .map(|contract| contract.provider.as_str())
17465 .collect::<Vec<_>>(),
17466 vec!["claude", "codex", "grok", "kimi"],
17467 );
17468 assert!(adapters.iter().all(|contract| {
17469 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
17470 }));
17471 }
17472
17473 #[test]
17476 fn gate_denied_host_request_mints_a_blocked_chunk() {
17477 let gate_text =
17478 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
17479 let event = denied_host_request(
17480 ProviderHostDecisionAuthority::Gate,
17481 Some(gate_text.to_owned()),
17482 );
17483 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
17484 assert_eq!(chunk.source_sequence, 2);
17485 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
17486 assert_eq!(authority, BlockAuthorityV1::HostGate);
17487 assert_eq!(reason, gate_text);
17488 assert_eq!(correlation_id, None);
17489 assert_eq!(reason_kind, None);
17490 assert_eq!(help, None);
17491 assert_eq!(tool_class, "Write");
17492 }
17493
17494 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.";
17495
17496 #[test]
17502 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
17503 let event = provider_control_event(
17504 AdapterFamily::Acp,
17505 ProviderEvent::ToolCompleted {
17506 id: "toolu_9".to_owned(),
17507 output: CLASSIFIER_SAMPLE.to_owned(),
17508 is_error: true,
17509 duration_ms: None,
17510 agent_id: None,
17511 non_execution_kind: Some("permission-rule".to_owned()),
17512 },
17513 );
17514 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
17515 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
17516 assert_eq!(reason, "Blocked by classifier");
17517 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
17518 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
17519 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
17520 }
17521
17522 #[test]
17527 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
17528 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.";
17529 let event = provider_control_event(
17530 AdapterFamily::Acp,
17531 ProviderEvent::SessionEnded {
17532 result: "JSON-RPC error -32603: Internal error".to_owned(),
17533 cost_usd: None,
17534 is_error: true,
17535 stop_reason: Some(ProviderStopReason::ProviderError {
17536 code: -32603,
17537 message: message.to_owned(),
17538 vendor_code: Some("usageLimitExceeded".to_owned()),
17539 }),
17540 },
17541 );
17542 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
17543 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
17544 assert_eq!(reason, message);
17545 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
17546 assert_eq!(tool_class, "Session");
17547 }
17548
17549 #[test]
17552 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
17553 let event = provider_control_event(
17554 AdapterFamily::Acp,
17555 ProviderEvent::SessionEnded {
17556 result: "refusal".to_owned(),
17557 cost_usd: None,
17558 is_error: true,
17559 stop_reason: Some(ProviderStopReason::Refusal),
17560 },
17561 );
17562 let (_, _, authority, _, _, _) = blocked(&event);
17563 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
17564 }
17565
17566 #[test]
17570 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
17571 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
17572 let event = provider_control_event(
17573 AdapterFamily::Acp,
17574 ProviderEvent::ToolCompleted {
17575 id: "toolu_1".to_owned(),
17576 output: "command exited with status 1".to_owned(),
17577 is_error: true,
17578 duration_ms: Some(12),
17579 agent_id: None,
17580 non_execution_kind,
17581 },
17582 );
17583 assert!(agent_stream_chunk(&event).is_none());
17584 }
17585 let narrative = provider_control_event(
17586 AdapterFamily::Acp,
17587 ProviderEvent::ToolCompleted {
17588 id: "toolu_2".to_owned(),
17589 output: "the harness blocked the write, approval needed".to_owned(),
17590 is_error: false,
17591 duration_ms: Some(3),
17592 agent_id: None,
17593 non_execution_kind: None,
17594 },
17595 );
17596 assert!(agent_stream_chunk(&narrative).is_none());
17597 }
17598
17599 #[test]
17603 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
17604 for (decision, outcome) in [
17605 (
17606 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17607 ProviderHostRequestOutcome::Executed,
17608 ),
17609 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
17610 (
17611 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17612 ProviderHostRequestOutcome::Failed {
17613 error: "terminal/create spawn failed: os error 3".to_owned(),
17614 },
17615 ),
17616 ] {
17617 let event = provider_control_event_at(
17618 AdapterFamily::Acp,
17619 "claude-code",
17620 3,
17621 ProviderEvent::HostRequestObserved {
17622 method: "fs/read_text_file".to_owned(),
17623 params_json: String::new(),
17624 decision: decision.clone(),
17625 outcome,
17626 reason: None,
17627 },
17628 );
17629 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
17630 }
17631 }
17632
17633 #[test]
17637 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
17638 for (by, expected_authority, expected_reason) in [
17639 (
17640 ProviderHostDecisionAuthority::Operator,
17641 BlockAuthorityV1::Operator,
17642 "declined by the operator",
17643 ),
17644 (
17645 ProviderHostDecisionAuthority::DeadlinePolicy,
17646 BlockAuthorityV1::HostDeadline,
17647 "denied by host policy after the operator deadline expired",
17648 ),
17649 ] {
17650 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
17651 assert_eq!(authority, expected_authority);
17652 assert_eq!(reason, expected_reason);
17653 }
17654 }
17655
17656 #[test]
17660 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
17661 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
17662 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
17663 ProviderHostDecisionAuthority::Policy,
17664 Some(oversized),
17665 ));
17666 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
17667 }
17668}
17669
17670#[cfg(test)]
17671mod standalone_capability_platform_tests {
17672 use super::*;
17673
17674 #[test]
17675 fn standalone_workspace_capability_requires_windows_identity_guards() {
17676 let capabilities = baseline_capabilities().unwrap();
17677 let advertised = capabilities.iter().any(|capability| {
17678 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
17679 });
17680 assert_eq!(advertised, cfg!(windows));
17681
17682 let request = NodeRequest::CreateStandaloneWorkspace {
17683 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
17684 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
17685 initial_branch: None,
17686 };
17687 assert_eq!(
17688 request_uses_unnegotiated_capability(&request, &capabilities),
17689 !cfg!(windows),
17690 );
17691 }
17692}
17693
17694
17695#[cfg(test)]
17696mod station_knobs_resolve_tests {
17697 use super::*;
17698 use gate4agent_catalog::EnvMutation;
17699 use gate4agent_runtime_native::{
17700 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
17701 NativeLaunchProfileId,
17702 };
17703 use std::ffi::OsString;
17704 use std::sync::Arc;
17705
17706 struct EmptyEnvironmentResolver;
17707
17708 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
17709 fn resolve_child_environment(
17710 &self,
17711 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
17712 Ok(vec![EnvMutation {
17713 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
17714 value: None,
17715 }])
17716 }
17717 }
17718
17719 fn agent(value: &str) -> AgentId {
17720 AgentId::new(value).unwrap()
17721 }
17722
17723 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
17724 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
17725 let profile_revision =
17726 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
17727 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
17728 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
17729 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
17730 provider: agent("claude"),
17731 mode: SessionMode::Pty,
17732 terminal_size: gate4agent_types::TerminalSize {
17733 rows: 24,
17734 columns: 80,
17735 },
17736 prompt: None,
17737 bundle_id: None,
17738 context_id: None,
17739 environment_profile_id: Some(profile_id.clone()),
17740 };
17741 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
17742 let catalog = active_registry().unwrap();
17743 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
17744 let control = runtime.native_launch_profile_control();
17745 let node_profile = NodeEnvironmentProfile::new(
17746 profile_id.clone(),
17747 profile_revision,
17748 agent("claude"),
17749 [NativeLaunchProfile::new(
17750 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
17751 agent("claude"),
17752 TransportKind::Pty,
17753 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
17754 Arc::new(EmptyEnvironmentResolver),
17755 )
17756 .unwrap()],
17757 )
17758 .unwrap();
17759 let (binding, native_profiles, _materialization) = node_profile.into_parts();
17760 for native_profile in native_profiles {
17761 runtime.upsert_native_launch_profile(native_profile).unwrap();
17762 }
17763 let workspace = WorkspaceConfig::new(
17764 WorkspaceId::new("primary").unwrap(),
17765 std::env::current_dir().unwrap(),
17766 )
17767 .unwrap();
17768 let shared = NodeShared::new_with_incarnation(
17769 handle,
17770 "fixture-token".to_owned(),
17771 NodeId::new("node-station-knobs-test").unwrap(),
17772 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
17773 vec![workspace],
17774 vec![agent("claude")],
17775 ProviderRuntimeStatuses::default(),
17776 None,
17777 Vec::new(),
17778 Vec::new(),
17779 spawn_profiles,
17780 Some(control),
17781 None,
17782 None,
17783 None,
17784 Vec::new(),
17785 Vec::new(),
17786 Vec::new(),
17787 Vec::new(),
17788 None,
17789 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
17790 );
17791 shared
17792 .environment_profiles
17793 .write()
17794 .unwrap_or_else(|poisoned| poisoned.into_inner())
17795 .insert(binding.id.clone(), binding);
17796 (shared, spawn_defaults)
17797 }
17798
17799 #[test]
17802 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
17803 let (shared, defaults) = station_knobs_fixture();
17804 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
17805 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17806 let spec = crate::protocol::SpawnSpec {
17807 target: crate::protocol::SpawnTarget {
17808 node_id: shared.node_id.clone(),
17809 workspace_id: WorkspaceId::new("primary").unwrap(),
17810 worktree_id: None,
17811 },
17812 profile_id: defaults.profile_id.clone(),
17813 expected_profile_revision: defaults.revision.clone(),
17814 overrides: crate::protocol::SpawnOverrides {
17815 network_allowlist: Some(unknown),
17816 browser_profile_id: Some(browser),
17817 ..crate::protocol::SpawnOverrides::default()
17818 },
17819 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17820 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17821 "station-knobs-unknown-allowlist",
17822 )
17823 .unwrap(),
17824 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17825 };
17826 let resolved = spec.resolve(&defaults).unwrap();
17827 let failure = shared
17828 .resolve_environment_profile(&resolved)
17829 .expect_err("unknown allowlist must refuse");
17830 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
17831 }
17832
17833 #[test]
17839 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
17840 let (shared, defaults) = station_knobs_fixture();
17841 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
17842 #[cfg(not(feature = "dig2-station-probe"))]
17843 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17844 shared
17845 .network_allowlist_catalog
17846 .write()
17847 .unwrap_or_else(|poisoned| poisoned.into_inner())
17848 .insert_id(allowlist.clone())
17849 .unwrap();
17850 let spec = crate::protocol::SpawnSpec {
17851 target: crate::protocol::SpawnTarget {
17852 node_id: shared.node_id.clone(),
17853 workspace_id: WorkspaceId::new("primary").unwrap(),
17854 worktree_id: None,
17855 },
17856 profile_id: defaults.profile_id.clone(),
17857 expected_profile_revision: defaults.revision.clone(),
17858 overrides: crate::protocol::SpawnOverrides {
17859 network_allowlist: Some(allowlist.clone()),
17860 #[cfg(not(feature = "dig2-station-probe"))]
17861 browser_profile_id: Some(browser.clone()),
17862 ..crate::protocol::SpawnOverrides::default()
17863 },
17864 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17865 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
17866 .unwrap(),
17867 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17868 };
17869 let resolved = spec.resolve(&defaults).unwrap();
17870 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
17871 #[cfg(not(feature = "dig2-station-probe"))]
17872 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
17873 let receipt = shared
17874 .resolve_environment_profile(&resolved)
17875 .expect("resolve ok")
17876 .expect("env profile present");
17877 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
17878 #[cfg(not(feature = "dig2-station-probe"))]
17879 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
17880 #[cfg(feature = "dig2-station-probe")]
17881 assert_eq!(receipt.browser_profile_id, None);
17882 assert_eq!(receipt.profile_id.as_str(), "local-claude");
17883
17884 let omitted = crate::protocol::SpawnSpec {
17885 overrides: crate::protocol::SpawnOverrides::default(),
17886 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
17887 .unwrap(),
17888 ..spec
17889 };
17890 let resolved_omit = omitted.resolve(&defaults).unwrap();
17891 let bare = shared
17892 .resolve_environment_profile(&resolved_omit)
17893 .expect("resolve ok")
17894 .expect("env profile present");
17895 assert_eq!(bare.network_allowlist, None);
17896 assert_eq!(bare.browser_profile_id, None);
17897 }
17898
17899 #[cfg(feature = "dig2-station-probe")]
17904 #[test]
17905 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
17906 let (shared, defaults) = station_knobs_fixture();
17907 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17908 let spec = crate::protocol::SpawnSpec {
17909 target: crate::protocol::SpawnTarget {
17910 node_id: shared.node_id.clone(),
17911 workspace_id: WorkspaceId::new("primary").unwrap(),
17912 worktree_id: None,
17913 },
17914 profile_id: defaults.profile_id.clone(),
17915 expected_profile_revision: defaults.revision.clone(),
17916 overrides: crate::protocol::SpawnOverrides {
17917 browser_profile_id: Some(browser),
17918 ..crate::protocol::SpawnOverrides::default()
17919 },
17920 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17921 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17922 "station-knobs-probe-refuse",
17923 )
17924 .unwrap(),
17925 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17926 };
17927 let resolved = spec.resolve(&defaults).unwrap();
17928 let failure = shared
17929 .resolve_environment_profile(&resolved)
17930 .expect_err("browser_profile with failing probe must refuse");
17931 #[cfg(windows)]
17932 {
17933 assert!(
17934 matches!(
17935 failure.code,
17936 NodeFailureCode::BrowserStationUnreachable
17937 | NodeFailureCode::BrowserStationProbeUnavailable
17938 ),
17939 "unexpected code {:?}",
17940 failure.code
17941 );
17942 }
17943 #[cfg(not(windows))]
17944 {
17945 assert_eq!(
17946 failure.code,
17947 NodeFailureCode::BrowserStationProbeUnavailable
17948 );
17949 }
17950 }
17951
17952 #[cfg(feature = "dig2-station-probe")]
17954 #[test]
17955 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
17956 let (shared, defaults) = station_knobs_fixture();
17957 let spec = crate::protocol::SpawnSpec {
17958 target: crate::protocol::SpawnTarget {
17959 node_id: shared.node_id.clone(),
17960 workspace_id: WorkspaceId::new("primary").unwrap(),
17961 worktree_id: None,
17962 },
17963 profile_id: defaults.profile_id.clone(),
17964 expected_profile_revision: defaults.revision.clone(),
17965 overrides: crate::protocol::SpawnOverrides::default(),
17966 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17967 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17968 "station-knobs-probe-omit",
17969 )
17970 .unwrap(),
17971 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17972 };
17973 let resolved = spec.resolve(&defaults).unwrap();
17974 let receipt = shared
17975 .resolve_environment_profile(&resolved)
17976 .expect("omit browser_profile must not probe")
17977 .expect("env profile present");
17978 assert_eq!(receipt.browser_profile_id, None);
17979 }
17980
17981 #[cfg(feature = "dig2-station-probe")]
17985 #[test]
17986 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
17987 let (shared, _defaults) = station_knobs_fixture();
17988 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17989 let workspace_id = WorkspaceId::new("primary").unwrap();
17990 let address_a = SessionAddress {
17991 workspace_id: workspace_id.clone(),
17992 session: SessionKey {
17993 instance_id: AgentInstanceId(101),
17994 generation: SessionGeneration(1),
17995 },
17996 };
17997 let address_b = SessionAddress {
17998 workspace_id: workspace_id.clone(),
17999 session: SessionKey {
18000 instance_id: AgentInstanceId(102),
18001 generation: SessionGeneration(1),
18002 },
18003 };
18004 let guard_a = crate::dig2_station_lease::acquire_lease(
18005 &shared.browser_station_leases,
18006 profile.clone(),
18007 address_a.session.instance_id,
18008 1,
18009 )
18010 .expect("first lease");
18011 let busy = crate::dig2_station_lease::acquire_lease(
18012 &shared.browser_station_leases,
18013 profile.clone(),
18014 address_b.session.instance_id,
18015 2,
18016 )
18017 .expect_err("second holder must refuse");
18018 assert!(matches!(
18019 busy,
18020 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18021 ));
18022 shared.bind_session_with_policy(
18024 &address_a,
18025 ProviderRuntimePolicy::raw_pty(),
18026 None,
18027 );
18028 guard_a.retain();
18029 assert!(shared.remove_binding(&address_a).is_some());
18030 crate::dig2_station_lease::acquire_lease(
18031 &shared.browser_station_leases,
18032 profile,
18033 address_b.session.instance_id,
18034 3,
18035 )
18036 .expect("reacquire after clear")
18037 .retain();
18038 assert!(
18039 crate::dig2_station_lease::release_lease_for_holder(
18040 &shared.browser_station_leases,
18041 address_b.session.instance_id,
18042 )
18043 );
18044 }
18045
18046 #[cfg(not(feature = "dig2-station-probe"))]
18049 #[test]
18050 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18051 let (shared, defaults) = station_knobs_fixture();
18052 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18053 let spec = crate::protocol::SpawnSpec {
18054 target: crate::protocol::SpawnTarget {
18055 node_id: shared.node_id.clone(),
18056 workspace_id: WorkspaceId::new("primary").unwrap(),
18057 worktree_id: None,
18058 },
18059 profile_id: defaults.profile_id.clone(),
18060 expected_profile_revision: defaults.revision.clone(),
18061 overrides: crate::protocol::SpawnOverrides {
18062 browser_profile_id: Some(browser.clone()),
18063 ..crate::protocol::SpawnOverrides::default()
18064 },
18065 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18066 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18067 .unwrap(),
18068 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18069 };
18070 let resolved = spec.resolve(&defaults).unwrap();
18071 let receipt = shared
18072 .resolve_environment_profile(&resolved)
18073 .expect("stub resolve")
18074 .expect("env profile");
18075 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18076 }
18077
18078
18079 #[test]
18082 fn network_allowlist_catalog_loads_from_temp_file() {
18083 let unique = std::time::SystemTime::now()
18084 .duration_since(std::time::UNIX_EPOCH)
18085 .unwrap()
18086 .as_nanos();
18087 let path = std::env::temp_dir().join(format!(
18088 "gate4agent-node-allowlist-config-{}-{unique}.txt",
18089 std::process::id(),
18090 ));
18091 std::fs::write(
18092 &path,
18093 "# opaque station allowlist ids\negress-default\nlab-egress\n",
18094 )
18095 .unwrap();
18096 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18097 .expect("temp catalog file must load");
18098 let _ = std::fs::remove_file(&path);
18099 assert_eq!(loaded.len(), 2);
18100 let (shared, defaults) = station_knobs_fixture();
18101 {
18102 let mut catalog = shared
18103 .network_allowlist_catalog
18104 .write()
18105 .unwrap_or_else(|poisoned| poisoned.into_inner());
18106 *catalog = loaded;
18107 }
18108 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18109 let spec = crate::protocol::SpawnSpec {
18110 target: crate::protocol::SpawnTarget {
18111 node_id: shared.node_id.clone(),
18112 workspace_id: WorkspaceId::new("primary").unwrap(),
18113 worktree_id: None,
18114 },
18115 profile_id: defaults.profile_id.clone(),
18116 expected_profile_revision: defaults.revision.clone(),
18117 overrides: crate::protocol::SpawnOverrides {
18118 network_allowlist: Some(allowlist.clone()),
18119 ..crate::protocol::SpawnOverrides::default()
18120 },
18121 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18122 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18123 "station-knobs-catalog-file",
18124 )
18125 .unwrap(),
18126 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18127 };
18128 let resolved = spec.resolve(&defaults).unwrap();
18129 let receipt = shared
18130 .resolve_environment_profile(&resolved)
18131 .expect("registered via file load")
18132 .expect("env profile present");
18133 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18134 }
18135
18136 #[test]
18139 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18140 let (shared, _defaults) = station_knobs_fixture();
18141 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18142 shared
18143 .network_allowlist_catalog
18144 .write()
18145 .unwrap_or_else(|poisoned| poisoned.into_inner())
18146 .insert_id(allowlist.clone())
18147 .unwrap();
18148 let snapshot = shared.snapshot();
18149 let inventory = snapshot
18150 .launch_inventory
18151 .expect("launch inventory present");
18152 let ids = inventory
18153 .network_allowlists
18154 .expect("non-empty catalog surfaces ids");
18155 assert_eq!(ids, vec![allowlist]);
18156 }
18157
18158 #[test]
18162 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18163 let (shared, defaults) = station_knobs_fixture();
18164 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18165 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18166 id: allowlist.clone(),
18167 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18168 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18169 peer: "127.0.0.1:443".to_owned(),
18170 }],
18171 provider_native: Some(
18172 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18173 codex_network_access: Some(true),
18174 },
18175 ),
18176 };
18177 shared
18178 .network_allowlist_catalog
18179 .write()
18180 .unwrap_or_else(|poisoned| poisoned.into_inner())
18181 .insert(entry)
18182 .unwrap();
18183 let spec = crate::protocol::SpawnSpec {
18184 target: crate::protocol::SpawnTarget {
18185 node_id: shared.node_id.clone(),
18186 workspace_id: WorkspaceId::new("primary").unwrap(),
18187 worktree_id: None,
18188 },
18189 profile_id: defaults.profile_id.clone(),
18190 expected_profile_revision: defaults.revision.clone(),
18191 overrides: crate::protocol::SpawnOverrides {
18192 network_allowlist: Some(allowlist),
18193 ..crate::protocol::SpawnOverrides::default()
18194 },
18195 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18196 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18197 "station-knobs-unsupported-mapping",
18198 )
18199 .unwrap(),
18200 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18201 };
18202 let resolved = spec.resolve(&defaults).unwrap();
18203 assert_eq!(resolved.provider.as_str(), "claude");
18204 let failure = shared
18205 .resolve_environment_profile(&resolved)
18206 .expect_err("claude + codex_network_access must refuse");
18207 assert_eq!(
18208 failure.code,
18209 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18210 );
18211 }
18212
18213 #[test]
18217 fn resolve_codex_network_access_overlay_moderate_only() {
18218 let (shared, defaults) = station_knobs_fixture();
18219 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18220 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18221 id: allowlist.clone(),
18222 permits: Vec::new(),
18223 provider_native: Some(
18224 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18225 codex_network_access: Some(true),
18226 },
18227 ),
18228 };
18229 shared
18230 .network_allowlist_catalog
18231 .write()
18232 .unwrap_or_else(|poisoned| poisoned.into_inner())
18233 .insert(entry)
18234 .unwrap();
18235
18236 let overlay = shared
18237 .resolve_codex_network_access_overlay_args(
18238 &agent("codex"),
18239 SessionMode::Pty,
18240 ApprovalLevel::Moderate,
18241 Some(&allowlist),
18242 )
18243 .expect("Moderate Codex maps network_access");
18244 assert_eq!(
18245 overlay,
18246 vec![
18247 std::ffi::OsString::from("-c"),
18248 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18249 ]
18250 );
18251
18252 let acp = shared
18253 .resolve_codex_network_access_overlay_args(
18254 &agent("codex"),
18255 SessionMode::Acp,
18256 ApprovalLevel::Moderate,
18257 Some(&allowlist),
18258 )
18259 .unwrap();
18260 assert_eq!(acp, overlay);
18261
18262 let inline = shared
18263 .resolve_codex_network_access_overlay_args(
18264 &agent("codex"),
18265 SessionMode::Inline,
18266 ApprovalLevel::Moderate,
18267 Some(&allowlist),
18268 )
18269 .unwrap_err();
18270 assert_eq!(
18271 inline.code,
18272 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18273 );
18274
18275 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18276 let spec = crate::protocol::SpawnSpec {
18277 target: crate::protocol::SpawnTarget {
18278 node_id: shared.node_id.clone(),
18279 workspace_id: WorkspaceId::new("primary").unwrap(),
18280 worktree_id: None,
18281 },
18282 profile_id: defaults.profile_id.clone(),
18283 expected_profile_revision: defaults.revision.clone(),
18284 overrides: crate::protocol::SpawnOverrides {
18285 provider: crate::protocol::SpawnOverride::Set {
18286 value: agent("codex"),
18287 },
18288 approval_level: Some(level),
18289 network_allowlist: Some(allowlist.clone()),
18290 ..crate::protocol::SpawnOverrides::default()
18291 },
18292 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18293 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18294 "codex-net-refuse-{}",
18295 match level {
18296 ApprovalLevel::ReadOnly => "ro",
18297 ApprovalLevel::FullAuto => "fa",
18298 _ => "other",
18299 }
18300 ))
18301 .unwrap(),
18302 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18303 };
18304 let resolved = spec.resolve(&defaults).unwrap();
18305 let failure = shared
18306 .resolve_environment_profile(&resolved)
18307 .expect_err("RO/FullAuto must refuse");
18308 assert_eq!(
18309 failure.code,
18310 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18311 );
18312 }
18313 }
18314
18315 #[test]
18319 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18320 let (shared, _defaults) = station_knobs_fixture();
18321 let allowlist =
18322 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18323 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18324 id: allowlist.clone(),
18325 permits: vec![
18326 crate::network_allowlist_catalog::NetworkPermitSketch {
18327 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18328 peer: "github.com:443".to_owned(),
18329 },
18330 crate::network_allowlist_catalog::NetworkPermitSketch {
18331 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18332 peer: "*.npmjs.org".to_owned(),
18333 },
18334 ],
18335 provider_native: None,
18336 };
18337 shared
18338 .network_allowlist_catalog
18339 .write()
18340 .unwrap_or_else(|poisoned| poisoned.into_inner())
18341 .insert(entry)
18342 .unwrap();
18343
18344 let unique = std::time::SystemTime::now()
18345 .duration_since(std::time::UNIX_EPOCH)
18346 .unwrap()
18347 .as_nanos();
18348 let provider_home = std::env::temp_dir().join(format!(
18349 "gate4agent-claude-provider-home-{}-{unique}",
18350 std::process::id(),
18351 ));
18352 std::fs::create_dir_all(&provider_home).unwrap();
18353
18354 let overlay = shared
18355 .resolve_network_allowlist_overlay_args(
18356 &agent("claude"),
18357 SessionMode::Pty,
18358 ApprovalLevel::Moderate,
18359 Some(&allowlist),
18360 Some(provider_home.as_path()),
18361 )
18362 .expect("Claude PTY settings overlay on unix");
18363 let settings_path = provider_home.join(
18364 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18365 );
18366 assert_eq!(
18367 overlay,
18368 vec![
18369 std::ffi::OsString::from("--settings"),
18370 std::ffi::OsString::from(settings_path.as_os_str()),
18371 ]
18372 );
18373 let body = std::fs::read_to_string(&settings_path).unwrap();
18374 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18375 assert_eq!(value["sandbox"]["enabled"], true);
18376 assert_eq!(
18377 value["sandbox"]["network"]["allowedDomains"],
18378 serde_json::json!(["github.com", "*.npmjs.org"])
18379 );
18380 assert!(settings_path.starts_with(&provider_home));
18381
18382 let acp = shared
18383 .resolve_network_allowlist_overlay_args(
18384 &agent("claude"),
18385 SessionMode::Acp,
18386 ApprovalLevel::Moderate,
18387 Some(&allowlist),
18388 Some(provider_home.as_path()),
18389 )
18390 .expect_err("Claude ACP must refuse settings overlay");
18391 assert_eq!(
18392 acp.code,
18393 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18394 );
18395
18396 let no_home = shared
18397 .resolve_network_allowlist_overlay_args(
18398 &agent("claude"),
18399 SessionMode::Pty,
18400 ApprovalLevel::Moderate,
18401 Some(&allowlist),
18402 None,
18403 )
18404 .expect_err("Claude overlay requires ProviderHome");
18405 assert_eq!(
18406 no_home.code,
18407 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18408 );
18409
18410 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18411 shared
18412 .network_allowlist_catalog
18413 .write()
18414 .unwrap_or_else(|poisoned| poisoned.into_inner())
18415 .insert_id(bare.clone())
18416 .unwrap();
18417 let empty = shared
18418 .resolve_network_allowlist_overlay_args(
18419 &agent("claude"),
18420 SessionMode::Pty,
18421 ApprovalLevel::Moderate,
18422 Some(&bare),
18423 Some(provider_home.as_path()),
18424 )
18425 .unwrap();
18426 assert!(empty.is_empty());
18427
18428 let _ = std::fs::remove_dir_all(&provider_home);
18429 }
18430
18431 #[test]
18433 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18434 let (shared, _defaults) = station_knobs_fixture();
18435 let allowlist =
18436 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18437 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18438 id: allowlist.clone(),
18439 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18440 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18441 peer: "example.com:443".to_owned(),
18442 }],
18443 provider_native: None,
18444 };
18445 shared
18446 .network_allowlist_catalog
18447 .write()
18448 .unwrap_or_else(|poisoned| poisoned.into_inner())
18449 .insert(entry)
18450 .unwrap();
18451 for provider in ["kimi", "grok"] {
18452 let overlay = shared
18453 .resolve_network_allowlist_overlay_args(
18454 &agent(provider),
18455 SessionMode::Pty,
18456 ApprovalLevel::Moderate,
18457 Some(&allowlist),
18458 None,
18459 )
18460 .unwrap();
18461 assert!(
18462 overlay.is_empty(),
18463 "{provider} must not invent network argv from permits"
18464 );
18465 }
18466 }
18467
18468 #[test]
18470 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
18471 let unique = std::time::SystemTime::now()
18472 .duration_since(std::time::UNIX_EPOCH)
18473 .unwrap()
18474 .as_nanos();
18475 let path = std::env::temp_dir().join(format!(
18476 "gate4agent-node-allowlist-json-{}-{unique}.json",
18477 std::process::id(),
18478 ));
18479 std::fs::write(
18480 &path,
18481 r#"{
18482 "schema_version": 2,
18483 "entries": [
18484 {
18485 "id": "egress-default",
18486 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
18487 },
18488 { "id": "lab-egress" }
18489 ]
18490 }"#,
18491 )
18492 .unwrap();
18493 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18494 .expect("json v2 catalog must load");
18495 let _ = std::fs::remove_file(&path);
18496 assert_eq!(loaded.len(), 2);
18497 let entry = loaded
18498 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
18499 .unwrap();
18500 assert_eq!(entry.permits.len(), 1);
18501 let (shared, defaults) = station_knobs_fixture();
18502 {
18503 let mut catalog = shared
18504 .network_allowlist_catalog
18505 .write()
18506 .unwrap_or_else(|poisoned| poisoned.into_inner());
18507 *catalog = loaded;
18508 }
18509 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18510 let spec = crate::protocol::SpawnSpec {
18511 target: crate::protocol::SpawnTarget {
18512 node_id: shared.node_id.clone(),
18513 workspace_id: WorkspaceId::new("primary").unwrap(),
18514 worktree_id: None,
18515 },
18516 profile_id: defaults.profile_id.clone(),
18517 expected_profile_revision: defaults.revision.clone(),
18518 overrides: crate::protocol::SpawnOverrides {
18519 network_allowlist: Some(allowlist.clone()),
18520 ..crate::protocol::SpawnOverrides::default()
18521 },
18522 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18523 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18524 "station-knobs-catalog-json",
18525 )
18526 .unwrap(),
18527 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18528 };
18529 let resolved = spec.resolve(&defaults).unwrap();
18530 let receipt = shared
18531 .resolve_environment_profile(&resolved)
18532 .expect("permits-only entry ok for claude")
18533 .expect("env profile present");
18534 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18535 let inventory = shared
18536 .snapshot()
18537 .launch_inventory
18538 .expect("launch inventory present");
18539 let ids = inventory.network_allowlists.expect("ids surface");
18540 assert_eq!(
18541 ids,
18542 vec![
18543 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
18544 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
18545 ]
18546 );
18547 }
18548
18549
18550}
18551
18552
18553#[cfg(test)]
18554mod workspace_environment_collision_tests {
18555 use super::*;
18556 use crate::session_environment::{
18557 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
18558 };
18559 use gate4agent_catalog::EnvMutation;
18560 use gate4agent_runtime_native::{
18561 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18562 NativeLaunchProfileId,
18563 };
18564 use std::ffi::OsString;
18565 use std::sync::Arc;
18566
18567 struct EmptyEnvironmentResolver;
18568
18569 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18570 fn resolve_child_environment(
18571 &self,
18572 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18573 Ok(vec![EnvMutation {
18574 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18575 value: None,
18576 }])
18577 }
18578 }
18579
18580 fn agent(value: &str) -> AgentId {
18581 AgentId::new(value).unwrap()
18582 }
18583
18584 fn claude_config_dir_collision_fixture() -> (
18586 NodeShared,
18587 ResolvedEnvironmentProfileReceipt,
18588 ResolvedEnvironmentProfileReceipt,
18589 ) {
18590 let catalog = active_registry().unwrap();
18591 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18592 let control = runtime.native_launch_profile_control();
18593 let workspace = WorkspaceConfig::new(
18594 WorkspaceId::new("primary").unwrap(),
18595 std::env::current_dir().unwrap(),
18596 )
18597 .unwrap();
18598 let shared = NodeShared::new_with_incarnation(
18599 handle,
18600 "fixture-token".to_owned(),
18601 NodeId::new("node-env-collision-test").unwrap(),
18602 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18603 vec![workspace],
18604 vec![agent("claude")],
18605 ProviderRuntimeStatuses::default(),
18606 None,
18607 Vec::new(),
18608 Vec::new(),
18609 SpawnProfileRegistry::default(),
18610 Some(control),
18611 None,
18612 None,
18613 None,
18614 Vec::new(),
18615 Vec::new(),
18616 Vec::new(),
18617 Vec::new(),
18618 None,
18619 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18620 );
18621
18622 let mut receipts = Vec::new();
18623 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
18624 let profile_id =
18625 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
18626 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
18627 "claude-config-dir-{suffix}-r1"
18628 ))
18629 .unwrap();
18630 let materialization = NodeSessionMaterializationProfile::new(
18631 Vec::new(),
18632 vec![NodeSessionPathBinding::new(
18633 "CLAUDE_CONFIG_DIR",
18634 NodeSessionPathClass::ProviderHome,
18635 )
18636 .unwrap()],
18637 Vec::new(),
18638 )
18639 .unwrap();
18640 let node_profile = NodeEnvironmentProfile::new_with_materialization(
18641 profile_id.clone(),
18642 profile_revision.clone(),
18643 agent("claude"),
18644 [NativeLaunchProfile::new(
18645 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
18646 .unwrap(),
18647 agent("claude"),
18648 TransportKind::Pty,
18649 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18650 Arc::new(EmptyEnvironmentResolver),
18651 )
18652 .unwrap()],
18653 Some(materialization),
18654 )
18655 .unwrap();
18656 let (binding, native_profiles, materialization) = node_profile.into_parts();
18657 for native_profile in native_profiles {
18658 runtime.upsert_native_launch_profile(native_profile).unwrap();
18659 }
18660 shared
18661 .environment_profiles
18662 .write()
18663 .unwrap_or_else(|poisoned| poisoned.into_inner())
18664 .insert(binding.id.clone(), binding);
18665 shared
18666 .environment_materialization_profiles
18667 .write()
18668 .unwrap_or_else(|poisoned| poisoned.into_inner())
18669 .insert(profile_id.clone(), materialization.unwrap());
18670 receipts.push(ResolvedEnvironmentProfileReceipt {
18671 profile_id,
18672 profile_revision,
18673 network_allowlist: None,
18674 browser_profile_id: None,
18675 });
18676 }
18677 let profile_b = receipts.pop().unwrap();
18678 let profile_a = receipts.pop().unwrap();
18679 (shared, profile_a, profile_b)
18680 }
18681
18682 #[test]
18683 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
18684 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18685 let primary = WorkspaceId::new("primary").unwrap();
18686 let worktree = WorkspaceId::new("worktree-b").unwrap();
18687 let address = SessionAddress {
18688 workspace_id: primary.clone(),
18689 session: SessionKey {
18690 instance_id: AgentInstanceId(41),
18691 generation: SessionGeneration(1),
18692 },
18693 };
18694 shared.bind_session_with_policy(
18695 &address,
18696 ProviderRuntimePolicy::raw_pty(),
18697 Some(profile_a.clone()),
18698 );
18699
18700 let refused = shared
18701 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
18702 .unwrap_err();
18703 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
18704
18705 let refused_none = shared
18707 .ensure_workspace_environment_profile_compatible(&primary, None)
18708 .unwrap_err();
18709 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
18710
18711 shared
18713 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
18714 .unwrap();
18715
18716 shared
18718 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
18719 .unwrap();
18720 }
18721
18722 #[tokio::test]
18723 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
18724 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18725 let primary = WorkspaceId::new("primary").unwrap();
18726 let address = SessionAddress {
18727 workspace_id: primary.clone(),
18728 session: SessionKey {
18729 instance_id: AgentInstanceId(41),
18730 generation: SessionGeneration(1),
18731 },
18732 };
18733 shared.bind_session_with_policy(
18734 &address,
18735 ProviderRuntimePolicy::raw_pty(),
18736 Some(profile_a),
18737 );
18738
18739 let failure = shared
18740 .spawn_session_with_deadline(
18741 primary,
18742 agent("claude"),
18743 SessionMode::Pty,
18744 gate4agent_types::TerminalSize {
18745 rows: 24,
18746 columns: 80,
18747 },
18748 None,
18749 Some(profile_b),
18750 None,
18751 None,
18752 None,
18753 None,
18754 SpawnRecordPolicy::ProviderIdentityOnly,
18755 Some(Instant::now() + Duration::from_secs(30)),
18756 &[],
18757 None,
18758 ApprovalLevel::default(),
18759 None,
18760 None,
18761 )
18762 .await
18763 .unwrap_err();
18764 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
18765 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
18767 assert!(shared.handle.snapshot().sessions.is_empty());
18768 }
18769}
18770
18771#[cfg(test)]
18772mod spawn_characterization_tests {
18773 use super::*;
18774 use crate::session_environment::{
18775 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
18776 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
18777 NodeSessionPathClass,
18778 };
18779 use gate4agent_catalog::EnvMutation;
18780 use gate4agent_runtime_native::{
18781 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
18782 NativeLaunchProfile, NativeLaunchProfileId,
18783 };
18784 use std::ffi::OsString;
18785 use std::sync::Arc;
18786
18787 struct EmptyEnvironmentResolver;
18788
18789 struct FixtureSecretResolver {
18790 deny: Arc<AtomicBool>,
18791 }
18792
18793 impl NodeSecretResolver for FixtureSecretResolver {
18794 fn resolve(
18795 &self,
18796 _reference: &NodeSecretReference,
18797 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
18798 if self.deny.load(Ordering::Acquire) {
18799 Err(NodeSecretResolveError::Denied)
18800 } else {
18801 NodeSecretValue::text("fixture-secret")
18802 .map_err(|_| NodeSecretResolveError::Unavailable)
18803 }
18804 }
18805 }
18806
18807 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18808 fn resolve_child_environment(
18809 &self,
18810 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18811 Ok(vec![EnvMutation {
18812 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18813 value: None,
18814 }])
18815 }
18816 }
18817
18818 fn agent(value: &str) -> AgentId {
18819 AgentId::new(value).unwrap()
18820 }
18821
18822 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
18823 let unique = std::time::SystemTime::now()
18824 .duration_since(std::time::UNIX_EPOCH)
18825 .unwrap()
18826 .as_nanos();
18827 std::env::temp_dir().join(format!(
18828 "gate4agent-node-{label}-{}-{unique}",
18829 std::process::id(),
18830 ))
18831 }
18832
18833 fn materialization_test_shared(
18834 deny_secret: bool,
18835 ) -> (
18836 NodeShared,
18837 NativeRuntime,
18838 ResolvedEnvironmentProfileReceipt,
18839 PathBuf,
18840 Arc<AtomicBool>,
18841 ) {
18842 let workspace = WorkspaceConfig::new(
18843 WorkspaceId::new("primary").unwrap(),
18844 std::env::current_dir().unwrap(),
18845 )
18846 .unwrap();
18847 materialization_test_shared_with_workspace(deny_secret, workspace)
18848 }
18849
18850 fn materialization_test_shared_with_workspace(
18851 deny_secret: bool,
18852 workspace: WorkspaceConfig,
18853 ) -> (
18854 NodeShared,
18855 NativeRuntime,
18856 ResolvedEnvironmentProfileReceipt,
18857 PathBuf,
18858 Arc<AtomicBool>,
18859 ) {
18860 let root = temporary_workspace_root(if deny_secret {
18861 "materialization-denied"
18862 } else {
18863 "materialization-ready"
18864 });
18865 std::fs::create_dir_all(&root).unwrap();
18866 let materialization_root = root.join("session-environments");
18867 let deny = Arc::new(AtomicBool::new(deny_secret));
18868 let resolver = Arc::new(FixtureSecretResolver {
18869 deny: Arc::clone(&deny),
18870 });
18871 let materializer = SessionEnvironmentMaterializer::new(
18872 materialization_root,
18873 resolver,
18874 )
18875 .unwrap();
18876 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
18877 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
18878 "materialized-claude-r1",
18879 )
18880 .unwrap();
18881 let materialization = NodeSessionMaterializationProfile::new(
18882 vec![NodeSessionEnvironmentMutation::SetSecret {
18883 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
18884 reference: NodeSecretReference::new("fixture-token").unwrap(),
18885 }],
18886 vec![NodeSessionPathBinding::new(
18887 "GATE4AGENT_PROVIDER_HOME",
18888 NodeSessionPathClass::ProviderHome,
18889 )
18890 .unwrap()],
18891 vec![NodeSessionFile::secret(
18892 NodeSessionPathClass::Config,
18893 "auth/token",
18894 NodeSecretReference::new("fixture-token").unwrap(),
18895 )
18896 .unwrap()],
18897 )
18898 .unwrap();
18899 let catalog = active_registry().unwrap();
18900 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18901 let control = runtime.native_launch_profile_control();
18902 let node_profile = NodeEnvironmentProfile::new_with_materialization(
18903 profile_id.clone(),
18904 profile_revision.clone(),
18905 agent("claude"),
18906 [NativeLaunchProfile::new(
18907 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
18908 agent("claude"),
18909 TransportKind::Pty,
18910 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18911 Arc::new(EmptyEnvironmentResolver),
18912 )
18913 .unwrap()],
18914 Some(materialization),
18915 )
18916 .unwrap();
18917 let (binding, native_profiles, materialization) = node_profile.into_parts();
18918 for native_profile in native_profiles {
18919 runtime.upsert_native_launch_profile(native_profile).unwrap();
18920 }
18921 let shared = NodeShared::new_with_incarnation(
18922 handle,
18923 "fixture-token".to_owned(),
18924 NodeId::new("node-materialization-test").unwrap(),
18925 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18926 vec![workspace],
18927 vec![agent("claude")],
18928 ProviderRuntimeStatuses::default(),
18929 None,
18930 Vec::new(),
18931 Vec::new(),
18932 SpawnProfileRegistry::default(),
18933 Some(control),
18934 None,
18935 Some(materializer),
18936 None,
18937 Vec::new(),
18938 Vec::new(),
18939 Vec::new(),
18940 Vec::new(),
18941 None,
18942 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18943 );
18944 shared
18945 .environment_profiles
18946 .write()
18947 .unwrap_or_else(|poisoned| poisoned.into_inner())
18948 .insert(binding.id.clone(), binding);
18949 shared
18950 .environment_materialization_profiles
18951 .write()
18952 .unwrap_or_else(|poisoned| poisoned.into_inner())
18953 .insert(profile_id.clone(), materialization.unwrap());
18954 (
18955 shared,
18956 runtime,
18957 ResolvedEnvironmentProfileReceipt {
18958 profile_id,
18959 profile_revision,
18960 network_allowlist: None,
18961 browser_profile_id: None,
18962 },
18963 root,
18964 deny,
18965 )
18966 }
18967
18968
18969 async fn characterization_spawn(
18972 shared: &NodeShared,
18973 mode: SessionMode,
18974 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
18975 required: &[ProviderRuntimeCapability],
18976 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
18977 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
18978 shared
18979 .spawn_session_with_deadline(
18980 WorkspaceId::new("primary").unwrap(),
18981 agent("claude"),
18982 mode,
18983 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
18984 None,
18985 environment_profile,
18986 None,
18987 None,
18988 None,
18989 Some(ProviderRuntimePolicy::raw_pty()),
18990 SpawnRecordPolicy::ProviderIdentityOnly,
18991 Some(Instant::now() + Duration::from_secs(30)),
18992 required,
18993 harness_mcp,
18994 ApprovalLevel::default(),
18995 None,
18996 None,
18997 )
18998 .await
18999 }
19000
19001 fn assert_no_session_state(shared: &NodeShared) {
19002 assert!(shared.handle.snapshot().sessions.is_empty());
19003 assert!(shared
19004 .session_bindings
19005 .lock()
19006 .unwrap_or_else(|poisoned| poisoned.into_inner())
19007 .is_empty());
19008 assert!(shared
19009 .materializations
19010 .lock()
19011 .unwrap_or_else(|poisoned| poisoned.into_inner())
19012 .is_empty());
19013 }
19014
19015 #[tokio::test]
19016 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19017 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19018 let error = characterization_spawn(
19019 &shared,
19020 SessionMode::Pty,
19021 None,
19022 &[ProviderRuntimeCapability::SemanticReadiness],
19023 None,
19024 )
19025 .await
19026 .unwrap_err();
19027 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19028 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19029 assert_no_session_state(&shared);
19030 drop(shared);
19031 drop(runtime);
19032 std::fs::remove_dir_all(root).unwrap();
19033 }
19034
19035 #[tokio::test]
19036 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19037 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19038 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19039 .await
19040 .unwrap_err();
19041 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19042 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19043 assert_no_session_state(&shared);
19044 drop(shared);
19045 drop(runtime);
19046 std::fs::remove_dir_all(root).unwrap();
19047 }
19048
19049 #[test]
19050 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19051 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19052 let address = SessionAddress {
19053 workspace_id: WorkspaceId::new("primary").unwrap(),
19054 session: SessionKey {
19055 instance_id: AgentInstanceId(77),
19056 generation: SessionGeneration(1),
19057 },
19058 };
19059 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19060 let removed = shared.remove_binding(&address).expect("binding removed");
19061 assert!(removed.environment_profile.is_some());
19062 assert!(shared.remove_binding(&address).is_none());
19063 assert_no_session_state(&shared);
19064 drop(shared);
19065 drop(runtime);
19066 std::fs::remove_dir_all(root).unwrap();
19067 }
19068
19069 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19070 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19071 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19072 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19073 .expect("review current test executable as fixture helper");
19074 let registry = HarnessMcpProxyRegistry::new(helper);
19075 let spec = crate::protocol::SpawnSpec {
19076 target: crate::protocol::SpawnTarget {
19077 node_id: NodeId::new("node-characterization").unwrap(),
19078 workspace_id: WorkspaceId::new("primary").unwrap(),
19079 worktree_id: None,
19080 },
19081 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19082 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19083 overrides: crate::protocol::SpawnOverrides::default(),
19084 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19085 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19086 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19087 };
19088 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19089 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19090 let launch = HarnessMcpLaunchV1 {
19091 server_name: "fixture-harness".to_owned(),
19092 args: vec!["--session-proxy".to_owned()],
19093 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19094 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19095 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19096 trace: None,
19097 scrub_env: Vec::new(),
19098 };
19099 let expires = unix_time_ms().saturating_add(30_000);
19100 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19101 registry
19102 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19103 .unwrap()
19104 }
19105
19106 #[tokio::test]
19107 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19108 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19109 let prepared = characterization_prepared_harness("claude").await;
19110 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19111 .await
19112 .unwrap_err();
19113 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19115 let mismatched = characterization_prepared_harness("codex").await;
19116 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19117 .await
19118 .unwrap_err();
19119 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19120 assert_no_session_state(&shared);
19121 drop(shared);
19122 drop(runtime);
19123 std::fs::remove_dir_all(root).unwrap();
19124 }
19125
19126 #[tokio::test]
19127 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19128 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19129 shared.native_launch_profile_control = None;
19130 let prepared = characterization_prepared_harness("claude").await;
19131 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19132 .await
19133 .unwrap_err();
19134 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19135 assert_no_session_state(&shared);
19136 drop(shared);
19137 drop(runtime);
19138 std::fs::remove_dir_all(root).unwrap();
19139 }
19140}
19141
19142#[cfg(all(test, windows))]
19143mod tests {
19144 use super::*;
19145 use gate4agent_types::ProviderActivity;
19146 use crate::session_environment::{
19147 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19148 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19149 NodeSessionPathClass,
19150 };
19151 use gate4agent_catalog::EnvMutation;
19152 use gate4agent_runtime_native::{
19153 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19154 NativeLaunchProfile, NativeLaunchProfileId,
19155 };
19156 use std::ffi::OsString;
19157 use std::sync::Arc;
19158
19159 struct EmptyEnvironmentResolver;
19160
19161 struct FixtureSecretResolver {
19162 deny: Arc<AtomicBool>,
19163 }
19164
19165 impl NodeSecretResolver for FixtureSecretResolver {
19166 fn resolve(
19167 &self,
19168 _reference: &NodeSecretReference,
19169 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19170 if self.deny.load(Ordering::Acquire) {
19171 Err(NodeSecretResolveError::Denied)
19172 } else {
19173 NodeSecretValue::text("fixture-secret")
19174 .map_err(|_| NodeSecretResolveError::Unavailable)
19175 }
19176 }
19177 }
19178
19179 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19180 fn resolve_child_environment(
19181 &self,
19182 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19183 Ok(vec![EnvMutation {
19184 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19185 value: None,
19186 }])
19187 }
19188 }
19189
19190 fn agent(value: &str) -> AgentId {
19191 AgentId::new(value).unwrap()
19192 }
19193
19194 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19195 let catalog = active_registry().unwrap();
19196 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19197 let workspace = WorkspaceConfig::new(
19198 WorkspaceId::new("primary").unwrap(),
19199 std::env::current_dir().unwrap(),
19200 )
19201 .unwrap()
19202 .with_worktree_service_mode(mode);
19203 NodeShared::new(
19204 handle,
19205 "fixture-token".to_owned(),
19206 NodeId::new("node-terminal-test").unwrap(),
19207 vec![workspace],
19208 vec![agent("claude")],
19209 )
19210 }
19211
19212 fn terminal_test_shared() -> NodeShared {
19213 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19214 }
19215
19216 #[cfg(feature = "fixture")]
19217 #[test]
19218 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19219 let probe = SpawnManagedWorktreeV2FailureProbe::default();
19220 assert_eq!(probe.latest_code(), None);
19221
19222 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19223 assert_eq!(
19224 probe.latest_code(),
19225 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19226 );
19227
19228 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19229 assert_eq!(
19230 probe.latest_code(),
19231 Some(NodeFailureCode::SpawnDeadlineExceeded),
19232 );
19233
19234 probe.record(None);
19235 assert_eq!(probe.latest_code(), None);
19236 }
19237
19238 #[test]
19239 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19240 let source = temporary_workspace_root("launch-inventory-source");
19241 let allocation = temporary_workspace_root("launch-inventory-allocation");
19242 std::fs::create_dir_all(&source).unwrap();
19243 std::fs::create_dir_all(&allocation).unwrap();
19244 let workspace_id = WorkspaceId::new("primary").unwrap();
19245 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19246 .unwrap()
19247 .with_worktree_service_mode(WorktreeServiceMode::Managed);
19248 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19249 workspace = workspace
19250 .with_managed_worktree_profile(
19251 ManagedWorktreeProfile::new(
19252 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19253 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19254 &allocation,
19255 "gate4agent",
19256 "HEAD",
19257 ManagedWorktreeRetention::RemoveWhenReleased,
19258 )
19259 .unwrap(),
19260 )
19261 .unwrap();
19262 }
19263 let overflow = workspace.clone().with_managed_worktree_profile(
19264 ManagedWorktreeProfile::new(
19265 WorktreeProfileId::new("overflow").unwrap(),
19266 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19267 &allocation,
19268 "gate4agent",
19269 "HEAD",
19270 ManagedWorktreeRetention::Retain,
19271 )
19272 .unwrap(),
19273 );
19274 assert!(matches!(
19275 overflow,
19276 Err(NodeServerError::ManagedWorktreeProfileCapacity {
19277 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19278 ..
19279 }),
19280 ));
19281
19282 let catalog = active_registry().unwrap();
19283 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19284 let shared = NodeShared::new(
19285 handle,
19286 "fixture-token".to_owned(),
19287 NodeId::new("node-launch-inventory").unwrap(),
19288 vec![workspace],
19289 vec![agent("claude")],
19290 );
19291 let snapshot = shared.snapshot();
19292 let launch = snapshot.launch_inventory.as_ref().unwrap();
19293 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19294 assert!(launch.bundles.as_ref().unwrap().is_empty());
19295 let profiles = snapshot.workspaces[0]
19296 .managed_worktree_profiles
19297 .as_ref()
19298 .unwrap();
19299 assert_eq!(
19300 snapshot.workspaces[0].worktree_service_mode,
19301 Some(WorktreeServiceMode::Managed),
19302 );
19303 assert_eq!(
19304 profiles.profiles.len(),
19305 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19306 );
19307 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19308 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19309 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19310 }
19311 let legacy = snapshot_for_wire(
19312 &shared,
19313 true,
19314 true,
19315 false,
19316 false,
19317 true,
19318 false,
19319 true,
19320 false,
19321 true,
19322 );
19323 assert!(legacy.launch_inventory.is_none());
19324 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19325
19326 drop(shared);
19327 std::fs::remove_dir_all(source).unwrap();
19328 std::fs::remove_dir_all(allocation).unwrap();
19329 }
19330
19331 #[test]
19332 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19333 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19334 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19335 }));
19336 let source = gate4agent_types::ProviderSource {
19337 family: AdapterFamily::PtySemantic,
19338 binding: AdapterBinding::new(
19339 gate4agent_types::AdapterId::new("claude").unwrap(),
19340 "fixture/v1",
19341 gate4agent_types::AdapterVerification::SyntheticFixture,
19342 )
19343 .unwrap(),
19344 };
19345 let provider = ProviderSnapshot {
19346 sequence: 17,
19347 session: Some(ProviderSessionIdentity {
19348 key: ProviderSessionKey::SessionId,
19349 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19350 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
19351 }),
19352 model: Some("PRIVATE_MODEL".to_owned()),
19353 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
19354 completed_turns: 3,
19355 usage: gate4agent_types::TokenUsage {
19356 input_tokens: 10,
19357 output_tokens: 20,
19358 cache_read_tokens: 30,
19359 cache_write_tokens: 40,
19360 reasoning_tokens: 50,
19361 context_window: Some(200_000),
19362 },
19363 lead_activity: ProviderActivity::WaitingForInput,
19364 activity: ProviderActivity::WaitingForInput,
19365 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
19366 active_tools: (0..9)
19367 .map(|index| gate4agent_types::ActiveProviderTool {
19368 id: format!("private-tool-id-{index}"),
19369 name: if index == 0 {
19370 format!(" Read\u{0000}{}", "x".repeat(80))
19371 } else {
19372 format!("Tool-{index}")
19373 },
19374 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
19375 })
19376 .collect(),
19377 interactions: vec![gate4agent_types::ProviderInteraction {
19378 id: gate4agent_types::ProviderInteractionId(17),
19379 source: source.clone(),
19380 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
19381 interaction_kind: ProviderInteractionKind::Approval,
19382 tool_name: " Approve\u{0007}Tool ".to_owned(),
19383 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
19384 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
19385 resume_lead_activity: Some(ProviderActivity::Working),
19386 status: ProviderInteractionStatus::Pending,
19387 }],
19388 subagents: vec![gate4agent_types::ProviderSubagent {
19389 source: source.clone(),
19390 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
19391 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
19392 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
19393 }],
19394 sources: vec![gate4agent_types::ProviderSourceCursor {
19395 source,
19396 sequence: 17,
19397 gap_count: 2,
19398 stale: true,
19399 }],
19400 last_event: Some(ProviderEvent::Error {
19401 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
19402 }),
19403 gap_count: 2,
19404 stale: true,
19405 };
19406 let address = terminal_address(3);
19407 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
19408 assert_eq!(entry.address, address);
19409 assert_eq!(entry.progress.provider_sequence, 17);
19410 assert_eq!(entry.progress.active_tool_count, 9);
19411 assert_eq!(
19412 entry.progress.active_tool_labels.len(),
19413 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
19414 );
19415 assert_eq!(entry.progress.subagent_count, 1);
19416 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
19417 assert!(entry.progress.truncated);
19418 let encoded = serde_json::to_string(&entry).unwrap();
19419 for forbidden in [
19420 "PRIVATE_PROVIDER_SESSION",
19421 "PRIVATE_PROVIDER_PATH",
19422 "PRIVATE_MODEL",
19423 "PRIVATE_TOOL_INVENTORY",
19424 "PRIVATE_CURRENT_PROMPT",
19425 "PRIVATE_TOOL_INPUT_JSON",
19426 "PRIVATE_PROVIDER_REQUEST",
19427 "PRIVATE_INTERACTION_PROMPT",
19428 "PRIVATE_INTERACTION_AGENT",
19429 "PRIVATE_SUBAGENT_ID",
19430 "PRIVATE_SUBAGENT_TYPE",
19431 "PRIVATE_SUBAGENT_DESCRIPTION",
19432 "PRIVATE_PROVIDER_ERROR",
19433 "input_json",
19434 "current_prompt",
19435 "session_id",
19436 ] {
19437 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
19438 }
19439
19440 let snapshot = NodeSnapshot {
19441 node_id: NodeId::new("node-progress-test").unwrap(),
19442 enabled_providers: vec![agent("claude")],
19443 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19444 workspaces: Vec::new(),
19445 session_records: Vec::new(),
19446 managed_worktrees: Vec::new(),
19447 launch_inventory: None,
19448 agent_progress: vec![entry],
19449 };
19450 let mut legacy_reply = ResponseEnvelope {
19451 request_id: 1,
19452 result: Ok(NodeResponse::Snapshot {
19453 event_sequence: 0,
19454 controller: None,
19455 snapshot: snapshot.clone(),
19456 }),
19457 };
19458 project_response_without_agent_progress(&mut legacy_reply);
19459 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
19460 panic!("agent progress projection changed response kind");
19461 };
19462 assert!(legacy.agent_progress.is_empty());
19463 assert_eq!(snapshot.agent_progress.len(), 1);
19464 }
19465
19466 fn terminal_address(generation: u64) -> SessionAddress {
19467 SessionAddress {
19468 workspace_id: WorkspaceId::new("primary").unwrap(),
19469 session: SessionKey {
19470 instance_id: AgentInstanceId(41),
19471 generation: SessionGeneration(generation),
19472 },
19473 }
19474 }
19475
19476 fn spawn_spec_fixture() -> SpawnSpec {
19477 SpawnSpec {
19478 target: crate::protocol::SpawnTarget {
19479 node_id: NodeId::new("node-terminal-test").unwrap(),
19480 workspace_id: WorkspaceId::new("primary").unwrap(),
19481 worktree_id: None,
19482 },
19483 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19484 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
19485 .unwrap(),
19486 overrides: crate::protocol::SpawnOverrides::default(),
19487 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19488 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
19489 required_capabilities: SpawnRequiredCapabilities::default(),
19490 }
19491 }
19492
19493 fn environment_profile_test_shared(
19494 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
19495 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
19496 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19497 "local-claude-r1",
19498 )
19499 .unwrap();
19500 let spawn_profiles = SpawnProfileRegistry::new([
19501 crate::protocol::SpawnProfileDefaults {
19502 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19503 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
19504 provider: agent("claude"),
19505 mode: SessionMode::Pty,
19506 terminal_size: gate4agent_types::TerminalSize {
19507 rows: 24,
19508 columns: 80,
19509 },
19510 prompt: None,
19511 bundle_id: None,
19512 context_id: None,
19513 environment_profile_id: Some(profile_id.clone()),
19514 },
19515 ])
19516 .unwrap();
19517 let catalog = active_registry().unwrap();
19518 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19519 let control = runtime.native_launch_profile_control();
19520 let node_profile = NodeEnvironmentProfile::new(
19521 profile_id.clone(),
19522 profile_revision.clone(),
19523 agent("claude"),
19524 [NativeLaunchProfile::new(
19525 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
19526 agent("claude"),
19527 TransportKind::Pty,
19528 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19529 Arc::new(EmptyEnvironmentResolver),
19530 )
19531 .unwrap()],
19532 )
19533 .unwrap();
19534 let (binding, native_profiles, _materialization) = node_profile.into_parts();
19535 for native_profile in native_profiles {
19536 runtime.upsert_native_launch_profile(native_profile).unwrap();
19537 }
19538 let workspace = WorkspaceConfig::new(
19539 WorkspaceId::new("primary").unwrap(),
19540 std::env::current_dir().unwrap(),
19541 )
19542 .unwrap();
19543 let shared = NodeShared::new_with_incarnation(
19544 handle,
19545 "fixture-token".to_owned(),
19546 NodeId::new("node-terminal-test").unwrap(),
19547 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19548 vec![workspace],
19549 vec![agent("claude")],
19550 ProviderRuntimeStatuses::default(),
19551 None,
19552 Vec::new(),
19553 Vec::new(),
19554 spawn_profiles,
19555 Some(control),
19556 None,
19557 None,
19558 None,
19559 Vec::new(),
19560 Vec::new(),
19561 Vec::new(),
19562 Vec::new(),
19563 None,
19564 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19565 );
19566 shared
19567 .environment_profiles
19568 .write()
19569 .unwrap_or_else(|poisoned| poisoned.into_inner())
19570 .insert(binding.id.clone(), binding);
19571 (
19572 shared,
19573 runtime,
19574 ResolvedEnvironmentProfileReceipt {
19575 profile_id,
19576 profile_revision,
19577 network_allowlist: None,
19578 browser_profile_id: None,
19579 },
19580 )
19581 }
19582
19583 fn materialization_test_shared(
19584 deny_secret: bool,
19585 ) -> (
19586 NodeShared,
19587 NativeRuntime,
19588 ResolvedEnvironmentProfileReceipt,
19589 PathBuf,
19590 Arc<AtomicBool>,
19591 ) {
19592 let workspace = WorkspaceConfig::new(
19593 WorkspaceId::new("primary").unwrap(),
19594 std::env::current_dir().unwrap(),
19595 )
19596 .unwrap();
19597 materialization_test_shared_with_workspace(deny_secret, workspace)
19598 }
19599
19600 fn materialization_test_shared_with_workspace(
19601 deny_secret: bool,
19602 workspace: WorkspaceConfig,
19603 ) -> (
19604 NodeShared,
19605 NativeRuntime,
19606 ResolvedEnvironmentProfileReceipt,
19607 PathBuf,
19608 Arc<AtomicBool>,
19609 ) {
19610 let root = temporary_workspace_root(if deny_secret {
19611 "materialization-denied"
19612 } else {
19613 "materialization-ready"
19614 });
19615 std::fs::create_dir_all(&root).unwrap();
19616 let materialization_root = root.join("session-environments");
19617 let deny = Arc::new(AtomicBool::new(deny_secret));
19618 let resolver = Arc::new(FixtureSecretResolver {
19619 deny: Arc::clone(&deny),
19620 });
19621 let materializer = SessionEnvironmentMaterializer::new(
19622 materialization_root,
19623 resolver,
19624 )
19625 .unwrap();
19626 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19627 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19628 "materialized-claude-r1",
19629 )
19630 .unwrap();
19631 let materialization = NodeSessionMaterializationProfile::new(
19632 vec![NodeSessionEnvironmentMutation::SetSecret {
19633 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19634 reference: NodeSecretReference::new("fixture-token").unwrap(),
19635 }],
19636 vec![NodeSessionPathBinding::new(
19637 "GATE4AGENT_PROVIDER_HOME",
19638 NodeSessionPathClass::ProviderHome,
19639 )
19640 .unwrap()],
19641 vec![NodeSessionFile::secret(
19642 NodeSessionPathClass::Config,
19643 "auth/token",
19644 NodeSecretReference::new("fixture-token").unwrap(),
19645 )
19646 .unwrap()],
19647 )
19648 .unwrap();
19649 let catalog = active_registry().unwrap();
19650 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19651 let control = runtime.native_launch_profile_control();
19652 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19653 profile_id.clone(),
19654 profile_revision.clone(),
19655 agent("claude"),
19656 [NativeLaunchProfile::new(
19657 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19658 agent("claude"),
19659 TransportKind::Pty,
19660 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19661 Arc::new(EmptyEnvironmentResolver),
19662 )
19663 .unwrap()],
19664 Some(materialization),
19665 )
19666 .unwrap();
19667 let (binding, native_profiles, materialization) = node_profile.into_parts();
19668 for native_profile in native_profiles {
19669 runtime.upsert_native_launch_profile(native_profile).unwrap();
19670 }
19671 let shared = NodeShared::new_with_incarnation(
19672 handle,
19673 "fixture-token".to_owned(),
19674 NodeId::new("node-materialization-test").unwrap(),
19675 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19676 vec![workspace],
19677 vec![agent("claude")],
19678 ProviderRuntimeStatuses::default(),
19679 None,
19680 Vec::new(),
19681 Vec::new(),
19682 SpawnProfileRegistry::default(),
19683 Some(control),
19684 None,
19685 Some(materializer),
19686 None,
19687 Vec::new(),
19688 Vec::new(),
19689 Vec::new(),
19690 Vec::new(),
19691 None,
19692 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19693 );
19694 shared
19695 .environment_profiles
19696 .write()
19697 .unwrap_or_else(|poisoned| poisoned.into_inner())
19698 .insert(binding.id.clone(), binding);
19699 shared
19700 .environment_materialization_profiles
19701 .write()
19702 .unwrap_or_else(|poisoned| poisoned.into_inner())
19703 .insert(profile_id.clone(), materialization.unwrap());
19704 (
19705 shared,
19706 runtime,
19707 ResolvedEnvironmentProfileReceipt {
19708 profile_id,
19709 profile_revision,
19710 network_allowlist: None,
19711 browser_profile_id: None,
19712 },
19713 root,
19714 deny,
19715 )
19716 }
19717
19718 #[cfg(feature = "fixture")]
19719 #[derive(Clone, Copy)]
19720 enum CodexHomeProfile {
19721 Missing,
19722 WrongClass,
19723 Exact,
19724 }
19725
19726 #[cfg(feature = "fixture")]
19727 fn codex_bundle_test_shared(
19728 home_profile: CodexHomeProfile,
19729 ) -> (
19730 NodeShared,
19731 NativeRuntime,
19732 ResolvedEnvironmentProfileReceipt,
19733 NodeBundle,
19734 PathBuf,
19735 ) {
19736 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
19737 const CLAUDE_MANIFEST: &[u8] =
19738 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
19739 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
19740 const DIGEST: &str =
19741 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
19742
19743 let (shared, mut runtime, _claude_receipt, root, _deny) =
19744 materialization_test_shared(false);
19745 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
19746 CodexHomeProfile::Missing => "codex-home-missing",
19747 CodexHomeProfile::WrongClass => "codex-home-wrong",
19748 CodexHomeProfile::Exact => "codex-home-exact",
19749 })
19750 .unwrap();
19751 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19752 "codex-home-r1",
19753 )
19754 .unwrap();
19755 let materialization = match home_profile {
19756 CodexHomeProfile::Missing => None,
19757 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
19758 Some(NodeSessionMaterializationProfile::new(
19759 Vec::new(),
19760 vec![NodeSessionPathBinding::new(
19761 "CODEX_HOME",
19762 if matches!(home_profile, CodexHomeProfile::Exact) {
19763 NodeSessionPathClass::ProviderHome
19764 } else {
19765 NodeSessionPathClass::Config
19766 },
19767 )
19768 .unwrap()],
19769 Vec::new(),
19770 )
19771 .unwrap())
19772 }
19773 };
19774 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19775 profile_id.clone(),
19776 profile_revision.clone(),
19777 agent("codex"),
19778 [NativeLaunchProfile::new(
19779 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
19780 agent("codex"),
19781 TransportKind::Pty,
19782 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19783 Arc::new(EmptyEnvironmentResolver),
19784 )
19785 .unwrap()],
19786 materialization,
19787 )
19788 .unwrap();
19789 let (binding, native_profiles, materialization) = node_profile.into_parts();
19790 for native_profile in native_profiles {
19791 runtime.upsert_native_launch_profile(native_profile).unwrap();
19792 }
19793 shared
19794 .environment_profiles
19795 .write()
19796 .unwrap_or_else(|poisoned| poisoned.into_inner())
19797 .insert(binding.id.clone(), binding);
19798 if let Some(materialization) = materialization {
19799 shared
19800 .environment_materialization_profiles
19801 .write()
19802 .unwrap_or_else(|poisoned| poisoned.into_inner())
19803 .insert(profile_id.clone(), materialization);
19804 }
19805
19806 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
19807 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
19808 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
19809 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
19810 std::fs::write(
19811 bundle_source.join(".claude-plugin/plugin.json"),
19812 CLAUDE_MANIFEST,
19813 )
19814 .unwrap();
19815 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
19816 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
19817 let bundle = NodeBundle::new(
19818 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
19819 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
19820 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
19821 &bundle_source,
19822 )
19823 .unwrap();
19824 *shared
19825 .bundle_catalog
19826 .write()
19827 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
19828 BundleCatalog::new([bundle.clone()]).unwrap();
19829 (
19830 shared,
19831 runtime,
19832 ResolvedEnvironmentProfileReceipt {
19833 profile_id,
19834 profile_revision,
19835 network_allowlist: None,
19836 browser_profile_id: None,
19837 },
19838 bundle,
19839 root,
19840 )
19841 }
19842
19843 #[cfg(feature = "fixture")]
19844 #[test]
19845 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
19846 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
19847 let (shared, runtime, receipt, bundle, root) =
19848 codex_bundle_test_shared(home_profile);
19849 let error = shared
19850 .bundle_layout(
19851 &agent("codex"),
19852 SessionMode::Pty,
19853 Some(&receipt),
19854 &bundle,
19855 )
19856 .unwrap_err();
19857 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
19858 assert!(shared
19859 .materializations
19860 .lock()
19861 .unwrap_or_else(|poisoned| poisoned.into_inner())
19862 .is_empty());
19863 let materialization_root = root.join("session-environments");
19864 assert!(!std::fs::read_dir(&materialization_root)
19865 .unwrap()
19866 .filter_map(Result::ok)
19867 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
19868 drop(shared);
19869 drop(runtime);
19870 let _ = std::fs::remove_dir_all(root);
19871 }
19872 }
19873
19874 #[cfg(feature = "fixture")]
19875 #[test]
19876 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
19877 let (shared, runtime, receipt, bundle, root) =
19878 codex_bundle_test_shared(CodexHomeProfile::Exact);
19879 let address = SessionAddress {
19880 workspace_id: WorkspaceId::new("primary").unwrap(),
19881 session: SessionKey {
19882 instance_id: AgentInstanceId(310),
19883 generation: SessionGeneration(1),
19884 },
19885 };
19886 let (overlay, guard) = shared
19887 .prepare_session_materialization(
19888 &address,
19889 &agent("codex"),
19890 SessionMode::Pty,
19891 Some(&receipt),
19892 Some(&bundle.receipt()),
19893 None,
19894 None,
19895 )
19896 .unwrap()
19897 .unwrap();
19898 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
19899 let ownership = shared
19900 .materializations
19901 .lock()
19902 .unwrap_or_else(|poisoned| poisoned.into_inner())
19903 .get(guard.id().unwrap())
19904 .unwrap()
19905 .clone();
19906 let (profile, _, _) = shared
19907 .materialization_profile(
19908 &agent("codex"),
19909 SessionMode::Pty,
19910 Some(&receipt),
19911 Some(&bundle.receipt()),
19912 None,
19913 )
19914 .unwrap()
19915 .unwrap();
19916 let environment = shared
19917 .session_environment_materializer
19918 .as_ref()
19919 .unwrap()
19920 .resolve_environment(&ownership, &profile)
19921 .unwrap();
19922 assert!(environment.iter().any(|mutation| {
19923 mutation.key == OsString::from("CODEX_HOME")
19924 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
19925 }));
19926 assert_eq!(
19927 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
19928 .unwrap(),
19929 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
19930 );
19931 assert!(std::fs::read_dir(ownership.bundle_root())
19932 .unwrap()
19933 .next()
19934 .is_none());
19935 drop(guard);
19936 drop(shared);
19937 drop(runtime);
19938 let _ = std::fs::remove_dir_all(root);
19939 }
19940
19941 #[cfg(feature = "fixture")]
19942 #[test]
19943 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
19944 let (shared, runtime, receipt, bundle, root) =
19945 codex_bundle_test_shared(CodexHomeProfile::Exact);
19946 let bundle_receipt = bundle.receipt();
19947 let address = SessionAddress {
19948 workspace_id: WorkspaceId::new("primary").unwrap(),
19949 session: SessionKey {
19950 instance_id: AgentInstanceId(311),
19951 generation: SessionGeneration(1),
19952 },
19953 };
19954 let (_overlay, guard) = shared
19955 .prepare_session_materialization(
19956 &address,
19957 &agent("codex"),
19958 SessionMode::Pty,
19959 Some(&receipt),
19960 Some(&bundle_receipt),
19961 None,
19962 None,
19963 )
19964 .unwrap()
19965 .unwrap();
19966 let materialization_id = guard.id().unwrap().clone();
19967 let skill_path = shared
19968 .materializations
19969 .lock()
19970 .unwrap_or_else(|poisoned| poisoned.into_inner())
19971 .get(&materialization_id)
19972 .unwrap()
19973 .provider_home()
19974 .join("skills/review-code/SKILL.md");
19975 let record_id = shared
19976 .bind_spawn_session_with_materialization(
19977 &address,
19978 agent("codex"),
19979 SessionMode::Pty,
19980 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
19981 SpawnRecordPolicy::ProviderIdentityOnly,
19982 Some(receipt.clone()),
19983 Some(bundle_receipt.clone()),
19984 None,
19985 Some(materialization_id.clone()),
19986 )
19987 .unwrap()
19988 .unwrap();
19989 guard.retain();
19990 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
19991
19992 let error = match shared.resolve_record_materialization(
19993 &record_id,
19994 &agent("codex"),
19995 SessionMode::Pty,
19996 Some(&receipt),
19997 Some(&bundle_receipt),
19998 None,
19999 ) {
20000 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20001 Err(error) => error,
20002 };
20003 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20004 assert_eq!(
20005 shared
20006 .materializations
20007 .lock()
20008 .unwrap_or_else(|poisoned| poisoned.into_inner())
20009 .get(&materialization_id)
20010 .unwrap()
20011 .state(),
20012 MaterializationState::RecoveryRequired,
20013 );
20014 drop(shared);
20015 drop(runtime);
20016 let _ = std::fs::remove_dir_all(root);
20017 }
20018
20019 fn bind_record_owned_materialization_fixture(
20020 shared: &NodeShared,
20021 receipt: &ResolvedEnvironmentProfileReceipt,
20022 instance_id: u64,
20023 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20024 let address = SessionAddress {
20025 workspace_id: WorkspaceId::new("primary").unwrap(),
20026 session: SessionKey {
20027 instance_id: AgentInstanceId(instance_id),
20028 generation: SessionGeneration(1),
20029 },
20030 };
20031 let (overlay, materialization_guard) = shared
20032 .prepare_session_materialization(
20033 &address,
20034 &agent("claude"),
20035 SessionMode::Pty,
20036 Some(receipt),
20037 None,
20038 None,
20039 None,
20040 )
20041 .unwrap()
20042 .unwrap();
20043 let materialization_id = materialization_guard.id().unwrap().clone();
20044 let materialization_root = shared
20045 .materializations
20046 .lock()
20047 .unwrap_or_else(|poisoned| poisoned.into_inner())
20048 .get(&materialization_id)
20049 .unwrap()
20050 .root()
20051 .to_path_buf();
20052 let selection = shared
20053 .select_environment_profile(
20054 address.session.instance_id,
20055 &agent("claude"),
20056 SessionMode::Pty,
20057 Some(receipt),
20058 )
20059 .unwrap()
20060 .unwrap();
20061 assert!(shared
20062 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20063 .unwrap()
20064 .is_none());
20065 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20066 let record_id = shared
20067 .bind_spawn_session_with_materialization(
20068 &address,
20069 agent("claude"),
20070 SessionMode::Pty,
20071 policy,
20072 SpawnRecordPolicy::ProviderIdentityOnly,
20073 Some(receipt.clone()),
20074 None,
20075 None,
20076 Some(materialization_id.clone()),
20077 )
20078 .unwrap()
20079 .unwrap();
20080 selection.retain();
20081 materialization_guard.retain();
20082 (address, record_id, materialization_id, materialization_root)
20083 }
20084
20085 #[test]
20086 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20087 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20088 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20089 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20090 }));
20091 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20092 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20093 let mut spec = spawn_spec_fixture();
20094 spec.expected_profile_revision =
20095 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20096 assert!(matches!(
20097 &spec.overrides.environment_profile_id,
20098 crate::protocol::SpawnOverride::Inherit,
20099 ));
20100 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20101 .requires_child_environment_profile_capability());
20102 assert!(request_requires_child_environment_profile(
20103 &shared,
20104 &NodeRequest::SpawnSpec { spec: spec.clone() },
20105 ));
20106
20107 let address = terminal_address(1);
20108 shared.bind_session_with_policy(
20109 &address,
20110 ProviderRuntimePolicy::raw_pty(),
20111 Some(environment_profile.clone()),
20112 );
20113 assert!(request_requires_child_environment_profile(
20114 &shared,
20115 &NodeRequest::Input {
20116 session: address.clone(),
20117 text: "x".to_owned(),
20118 },
20119 ));
20120
20121 let mut record = record("claude", "profile-record");
20122 record.active_session = Some(address.clone());
20123 record.environment_profile = Some(environment_profile.clone());
20124 let record_id = record.record_id.clone();
20125 shared.insert_record(record.clone()).unwrap();
20126 assert!(request_requires_child_environment_profile(
20127 &shared,
20128 &NodeRequest::ResumeSessionRecord {
20129 record_id,
20130 terminal_size: gate4agent_types::TerminalSize {
20131 rows: 24,
20132 columns: 80,
20133 },
20134 initial_prompt: None,
20135 },
20136 ));
20137
20138 let snapshot = snapshot_for_wire(
20139 &shared, false, true, true, true, false, false, false, false, true,
20140 );
20141 assert_eq!(snapshot.session_records.len(), 1);
20142 assert!(snapshot.session_records[0].environment_profile.is_none());
20143 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20144 sequence: 1,
20145 event: NodeEvent::SessionRecordUpserted { record },
20146 });
20147 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20148 panic!("record projection changed the event kind");
20149 };
20150 assert!(record.environment_profile.is_none());
20151
20152 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20153 let mut reply = ResponseEnvelope {
20154 request_id: 1,
20155 result: Ok(NodeResponse::SpawnSpecAccepted {
20156 receipt: resolved.receipt_with_environment(
20157 shared.incarnation_id,
20158 address,
20159 Some(environment_profile),
20160 ),
20161 }),
20162 };
20163 project_response_without_child_environment_profile(&mut reply);
20164 assert_eq!(
20165 reply.result.unwrap_err().code,
20166 NodeFailureCode::UnsupportedCapability,
20167 );
20168 }
20169
20170 #[test]
20171 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20172 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20173 let snapshot = shared.snapshot();
20174 let profiles = snapshot
20175 .launch_inventory
20176 .as_ref()
20177 .and_then(|inventory| inventory.spawn_profiles.as_ref())
20178 .unwrap();
20179 assert_eq!(profiles.len(), 1);
20180 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20181 assert!(snapshot.requires_child_environment_profile_capability());
20182
20183 let mut legacy = snapshot.clone();
20184 clear_snapshot_child_environment_profiles(&mut legacy);
20185 assert!(legacy
20186 .launch_inventory
20187 .as_ref()
20188 .unwrap()
20189 .spawn_profiles
20190 .as_ref()
20191 .unwrap()[0]
20192 .environment_profile
20193 .is_none());
20194 assert!(!legacy.requires_child_environment_profile_capability());
20195
20196 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20197 let bindings = shared
20198 .environment_profiles
20199 .read()
20200 .unwrap_or_else(|poisoned| poisoned.into_inner())
20201 .clone();
20202 let mut provider_mismatch = bindings.clone();
20203 provider_mismatch
20204 .values_mut()
20205 .next()
20206 .unwrap()
20207 .provider = agent("codex");
20208 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20209 let mut mode_mismatch = profile;
20210 mode_mismatch.mode = SessionMode::Inline;
20211 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20212
20213 shared
20214 .environment_profiles
20215 .write()
20216 .unwrap_or_else(|poisoned| poisoned.into_inner())
20217 .clear();
20218 assert!(shared
20219 .snapshot()
20220 .launch_inventory
20221 .unwrap()
20222 .spawn_profiles
20223 .unwrap()
20224 .is_empty());
20225 }
20226
20227 #[test]
20228 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20229 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20230 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20231 }));
20232 let shared = terminal_test_shared();
20233 let bundle = ResolvedBundleReceipt {
20234 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20235 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20236 digest: crate::protocol::SpawnBundleDigest::new(format!(
20237 "sha256:{}",
20238 "0".repeat(64),
20239 ))
20240 .unwrap(),
20241 };
20242 let mut bundled = record("claude", "bundle-record");
20243 bundled.bundle = Some(bundle.clone());
20244 let record_id = bundled.record_id.clone();
20245 shared.insert_record(bundled.clone()).unwrap();
20246 assert!(request_requires_session_bundle(
20247 &shared,
20248 &NodeRequest::ForgetSessionRecord {
20249 record_id: record_id.clone(),
20250 },
20251 ));
20252
20253 let snapshot = snapshot_for_wire(
20254 &shared, false, true, true, true, true, false, false, false, true,
20255 );
20256 assert!(snapshot.session_records[0].bundle.is_none());
20257 let launch = snapshot.launch_inventory.as_ref().unwrap();
20258 assert!(launch.spawn_profiles.is_some());
20259 assert!(launch.bundles.is_none());
20260 let event = project_event_without_session_bundle(NodeEventEnvelope {
20261 sequence: 1,
20262 event: NodeEvent::SessionRecordUpserted { record: bundled },
20263 });
20264 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20265 panic!("record projection changed the event kind");
20266 };
20267 assert!(record.bundle.is_none());
20268
20269 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20270 let mut reply = ResponseEnvelope {
20271 request_id: 1,
20272 result: Ok(NodeResponse::SpawnSpecAccepted {
20273 receipt: resolved.receipt_with_materialization(
20274 shared.incarnation_id,
20275 terminal_address(17),
20276 None,
20277 Some(bundle),
20278 None,
20279 ),
20280 }),
20281 };
20282 project_response_without_session_bundle(&mut reply);
20283 assert_eq!(
20284 reply.result.unwrap_err().code,
20285 NodeFailureCode::UnsupportedCapability,
20286 );
20287 }
20288
20289 #[test]
20290 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20291 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20292 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20293 }));
20294 let context_id = SpawnContextId::new("context-review").unwrap();
20295 let context = ResolvedContextPackReceipt {
20296 id: context_id.clone(),
20297 digest: crate::protocol::SpawnContextDigest::new(format!(
20298 "sha256:{}",
20299 "c".repeat(64),
20300 ))
20301 .unwrap(),
20302 lineage: ContextPackLineageReceipt {
20303 source_node_id: NodeId::new("node-source").unwrap(),
20304 source_session: terminal_address(1),
20305 source_provider: agent("kimi"),
20306 },
20307 source_message_count: 2,
20308 retained_message_count: 2,
20309 byte_len: 256,
20310 truncated: false,
20311 };
20312 assert!(context.is_valid());
20313 let mut bound_record = record("codex", "context-record");
20314 bound_record.context_id = Some(context_id);
20315 bound_record.context = Some(context.clone());
20316 let session = gate4agent_types::SessionSnapshot {
20317 instance_id: AgentInstanceId(91),
20318 agent_id: agent("kimi"),
20319 transport: TransportKind::Pty,
20320 generation: SessionGeneration(3),
20321 status: SessionStatus::Running,
20322 pending_operation: None,
20323 pending_input: None,
20324 process_id: Some(9001),
20325 terminal_size: None,
20326 terminal_frame: None,
20327 terminal_stale: None,
20328 session_options: None,
20329 capabilities: gate4agent_types::CapabilitySnapshot::default(),
20330 history: gate4agent_types::HistorySnapshot {
20331 pending: None,
20332 candidates: vec![HistoryCandidateSummary {
20333 id: "candidate-opaque".to_owned(),
20334 session_id_hint: "session-hint".to_owned(),
20335 modified_at_unix_ms: Some(1),
20336 }],
20337 loaded_candidate_id: Some("candidate-opaque".to_owned()),
20338 loaded: Some(HistorySessionRecord {
20339 session_id: "private-vendor-session".to_owned(),
20340 title: Some("private title".to_owned()),
20341 cwd: Some(r"C:\private\history-root".to_owned()),
20342 model: Some("private-model".to_owned()),
20343 message_count: 2,
20344 completed_turn_count: None,
20345 total_tokens: 19,
20346 messages: vec![
20347 gate4agent_types::HistoryMessageRecord {
20348 role: gate4agent_types::HistoryMessageRole::User,
20349 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20350 },
20351 gate4agent_types::HistoryMessageRecord {
20352 role: gate4agent_types::HistoryMessageRole::Assistant,
20353 text: "private answer".to_owned(),
20354 },
20355 ],
20356 }),
20357 last_error: None,
20358 },
20359 resume: gate4agent_types::ResumeSnapshot::default(),
20360 foreground: gate4agent_types::ForegroundSnapshot::default(),
20361 provider: gate4agent_types::ProviderSnapshot::default(),
20362 screen_state: gate4agent_types::PtyScreenState::default(),
20363 };
20364 let original = NodeSnapshot {
20365 node_id: NodeId::new("node-terminal-test").unwrap(),
20366 enabled_providers: vec![agent("kimi"), agent("codex")],
20367 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20368 workspaces: vec![WorkspaceSnapshot {
20369 workspace_id: WorkspaceId::new("primary").unwrap(),
20370 canonical_root: opaque_windows_path(
20371 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20372 ),
20373 sessions: vec![session],
20374 worktree_service_mode: None,
20375 managed_worktree_profiles: None,
20376 }],
20377 session_records: vec![bound_record],
20378 managed_worktrees: Vec::new(),
20379 launch_inventory: None,
20380 agent_progress: Vec::new(),
20381 };
20382
20383 let mut negotiated = original.clone();
20384 project_snapshot_history_for_wire(&mut negotiated, true);
20385 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
20386 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
20387 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
20388 assert!(!serde_json::to_string(&negotiated)
20389 .unwrap()
20390 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
20391
20392 let mut legacy = original;
20393 project_snapshot_history_for_wire(&mut legacy, false);
20394 clear_snapshot_context_packs(&mut legacy);
20395 assert_eq!(
20396 legacy.workspaces[0].sessions[0].history,
20397 gate4agent_types::HistorySnapshot::default(),
20398 );
20399 assert!(legacy.session_records[0].context_id.is_none());
20400 assert!(legacy.session_records[0].context.is_none());
20401 let encoded = serde_json::to_string(&legacy).unwrap();
20402 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
20403 assert!(!encoded.contains("context-review"));
20404
20405 let mut reply = ResponseEnvelope {
20406 request_id: 7,
20407 result: Ok(NodeResponse::ContextPackExported { context }),
20408 };
20409 project_response_without_context_pack(&mut reply);
20410 assert_eq!(
20411 reply.result.unwrap_err().code,
20412 NodeFailureCode::UnsupportedCapability,
20413 );
20414 }
20415
20416 #[test]
20417 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
20418 let shared = terminal_test_shared();
20419 let history = HistorySessionRecord {
20420 session_id: "fixture-history".to_owned(),
20421 title: None,
20422 cwd: None,
20423 model: None,
20424 message_count: 1,
20425 completed_turn_count: None,
20426 total_tokens: 0,
20427 messages: vec![gate4agent_types::HistoryMessageRecord {
20428 role: gate4agent_types::HistoryMessageRole::User,
20429 text: "bounded handoff".to_owned(),
20430 }],
20431 };
20432 let pack = NodeContextPack::export(
20433 ContextPackLineageReceipt {
20434 source_node_id: shared.node_id.clone(),
20435 source_session: terminal_address(1),
20436 source_provider: agent("claude"),
20437 },
20438 &history,
20439 )
20440 .unwrap();
20441 let context = shared
20442 .context_catalog
20443 .write()
20444 .unwrap_or_else(|poisoned| poisoned.into_inner())
20445 .insert(pack)
20446 .unwrap();
20447 let mut retained = record("codex", "context-retained-record");
20448 retained.context_id = Some(context.id.clone());
20449 retained.context = Some(context.clone());
20450 let record_id = retained.record_id.clone();
20451 shared.insert_record(retained).unwrap();
20452 assert_eq!(
20453 shared.forget_context_pack(&context.id).unwrap_err().code,
20454 NodeFailureCode::ContextPackBusy,
20455 );
20456 assert!(shared
20457 .context_catalog
20458 .read()
20459 .unwrap_or_else(|poisoned| poisoned.into_inner())
20460 .get(&context.id)
20461 .is_some());
20462 shared
20463 .session_records
20464 .lock()
20465 .unwrap_or_else(|poisoned| poisoned.into_inner())
20466 .records
20467 .remove(&record_id);
20468
20469 let mut spec = spawn_spec_fixture();
20470 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
20471 value: context.id.clone(),
20472 };
20473 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20474 let receipt = resolved.receipt_with_materialization(
20475 shared.incarnation_id,
20476 terminal_address(2),
20477 None,
20478 None,
20479 Some(context.clone()),
20480 );
20481 assert!(receipt.context_binding_is_valid());
20482 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
20483
20484 shared.forget_context_pack(&context.id).unwrap();
20485 assert!(shared
20486 .context_catalog
20487 .read()
20488 .unwrap_or_else(|poisoned| poisoned.into_inner())
20489 .get(&context.id)
20490 .is_none());
20491 assert!(shared
20492 .replay_spawn_spec(&spec, Instant::now())
20493 .unwrap()
20494 .is_none());
20495 }
20496
20497 #[test]
20498 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
20499 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20500 let address = terminal_address(1);
20501 let selection = shared
20502 .select_environment_profile(
20503 address.session.instance_id,
20504 &agent("claude"),
20505 SessionMode::Pty,
20506 Some(&environment_profile),
20507 )
20508 .unwrap()
20509 .unwrap();
20510 shared.bind_session_with_policy(
20511 &address,
20512 ProviderRuntimePolicy::raw_pty(),
20513 Some(environment_profile.clone()),
20514 );
20515 selection.retain();
20516 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20517 assert!(matches!(
20518 runtime.remove_native_launch_profile(&native_id),
20519 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20520 ));
20521 assert!(shared.remove_binding(&address).is_some());
20522 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20523
20524 let mismatched = ResolvedEnvironmentProfileReceipt {
20525 profile_id: environment_profile.profile_id,
20526 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
20527 "local-claude-r2",
20528 )
20529 .unwrap(),
20530 network_allowlist: None,
20531 browser_profile_id: None,
20532 };
20533 let error = match shared.select_environment_profile(
20534 AgentInstanceId(42),
20535 &agent("claude"),
20536 SessionMode::Pty,
20537 Some(&mismatched),
20538 ) {
20539 Ok(_) => panic!("mismatched environment profile revision was selected"),
20540 Err(error) => error,
20541 };
20542 assert_eq!(
20543 error.code,
20544 NodeFailureCode::EnvironmentProfileBindingMismatch,
20545 );
20546 assert!(!shared
20547 .session_bindings
20548 .lock()
20549 .unwrap_or_else(|poisoned| poisoned.into_inner())
20550 .contains_key(&AgentInstanceId(42)));
20551 }
20552
20553 #[test]
20554 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
20555 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20556 let address = terminal_address(1);
20557 let selection = shared
20558 .select_environment_profile(
20559 address.session.instance_id,
20560 &agent("claude"),
20561 SessionMode::Pty,
20562 Some(&environment_profile),
20563 )
20564 .unwrap()
20565 .unwrap();
20566 let overlay = NativeInstanceLaunchOverlay::new(
20567 agent("claude"),
20568 TransportKind::Pty,
20569 vec![EnvMutation {
20570 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
20571 value: Some(OsString::from("fixture-secret")),
20572 }],
20573 vec![
20574 OsString::from("--plugin-dir"),
20575 std::env::current_dir().unwrap().into_os_string(),
20576 ],
20577 )
20578 .unwrap();
20579 let instance_overlay = shared
20580 .install_prepared_launch_overlay(
20581 address.session.instance_id,
20582 PreparedNativeLaunchOverlay::Instance(overlay),
20583 )
20584 .unwrap()
20585 .unwrap();
20586 let bundle = ResolvedBundleReceipt {
20587 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
20588 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
20589 digest: crate::protocol::SpawnBundleDigest::new(format!(
20590 "sha256:{}",
20591 "0".repeat(64),
20592 ))
20593 .unwrap(),
20594 };
20595 shared.bind_session_with_materialization(
20596 &address,
20597 ProviderRuntimePolicy::raw_pty(),
20598 Some(environment_profile),
20599 Some(bundle),
20600 None,
20601 None,
20602 );
20603 selection.retain();
20604 instance_overlay.retain();
20605
20606 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20607 assert!(matches!(
20608 runtime.remove_native_launch_profile(&native_id),
20609 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20610 ));
20611 assert!(shared.remove_binding(&address).is_some());
20612 assert!(!shared
20613 .native_launch_profile_control
20614 .as_ref()
20615 .unwrap()
20616 .clear_native_instance_launch_overlay(address.session.instance_id));
20617 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20618 }
20619
20620 #[test]
20621 fn session_environment_materialization_failure_is_pre_child_and_durable() {
20622 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20623 let address = SessionAddress {
20624 workspace_id: WorkspaceId::new("primary").unwrap(),
20625 session: SessionKey {
20626 instance_id: AgentInstanceId(201),
20627 generation: SessionGeneration(1),
20628 },
20629 };
20630 let error = match shared.prepare_session_materialization(
20631 &address,
20632 &agent("claude"),
20633 SessionMode::Pty,
20634 Some(&receipt),
20635 None,
20636 None,
20637 None,
20638 ) {
20639 Ok(_) => panic!("denied local reference unexpectedly materialized"),
20640 Err(error) => error,
20641 };
20642 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20643 assert!(shared.handle.snapshot().sessions.is_empty());
20644 assert!(shared
20645 .session_bindings
20646 .lock()
20647 .unwrap_or_else(|poisoned| poisoned.into_inner())
20648 .is_empty());
20649 assert!(shared
20650 .materializations
20651 .lock()
20652 .unwrap_or_else(|poisoned| poisoned.into_inner())
20653 .is_empty());
20654 drop(shared);
20655 drop(runtime);
20656 std::fs::remove_dir_all(root).unwrap();
20657 }
20658
20659 #[test]
20660 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
20661 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20662 let address = SessionAddress {
20663 workspace_id: WorkspaceId::new("primary").unwrap(),
20664 session: SessionKey {
20665 instance_id: AgentInstanceId(202),
20666 generation: SessionGeneration(1),
20667 },
20668 };
20669 let (overlay, materialization_guard) = shared
20670 .prepare_session_materialization(
20671 &address,
20672 &agent("claude"),
20673 SessionMode::Pty,
20674 Some(&receipt),
20675 None,
20676 None,
20677 None,
20678 )
20679 .unwrap()
20680 .unwrap();
20681 let materialization_id = materialization_guard.id().unwrap().clone();
20682 let materialization_root = shared
20683 .materializations
20684 .lock()
20685 .unwrap_or_else(|poisoned| poisoned.into_inner())
20686 .get(&materialization_id)
20687 .unwrap()
20688 .root()
20689 .to_path_buf();
20690 let selection = shared
20691 .select_environment_profile(
20692 address.session.instance_id,
20693 &agent("claude"),
20694 SessionMode::Pty,
20695 Some(&receipt),
20696 )
20697 .unwrap()
20698 .unwrap();
20699 assert!(shared
20700 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20701 .unwrap()
20702 .is_none());
20703 shared.bind_session_with_materialization(
20704 &address,
20705 ProviderRuntimePolicy::raw_pty(),
20706 Some(receipt),
20707 None,
20708 None,
20709 Some(materialization_id),
20710 );
20711 selection.retain();
20712 materialization_guard.retain();
20713
20714 assert!(materialization_root.is_dir());
20715 assert!(shared
20716 .session_bindings
20717 .lock()
20718 .unwrap_or_else(|poisoned| poisoned.into_inner())
20719 .contains_key(&address.session.instance_id));
20720
20721 let removed = shared.remove_binding(&address).unwrap();
20722 shared
20723 .cleanup_session_owned_materialization(&address, &removed)
20724 .unwrap();
20725 assert!(!materialization_root.exists());
20726 assert!(shared
20727 .materializations
20728 .lock()
20729 .unwrap_or_else(|poisoned| poisoned.into_inner())
20730 .is_empty());
20731 drop(shared);
20732 drop(runtime);
20733 std::fs::remove_dir_all(root).unwrap();
20734 }
20735
20736 #[tokio::test]
20737 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
20738 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20739 let address = SessionAddress {
20740 workspace_id: WorkspaceId::new("primary").unwrap(),
20741 session: SessionKey {
20742 instance_id: AgentInstanceId(203),
20743 generation: SessionGeneration(1),
20744 },
20745 };
20746 let (overlay, materialization_guard) = shared
20747 .prepare_session_materialization(
20748 &address,
20749 &agent("claude"),
20750 SessionMode::Pty,
20751 Some(&receipt),
20752 None,
20753 None,
20754 None,
20755 )
20756 .unwrap()
20757 .unwrap();
20758 let materialization_id = materialization_guard.id().unwrap().clone();
20759 let materialization_root = shared
20760 .materializations
20761 .lock()
20762 .unwrap_or_else(|poisoned| poisoned.into_inner())
20763 .get(&materialization_id)
20764 .unwrap()
20765 .root()
20766 .to_path_buf();
20767 let selection = shared
20768 .select_environment_profile(
20769 address.session.instance_id,
20770 &agent("claude"),
20771 SessionMode::Pty,
20772 Some(&receipt),
20773 )
20774 .unwrap()
20775 .unwrap();
20776 assert!(shared
20777 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20778 .unwrap()
20779 .is_none());
20780 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20781 let record_id = shared
20782 .bind_spawn_session_with_materialization(
20783 &address,
20784 agent("claude"),
20785 SessionMode::Pty,
20786 policy,
20787 SpawnRecordPolicy::ProviderIdentityOnly,
20788 Some(receipt.clone()),
20789 None,
20790 None,
20791 Some(materialization_id.clone()),
20792 )
20793 .unwrap()
20794 .unwrap();
20795 selection.retain();
20796 materialization_guard.retain();
20797 assert_eq!(
20798 shared
20799 .materializations
20800 .lock()
20801 .unwrap_or_else(|poisoned| poisoned.into_inner())
20802 .get(&materialization_id)
20803 .unwrap()
20804 .owner(),
20805 &MaterializationOwner::Record {
20806 record_id: record_id.clone(),
20807 },
20808 );
20809
20810 let removed = shared.remove_binding(&address).unwrap();
20811 shared
20812 .cleanup_session_owned_materialization(&address, &removed)
20813 .unwrap();
20814 assert!(materialization_root.is_dir());
20815 assert!(shared
20816 .resolve_record_materialization(
20817 &record_id,
20818 &agent("claude"),
20819 SessionMode::Pty,
20820 Some(&receipt),
20821 None,
20822 None,
20823 )
20824 .unwrap()
20825 .is_some());
20826 {
20827 let mut records = shared
20828 .session_records
20829 .lock()
20830 .unwrap_or_else(|poisoned| poisoned.into_inner());
20831 let record = records.records.get_mut(&record_id).unwrap();
20832 record.active_session = None;
20833 record.state = ManagedSessionState::Unavailable;
20834 }
20835 shared.forget_session_record(&record_id).await.unwrap();
20836 assert!(!materialization_root.exists());
20837 assert!(shared
20838 .materializations
20839 .lock()
20840 .unwrap_or_else(|poisoned| poisoned.into_inner())
20841 .is_empty());
20842 drop(shared);
20843 drop(runtime);
20844 std::fs::remove_dir_all(root).unwrap();
20845 }
20846
20847 #[test]
20848 fn record_materialization_revalidation_failure_persists_recovery_required() {
20849 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20850 let state_path = root.join("node-state-v6.json");
20851 shared.state_path = Some(state_path.clone());
20852 let (_address, _record_id, materialization_id, materialization_root) =
20853 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
20854 std::fs::write(
20855 materialization_root.join(".gate4agent-materialization-owner"),
20856 b"tampered",
20857 )
20858 .unwrap();
20859
20860 let error = match shared.resolve_record_materialization(
20861 &_record_id,
20862 &agent("claude"),
20863 SessionMode::Pty,
20864 Some(&receipt),
20865 None,
20866 None,
20867 ) {
20868 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
20869 Err(error) => error,
20870 };
20871 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20872 assert_eq!(
20873 shared
20874 .materializations
20875 .lock()
20876 .unwrap_or_else(|poisoned| poisoned.into_inner())
20877 .get(&materialization_id)
20878 .unwrap()
20879 .state(),
20880 MaterializationState::RecoveryRequired,
20881 );
20882 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
20883 assert_eq!(loaded.materializations.len(), 1);
20884 assert_eq!(
20885 loaded.materializations[0].state(),
20886 MaterializationState::RecoveryRequired,
20887 );
20888 drop(shared);
20889 drop(runtime);
20890 std::fs::remove_dir_all(root).unwrap();
20891 }
20892
20893 #[test]
20894 fn transient_record_materialization_resolution_failure_remains_ready() {
20895 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
20896 let (_address, record_id, materialization_id, _materialization_root) =
20897 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
20898 deny.store(true, Ordering::Release);
20899
20900 let error = match shared.resolve_record_materialization(
20901 &record_id,
20902 &agent("claude"),
20903 SessionMode::Pty,
20904 Some(&receipt),
20905 None,
20906 None,
20907 ) {
20908 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
20909 Err(error) => error,
20910 };
20911 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20912 assert_eq!(
20913 shared
20914 .materializations
20915 .lock()
20916 .unwrap_or_else(|poisoned| poisoned.into_inner())
20917 .get(&materialization_id)
20918 .unwrap()
20919 .state(),
20920 MaterializationState::Ready,
20921 );
20922 drop(shared);
20923 drop(runtime);
20924 std::fs::remove_dir_all(root).unwrap();
20925 }
20926
20927 #[test]
20928 fn startup_missing_record_materialization_is_not_resumable() {
20929 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20930 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
20931 shared
20932 .insert_record(ManagedSessionRecord {
20933 record_id: record_id.clone(),
20934 display_name: "missing materialization".to_owned(),
20935 provider: agent("claude"),
20936 mode: SessionMode::Pty,
20937 state: ManagedSessionState::Dormant,
20938 workspace_id: WorkspaceId::new("primary").unwrap(),
20939 canonical_root: opaque_windows_path(
20940 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20941 ),
20942 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
20943 key: gate4agent_types::ProviderSessionKey::SessionId,
20944 id: "provider-session-missing-materialization".to_owned(),
20945 transcript_path: None,
20946 }),
20947 active_session: None,
20948 environment_profile: Some(receipt),
20949 bundle: None,
20950 context_id: None,
20951 context: None,
20952 exported_context: None,
20953 task_binding: None,
20954 created_at_unix_ms: 1,
20955 updated_at_unix_ms: 1,
20956 last_error: None,
20957 })
20958 .unwrap();
20959
20960 shared.reconcile_materializations();
20961 let record = shared.record(&record_id).unwrap();
20962 assert_eq!(record.state, ManagedSessionState::Unavailable);
20963 assert!(record.provider_session.is_none());
20964 assert_eq!(
20965 record.last_error.as_deref(),
20966 Some("environment-profile-unavailable"),
20967 );
20968 drop(shared);
20969 drop(runtime);
20970 std::fs::remove_dir_all(root).unwrap();
20971 }
20972
20973 #[tokio::test]
20974 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
20975 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20976 let lease = install_managed_lease(&shared);
20977 let address = SessionAddress {
20978 workspace_id: WorkspaceId::new("primary").unwrap(),
20979 session: SessionKey {
20980 instance_id: AgentInstanceId(204),
20981 generation: SessionGeneration(1),
20982 },
20983 };
20984 let (overlay, materialization_guard) = shared
20985 .prepare_session_materialization(
20986 &address,
20987 &agent("claude"),
20988 SessionMode::Pty,
20989 Some(&receipt),
20990 None,
20991 None,
20992 Some(lease.lease_id.clone()),
20993 )
20994 .unwrap()
20995 .unwrap();
20996 let materialization_id = materialization_guard.id().unwrap().clone();
20997 let materialization_root = shared
20998 .materializations
20999 .lock()
21000 .unwrap_or_else(|poisoned| poisoned.into_inner())
21001 .get(&materialization_id)
21002 .unwrap()
21003 .root()
21004 .to_path_buf();
21005 let selection = shared
21006 .select_environment_profile(
21007 address.session.instance_id,
21008 &agent("claude"),
21009 SessionMode::Pty,
21010 Some(&receipt),
21011 )
21012 .unwrap()
21013 .unwrap();
21014 assert!(shared
21015 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21016 .unwrap()
21017 .is_none());
21018 shared.bind_session_with_materialization(
21019 &address,
21020 ProviderRuntimePolicy::raw_pty(),
21021 Some(receipt),
21022 None,
21023 None,
21024 Some(materialization_id),
21025 );
21026 selection.retain();
21027 materialization_guard.retain();
21028
21029 let blocked = shared
21030 .cleanup_managed_worktree(&lease.lease_id, false)
21031 .await
21032 .unwrap_err();
21033 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21034 assert!(materialization_root.is_dir());
21035 let removed = shared.remove_binding(&address).unwrap();
21036 shared
21037 .cleanup_session_owned_materialization(&address, &removed)
21038 .unwrap();
21039 assert!(!materialization_root.exists());
21040 drop(shared);
21041 drop(runtime);
21042 std::fs::remove_dir_all(root).unwrap();
21043 }
21044
21045 #[tokio::test]
21046 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21047 let git_root = temporary_workspace_root("raw-managed-inventory");
21048 let source = git_root.join("source");
21049 let allocation = git_root.join("allocation");
21050 std::fs::create_dir_all(&source).unwrap();
21051 std::fs::create_dir_all(&allocation).unwrap();
21052 run_test_git(&source, &["init"]);
21053 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21054 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21055 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21056 run_test_git(&source, &["add", "seed.txt"]);
21057 run_test_git(&source, &["commit", "-m", "seed"]);
21058 let profile = ManagedWorktreeProfile::new(
21059 WorktreeProfileId::new("default").unwrap(),
21060 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21061 &allocation,
21062 "gate4agent",
21063 "HEAD",
21064 ManagedWorktreeRetention::RemoveWhenReleased,
21065 )
21066 .unwrap();
21067 let workspace = WorkspaceConfig::new(
21068 WorkspaceId::new("primary").unwrap(),
21069 &source,
21070 )
21071 .unwrap()
21072 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21073 .with_managed_worktree_profile(profile.clone())
21074 .unwrap();
21075 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21076 materialization_test_shared_with_workspace(false, workspace);
21077 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21078 let source_text = source.to_string_lossy().into_owned();
21079 let target_text = target.to_string_lossy().into_owned();
21080 let created = create_git_worktree(
21081 &source_text,
21082 &target_text,
21083 "gate4agent/mw-raw-inventory",
21084 Some("HEAD"),
21085 )
21086 .await
21087 .unwrap();
21088 let lease = ManagedWorktreeLeaseRecord {
21089 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21090 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21091 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21092 profile_id: profile.profile_id().clone(),
21093 profile_revision: profile.revision().clone(),
21094 target_root: created.path.clone(),
21095 branch: "gate4agent/mw-raw-inventory".to_owned(),
21096 base_commit: created.head.clone(),
21097 expected_head: Some(created.head.clone()),
21098 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21099 state: ManagedWorktreeLeaseState::Ready,
21100 session_holders: Vec::new(),
21101 record_holders: Vec::new(),
21102 cleanup_failure: None,
21103 created_at_unix_ms: 1,
21104 updated_at_unix_ms: 1,
21105 };
21106 shared
21107 .workspaces
21108 .write()
21109 .unwrap_or_else(|poisoned| poisoned.into_inner())
21110 .insert(lease.workspace_id.clone(), created.path.clone());
21111 *shared
21112 .managed_worktrees
21113 .lock()
21114 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21115 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21116 let address = SessionAddress {
21117 workspace_id: lease.workspace_id.clone(),
21118 session: SessionKey {
21119 instance_id: AgentInstanceId(205),
21120 generation: SessionGeneration(1),
21121 },
21122 };
21123 let (overlay, materialization_guard) = shared
21124 .prepare_session_materialization(
21125 &address,
21126 &agent("claude"),
21127 SessionMode::Pty,
21128 Some(&receipt),
21129 None,
21130 None,
21131 Some(lease.lease_id.clone()),
21132 )
21133 .unwrap()
21134 .unwrap();
21135 let materialization_id = materialization_guard.id().unwrap().clone();
21136 let materialization_root = shared
21137 .materializations
21138 .lock()
21139 .unwrap_or_else(|poisoned| poisoned.into_inner())
21140 .get(&materialization_id)
21141 .unwrap()
21142 .root()
21143 .to_path_buf();
21144 let selection = shared
21145 .select_environment_profile(
21146 address.session.instance_id,
21147 &agent("claude"),
21148 SessionMode::Pty,
21149 Some(&receipt),
21150 )
21151 .unwrap()
21152 .unwrap();
21153 assert!(shared
21154 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21155 .unwrap()
21156 .is_none());
21157 let record_id = shared
21158 .bind_spawn_session_with_materialization(
21159 &address,
21160 agent("claude"),
21161 SessionMode::Pty,
21162 ProviderRuntimePolicy::raw_pty(),
21163 SpawnRecordPolicy::Always,
21164 Some(receipt.clone()),
21165 None,
21166 None,
21167 Some(materialization_id.clone()),
21168 )
21169 .unwrap()
21170 .unwrap();
21171 selection.retain();
21172 materialization_guard.retain();
21173 let record = shared.record(&record_id).unwrap();
21174 assert_eq!(record.state, ManagedSessionState::Live);
21175 assert!(record.provider_session.is_none());
21176 assert!(record.environment_profile.is_none());
21177 assert!(record.bundle.is_none());
21178 assert!(record.context_id.is_none());
21179 assert!(record.context.is_none());
21180 assert_eq!(record.active_session.as_ref(), Some(&address));
21181 assert_eq!(
21182 shared
21183 .materializations
21184 .lock()
21185 .unwrap_or_else(|poisoned| poisoned.into_inner())
21186 .get(&materialization_id)
21187 .unwrap()
21188 .owner(),
21189 &MaterializationOwner::Session {
21190 incarnation_id: shared.incarnation_id,
21191 instance_id: address.session.instance_id,
21192 generation: address.session.generation,
21193 },
21194 );
21195 {
21196 let mut bindings = shared
21197 .session_bindings
21198 .lock()
21199 .unwrap_or_else(|poisoned| poisoned.into_inner());
21200 bindings
21201 .get_mut(&address.session.instance_id)
21202 .unwrap()
21203 .managed_worktree_lease_id = Some(lease.lease_id.clone());
21204 }
21205 let lease_snapshot = shared
21206 .managed_worktrees
21207 .lock()
21208 .unwrap_or_else(|poisoned| poisoned.into_inner())
21209 .bind_session(
21210 &lease.lease_id,
21211 ManagedWorktreeSessionHolder {
21212 incarnation_id: shared.incarnation_id,
21213 instance_id: address.session.instance_id,
21214 generation: address.session.generation,
21215 },
21216 None,
21217 unix_time_ms(),
21218 )
21219 .unwrap();
21220 assert_eq!(lease_snapshot.active_session_count, 1);
21221 assert_eq!(lease_snapshot.managed_record_count, 0);
21222 shared.persist_state().unwrap();
21223 assert!(shared
21224 .managed_worktrees
21225 .lock()
21226 .unwrap_or_else(|poisoned| poisoned.into_inner())
21227 .get(&lease.lease_id)
21228 .unwrap()
21229 .record_holders
21230 .is_empty());
21231 shared
21232 .handle
21233 .dispatch(shared.prepare_command(ControlCommand::Register {
21234 instance_id: address.session.instance_id,
21235 agent_id: agent("claude"),
21236 transport: TransportKind::Pty,
21237 }))
21238 .unwrap();
21239 runtime.tick().await;
21240 shared.reconcile_managed_record(
21241 &record_id,
21242 &address,
21243 &ControlEvent {
21244 sequence: 1,
21245 command_id: None,
21246 instance_id: address.session.instance_id,
21247 generation: address.session.generation,
21248 event: ControlEventKind::Removed,
21249 },
21250 false,
21251 false,
21252 );
21253 let remove = shared.remove_session(&address);
21254 let drive_runtime = async {
21255 runtime.tick().await;
21256 };
21257 let (removed, ()) = tokio::join!(remove, drive_runtime);
21258 removed.unwrap();
21259
21260 assert!(!materialization_root.exists());
21261 assert!(!Path::new(&created.path).exists());
21262 assert_eq!(
21263 shared
21264 .managed_worktrees
21265 .lock()
21266 .unwrap_or_else(|poisoned| poisoned.into_inner())
21267 .get(&lease.lease_id)
21268 .unwrap()
21269 .state,
21270 ManagedWorktreeLeaseState::Removed,
21271 );
21272 let renamed = shared
21273 .rename_session_record(&record_id, "raw managed inventory".to_owned())
21274 .unwrap();
21275 assert_eq!(renamed.record_id, record_id);
21276 assert_eq!(renamed.display_name, "raw managed inventory");
21277 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21278 assert!(renamed.active_session.is_none());
21279
21280 drop(shared);
21281 drop(runtime);
21282 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21283 std::fs::remove_dir_all(git_root).unwrap();
21284 }
21285
21286 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21287 let root = std::env::current_dir().unwrap()
21288 .join("managed-test-target")
21289 .to_string_lossy()
21290 .into_owned();
21291 let lease = ManagedWorktreeLeaseRecord {
21292 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21293 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21294 workspace_id: WorkspaceId::new("managed-test").unwrap(),
21295 profile_id: WorktreeProfileId::new("default").unwrap(),
21296 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21297 target_root: root.clone(),
21298 branch: "gate4agent/mw-test".to_owned(),
21299 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21300 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21301 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21302 state: ManagedWorktreeLeaseState::Ready,
21303 session_holders: Vec::new(),
21304 record_holders: Vec::new(),
21305 cleanup_failure: None,
21306 created_at_unix_ms: 1,
21307 updated_at_unix_ms: 1,
21308 };
21309 shared.workspaces.write()
21310 .unwrap_or_else(|poisoned| poisoned.into_inner())
21311 .insert(lease.workspace_id.clone(), root);
21312 *shared.managed_worktrees.lock()
21313 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21314 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21315 lease
21316 }
21317
21318 #[cfg(feature = "fixture")]
21319 #[test]
21320 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21321 let mut shared = terminal_test_shared();
21322 shared.fixture_semantic_hook_policy = true;
21323 let lease = install_managed_lease(&shared);
21324 let address = SessionAddress {
21325 workspace_id: lease.workspace_id.clone(),
21326 session: SessionKey {
21327 instance_id: AgentInstanceId(206),
21328 generation: SessionGeneration(1),
21329 },
21330 };
21331 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21332 let effective_runtime_policy =
21333 shared.effective_spawn_runtime_policy(pre_admission_policy);
21334 assert_ne!(effective_runtime_policy, pre_admission_policy);
21335 assert!(effective_runtime_policy.provider_session_identity);
21336
21337 let record_id = shared
21338 .bind_spawn_session(
21339 &address,
21340 agent("claude"),
21341 SessionMode::Pty,
21342 effective_runtime_policy,
21343 SpawnRecordPolicy::Always,
21344 None,
21345 )
21346 .unwrap()
21347 .unwrap();
21348 assert_eq!(
21349 shared.record(&record_id).unwrap().state,
21350 ManagedSessionState::IdentityPending,
21351 );
21352 let (record_holder, raw_inventory_record_id) = {
21353 let mut bindings = shared
21354 .session_bindings
21355 .lock()
21356 .unwrap_or_else(|poisoned| poisoned.into_inner());
21357 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
21358 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
21359 managed_spawn_record_ownership(binding, effective_runtime_policy)
21360 };
21361 assert_eq!(record_holder.as_ref(), Some(&record_id));
21362 assert!(raw_inventory_record_id.is_none());
21363 let snapshot = shared
21364 .managed_worktrees
21365 .lock()
21366 .unwrap_or_else(|poisoned| poisoned.into_inner())
21367 .bind_session(
21368 &lease.lease_id,
21369 ManagedWorktreeSessionHolder {
21370 incarnation_id: shared.incarnation_id,
21371 instance_id: address.session.instance_id,
21372 generation: address.session.generation,
21373 },
21374 record_holder,
21375 unix_time_ms(),
21376 )
21377 .unwrap();
21378 assert_eq!(snapshot.managed_record_count, 1);
21379 assert_eq!(
21380 shared
21381 .managed_worktrees
21382 .lock()
21383 .unwrap_or_else(|poisoned| poisoned.into_inner())
21384 .get(&lease.lease_id)
21385 .unwrap()
21386 .record_holders,
21387 vec![record_id.clone()],
21388 );
21389
21390 let identity = ProviderSessionIdentity {
21391 key: ProviderSessionKey::SessionId,
21392 id: "monitoring-fixture-managed-session".to_owned(),
21393 transcript_path: None,
21394 };
21395 shared.publish_control(ControlEvent {
21396 sequence: 1,
21397 command_id: None,
21398 instance_id: address.session.instance_id,
21399 generation: address.session.generation,
21400 event: ControlEventKind::ProviderEvent {
21401 sequence: 1,
21402 source: gate4agent_types::ProviderSource {
21403 family: AdapterFamily::Hook,
21404 binding: AdapterBinding::new(
21405 gate4agent_types::AdapterId::new("claude-code").unwrap(),
21406 "fixture/v1",
21407 gate4agent_types::AdapterVerification::SyntheticFixture,
21408 )
21409 .unwrap(),
21410 },
21411 source_sequence: 1,
21412 event: ProviderEvent::SessionIdentityObserved {
21413 identity: identity.clone(),
21414 },
21415 },
21416 });
21417
21418 let live = shared.record(&record_id).unwrap();
21419 assert_eq!(live.state, ManagedSessionState::Live);
21420 assert_eq!(live.provider_session.as_ref(), Some(&identity));
21421 assert_eq!(live.active_session.as_ref(), Some(&address));
21422 assert_eq!(
21423 shared
21424 .managed_worktrees
21425 .lock()
21426 .unwrap_or_else(|poisoned| poisoned.into_inner())
21427 .get(&lease.lease_id)
21428 .unwrap()
21429 .record_holders,
21430 vec![record_id],
21431 );
21432 }
21433
21434 fn managed_v2_test_shared(
21435 allocation_root: &Path,
21436 state_path: Option<PathBuf>,
21437 profile_revision: &str,
21438 ) -> NodeShared {
21439 let profile = ManagedWorktreeProfile::new(
21440 WorktreeProfileId::new("default").unwrap(),
21441 WorktreeProfileRevision::new(profile_revision).unwrap(),
21442 allocation_root,
21443 "gate4agent",
21444 "HEAD",
21445 ManagedWorktreeRetention::RemoveWhenReleased,
21446 )
21447 .unwrap();
21448 let workspace = WorkspaceConfig::new(
21449 WorkspaceId::new("primary").unwrap(),
21450 std::env::current_dir().unwrap(),
21451 )
21452 .unwrap()
21453 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21454 .with_managed_worktree_profile(profile)
21455 .unwrap();
21456 let catalog = active_registry().unwrap();
21457 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21458 let mut shared = NodeShared::new(
21459 handle,
21460 "fixture-token".to_owned(),
21461 NodeId::new("node-terminal-test").unwrap(),
21462 vec![workspace],
21463 vec![agent("claude")],
21464 );
21465 shared.state_path = state_path;
21466 shared
21467 }
21468
21469 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
21470 let mut spawn_spec = spawn_spec_fixture();
21471 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
21472 ManagedWorktreeSpawnRequestV2 {
21473 spawn_spec,
21474 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21475 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
21476 }
21477 }
21478
21479 #[test]
21480 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
21481 let shared = terminal_test_shared();
21482 let mut lease = install_managed_lease(&shared);
21483 let session = SessionAddress {
21484 workspace_id: lease.workspace_id.clone(),
21485 session: SessionKey {
21486 instance_id: AgentInstanceId(73),
21487 generation: SessionGeneration(4),
21488 },
21489 };
21490 lease.state = ManagedWorktreeLeaseState::InUse;
21491 lease.session_holders.push(ManagedWorktreeSessionHolder {
21492 incarnation_id: shared.incarnation_id,
21493 instance_id: session.session.instance_id,
21494 generation: session.session.generation,
21495 });
21496 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21497 assert!(resolved.target.worktree_id.is_none());
21498
21499 let receipt = managed_spawn_receipt(
21500 &resolved,
21501 shared.incarnation_id,
21502 session.clone(),
21503 lease.snapshot(),
21504 None,
21505 None,
21506 None,
21507 );
21508 assert!(resolved.target.worktree_id.is_none());
21509 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
21510 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
21511 assert_eq!(receipt.spawn.session, session);
21512 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
21513 let encoded = serde_json::to_vec(&receipt).unwrap();
21514 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
21515 assert_eq!(decoded, receipt);
21516 }
21517
21518 #[tokio::test]
21519 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
21520 let root = temporary_workspace_root("managed-v2-revision");
21521 let allocation = root.join("allocation");
21522 std::fs::create_dir_all(&allocation).unwrap();
21523 let shared = managed_v2_test_shared(
21524 &allocation,
21525 Some(root.join("node-state.json")),
21526 "v1",
21527 );
21528 let mut request = managed_v2_request("managed-v2-revision");
21529 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
21530
21531 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
21532
21533 assert_eq!(
21534 failure.code,
21535 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21536 );
21537 assert!(shared
21538 .managed_worktrees
21539 .lock()
21540 .unwrap_or_else(|poisoned| poisoned.into_inner())
21541 .records()
21542 .is_empty());
21543 assert!(shared
21544 .managed_spawn_replays
21545 .lock()
21546 .unwrap_or_else(|poisoned| poisoned.into_inner())
21547 .is_empty());
21548 std::fs::remove_dir_all(root).unwrap();
21549 }
21550
21551 #[test]
21552 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
21553 let root = temporary_workspace_root("managed-v2-concurrent");
21554 let allocation = root.join("allocation");
21555 std::fs::create_dir_all(&allocation).unwrap();
21556 let shared = Arc::new(managed_v2_test_shared(
21557 &allocation,
21558 Some(root.join("node-state.json")),
21559 "v1",
21560 ));
21561 let request = managed_v2_request("managed-v2-concurrent");
21562 let barrier = Arc::new(std::sync::Barrier::new(3));
21563 let mut joins = Vec::new();
21564 for _ in 0..2 {
21565 let shared = Arc::clone(&shared);
21566 let request = request.clone();
21567 let barrier = Arc::clone(&barrier);
21568 joins.push(std::thread::spawn(move || {
21569 let digest = managed_spawn_request_digest_v2(&request).unwrap();
21570 barrier.wait();
21571 shared.acquire_durable_managed_spawn_v2(&request, digest)
21572 }));
21573 }
21574 barrier.wait();
21575 let results = joins
21576 .into_iter()
21577 .map(|join| join.join().unwrap())
21578 .collect::<Vec<_>>();
21579 assert_eq!(
21580 results
21581 .iter()
21582 .filter(|result| {
21583 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
21584 })
21585 .count(),
21586 1,
21587 );
21588 assert_eq!(
21589 results
21590 .iter()
21591 .filter(|result| {
21592 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
21593 })
21594 .count(),
21595 1,
21596 );
21597 assert_eq!(
21598 shared
21599 .managed_spawn_replays
21600 .lock()
21601 .unwrap_or_else(|poisoned| poisoned.into_inner())
21602 .len(),
21603 1,
21604 );
21605 assert!(shared
21606 .managed_worktrees
21607 .lock()
21608 .unwrap_or_else(|poisoned| poisoned.into_inner())
21609 .records()
21610 .is_empty());
21611 drop(shared);
21612 std::fs::remove_dir_all(root).unwrap();
21613 }
21614
21615 #[tokio::test]
21616 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
21617 let root = temporary_workspace_root("managed-v2-linked-pending");
21618 let allocation = root.join("allocation");
21619 std::fs::create_dir_all(&allocation).unwrap();
21620 let state_path = root.join("node-state.json");
21621 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21622 let lease = install_managed_lease(&shared);
21623 let request = managed_v2_request("managed-v2-linked-pending");
21624 assert!(matches!(
21625 shared
21626 .acquire_durable_managed_spawn_v2(
21627 &request,
21628 managed_spawn_request_digest_v2(&request).unwrap(),
21629 )
21630 .unwrap(),
21631 DurableManagedSpawnReplayDecision::Reserved,
21632 ));
21633 {
21634 let _transaction = shared
21635 .state_transaction
21636 .lock()
21637 .unwrap_or_else(|poisoned| poisoned.into_inner());
21638 shared
21639 .link_durable_managed_spawn_v2_lease_locked(
21640 &request.spawn_spec.idempotency_key,
21641 &lease.lease_id,
21642 )
21643 .unwrap();
21644 shared.persist_state_locked().unwrap();
21645 }
21646 drop(shared);
21647
21648 let mut loaded = session_registry::load(
21649 Some(&state_path),
21650 &NodeId::new("node-terminal-test").unwrap(),
21651 )
21652 .unwrap();
21653 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21654 *reopened
21655 .managed_worktrees
21656 .lock()
21657 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21658 ManagedWorktreeRegistry::from_records(
21659 std::mem::take(&mut loaded.managed_worktrees),
21660 std::mem::take(&mut loaded.managed_worktree_tombstones),
21661 )
21662 .unwrap();
21663 *reopened
21664 .managed_spawn_replays
21665 .lock()
21666 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21667 .managed_spawn_replays
21668 .into_iter()
21669 .map(|record| (record.idempotency_key.clone(), record))
21670 .collect();
21671 let lease_count = reopened
21672 .managed_worktrees
21673 .lock()
21674 .unwrap_or_else(|poisoned| poisoned.into_inner())
21675 .records()
21676 .len();
21677
21678 let failure = reopened
21679 .spawn_managed_worktree_v2(request)
21680 .await
21681 .unwrap_err();
21682
21683 assert_eq!(
21684 failure.code,
21685 NodeFailureCode::ManagedWorktreeRecoveryRequired,
21686 );
21687 assert_eq!(
21688 reopened
21689 .managed_worktrees
21690 .lock()
21691 .unwrap_or_else(|poisoned| poisoned.into_inner())
21692 .records()
21693 .len(),
21694 lease_count,
21695 );
21696 std::fs::remove_dir_all(root).unwrap();
21697 }
21698
21699 #[tokio::test]
21700 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
21701 let root = temporary_workspace_root("managed-v2-reopen");
21702 let allocation = root.join("allocation");
21703 std::fs::create_dir_all(&allocation).unwrap();
21704 let state_path = root.join("node-state.json");
21705 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21706 let lease = install_managed_lease(&shared);
21707 let request = managed_v2_request("managed-v2-reopen");
21708 let session = SessionAddress {
21709 workspace_id: lease.workspace_id.clone(),
21710 session: SessionKey {
21711 instance_id: AgentInstanceId(91),
21712 generation: SessionGeneration(1),
21713 },
21714 };
21715 let snapshot = {
21716 let mut registry = shared
21717 .managed_worktrees
21718 .lock()
21719 .unwrap_or_else(|poisoned| poisoned.into_inner());
21720 let current = registry.get_mut(&lease.lease_id).unwrap();
21721 current.state = ManagedWorktreeLeaseState::InUse;
21722 current.session_holders.push(ManagedWorktreeSessionHolder {
21723 incarnation_id: shared.incarnation_id,
21724 instance_id: session.session.instance_id,
21725 generation: session.session.generation,
21726 });
21727 current.snapshot()
21728 };
21729 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
21730 let receipt = managed_spawn_receipt(
21731 &resolved,
21732 shared.incarnation_id,
21733 session,
21734 snapshot,
21735 None,
21736 None,
21737 None,
21738 );
21739 assert!(matches!(
21740 shared
21741 .acquire_durable_managed_spawn_v2(
21742 &request,
21743 managed_spawn_request_digest_v2(&request).unwrap(),
21744 )
21745 .unwrap(),
21746 DurableManagedSpawnReplayDecision::Reserved,
21747 ));
21748 {
21749 let _transaction = shared
21750 .state_transaction
21751 .lock()
21752 .unwrap_or_else(|poisoned| poisoned.into_inner());
21753 shared
21754 .link_durable_managed_spawn_v2_lease_locked(
21755 &request.spawn_spec.idempotency_key,
21756 &lease.lease_id,
21757 )
21758 .unwrap();
21759 shared.persist_state_locked().unwrap();
21760 }
21761 shared
21762 .commit_durable_managed_spawn_v2(&request, receipt.clone())
21763 .unwrap();
21764 let legacy_same_key = ManagedWorktreeSpawnRequest {
21765 spawn_spec: request.spawn_spec.clone(),
21766 worktree_profile_id: request.worktree_profile_id.clone(),
21767 };
21768 shared.remember_managed_spawn_attempt(
21769 Some(&request.spawn_spec.idempotency_key),
21770 legacy_same_key.clone(),
21771 Ok(receipt.clone()),
21772 Instant::now(),
21773 );
21774 assert!(shared
21775 .replay_managed_spawn(&legacy_same_key, Instant::now())
21776 .unwrap()
21777 .is_none());
21778 drop(shared);
21779
21780 let mut loaded = session_registry::load(
21781 Some(&state_path),
21782 &NodeId::new("node-terminal-test").unwrap(),
21783 )
21784 .unwrap();
21785 assert_eq!(loaded.managed_spawn_replays.len(), 1);
21786 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21787 *reopened
21788 .managed_worktrees
21789 .lock()
21790 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21791 ManagedWorktreeRegistry::from_records(
21792 std::mem::take(&mut loaded.managed_worktrees),
21793 std::mem::take(&mut loaded.managed_worktree_tombstones),
21794 )
21795 .unwrap();
21796 *reopened
21797 .managed_spawn_replays
21798 .lock()
21799 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21800 .managed_spawn_replays
21801 .into_iter()
21802 .map(|record| (record.idempotency_key.clone(), record))
21803 .collect();
21804 let lease_count_before = reopened
21805 .managed_worktrees
21806 .lock()
21807 .unwrap_or_else(|poisoned| poisoned.into_inner())
21808 .records()
21809 .len();
21810
21811 assert_eq!(
21812 reopened
21813 .spawn_managed_worktree_v2(request.clone())
21814 .await
21815 .unwrap(),
21816 receipt,
21817 );
21818 assert_eq!(
21819 reopened
21820 .managed_worktrees
21821 .lock()
21822 .unwrap_or_else(|poisoned| poisoned.into_inner())
21823 .records()
21824 .len(),
21825 lease_count_before,
21826 );
21827 let mut changed = request;
21828 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
21829 let failure = reopened
21830 .spawn_managed_worktree_v2(changed)
21831 .await
21832 .unwrap_err();
21833 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
21834 assert_eq!(
21835 reopened
21836 .managed_worktrees
21837 .lock()
21838 .unwrap_or_else(|poisoned| poisoned.into_inner())
21839 .records()
21840 .len(),
21841 lease_count_before,
21842 );
21843 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
21844 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
21845 let failure = reopened
21846 .spawn_managed_worktree_v2(new_key)
21847 .await
21848 .unwrap_err();
21849 assert_eq!(
21850 failure.code,
21851 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21852 );
21853 assert_eq!(
21854 reopened
21855 .managed_worktrees
21856 .lock()
21857 .unwrap_or_else(|poisoned| poisoned.into_inner())
21858 .records()
21859 .len(),
21860 lease_count_before,
21861 );
21862 assert_eq!(
21863 reopened
21864 .managed_spawn_replays
21865 .lock()
21866 .unwrap_or_else(|poisoned| poisoned.into_inner())
21867 .len(),
21868 1,
21869 );
21870 std::fs::remove_dir_all(root).unwrap();
21871 }
21872
21873 #[test]
21874 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
21875 let shared = terminal_test_shared();
21876 let request = ManagedWorktreeSpawnRequest {
21877 spawn_spec: spawn_spec_fixture(),
21878 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21879 };
21880 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
21881 shared.remember_managed_spawn(
21882 request.clone(),
21883 Err(expected.clone()),
21884 Instant::now(),
21885 );
21886
21887 assert_eq!(
21888 shared
21889 .replay_managed_spawn(&request, Instant::now())
21890 .unwrap()
21891 .unwrap(),
21892 Err(expected),
21893 );
21894 assert!(shared
21895 .managed_spawn_replays
21896 .lock()
21897 .unwrap_or_else(|poisoned| poisoned.into_inner())
21898 .is_empty());
21899 }
21900
21901 #[tokio::test]
21902 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
21903 let shared = terminal_test_shared();
21904 let lease = install_managed_lease(&shared);
21905 let raw = shared.spawn_session(
21906 lease.workspace_id.clone(),
21907 agent("claude"),
21908 SessionMode::Pty,
21909 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
21910 None,
21911 ).await.unwrap_err();
21912 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21913 let mut spec = spawn_spec_fixture();
21914 spec.target.workspace_id = lease.workspace_id.clone();
21915 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
21916 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21917 let mut selected = spawn_spec_fixture();
21918 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
21919 selected.target.worktree_id = Some(lease.workspace_id);
21920 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
21921 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21922 assert!(shared.handle.snapshot().sessions.is_empty());
21923 }
21924
21925 #[test]
21926 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
21927 let shared = terminal_test_shared();
21928 let lease = install_managed_lease(&shared);
21929 let snapshot = snapshot_for_wire(
21930 &shared, true, true, true, false, true, true, false, false, true,
21931 );
21932 assert!(snapshot.managed_worktrees.is_empty());
21933 assert!(snapshot.workspaces.iter().all(|workspace| {
21934 workspace.managed_worktree_profiles.is_none()
21935 }));
21936 let mut reply = ResponseEnvelope {
21937 request_id: 1,
21938 result: Ok(NodeResponse::Resync {
21939 event_sequence: 1,
21940 oldest_available_sequence: 1,
21941 snapshot: shared.snapshot(),
21942 events: vec![NodeEventEnvelope {
21943 sequence: 1,
21944 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
21945 }],
21946 }),
21947 };
21948 project_response_without_managed_worktrees(&mut reply);
21949 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
21950 panic!("projection changed the response kind");
21951 };
21952 assert!(snapshot.managed_worktrees.is_empty());
21953 assert!(events.is_empty());
21954 let mut git = git_snapshot_for_parser();
21955 git.branch = Some(lease.branch.clone());
21956 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
21957 lease_id: lease.lease_id.clone(),
21958 source_workspace_id: lease.source_workspace_id.clone(),
21959 branch: lease.branch.clone(),
21960 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
21961 active_session_count: 1,
21962 managed_record_count: 0,
21963 });
21964 let mut inspection_reply = ResponseEnvelope {
21965 request_id: 2,
21966 result: Ok(NodeResponse::WorkspaceInspected {
21967 inspection: WorkspaceInspection {
21968 workspace_id: lease.workspace_id.clone(),
21969 entries: Vec::new(),
21970 tree_truncated: false,
21971 git,
21972 truncation: None,
21973 },
21974 }),
21975 };
21976 project_response_without_managed_worktrees(&mut inspection_reply);
21977 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
21978 panic!("projection changed the inspection response kind");
21979 };
21980 assert!(inspection.git.managed_worktree.is_none());
21981 let failure = managed_git_worktree_failure(GitWorktreeError {
21982 kind: GitWorktreeErrorKind::Failed,
21983 message: r"git failed at C:\private\managed-target".to_owned(),
21984 });
21985 assert!(!failure.message.contains("private"));
21986 assert!(!failure.message.contains("managed-target"));
21987 }
21988
21989 #[tokio::test]
21990 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
21991 let root = temporary_workspace_root("managed-restart");
21992 let source = root.join("source");
21993 let allocation = root.join("allocation");
21994 std::fs::create_dir_all(&source).unwrap();
21995 std::fs::create_dir_all(&allocation).unwrap();
21996 run_test_git(&source, &["init"]);
21997 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21998 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21999 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22000 run_test_git(&source, &["add", "seed.txt"]);
22001 run_test_git(&source, &["commit", "-m", "seed"]);
22002 let profile = ManagedWorktreeProfile::new(
22003 WorktreeProfileId::new("default").unwrap(),
22004 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22005 &allocation,
22006 "gate4agent",
22007 "HEAD",
22008 ManagedWorktreeRetention::Retain,
22009 ).unwrap();
22010 let workspace = WorkspaceConfig::new(
22011 WorkspaceId::new("primary").unwrap(),
22012 &source,
22013 ).unwrap()
22014 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22015 .with_managed_worktree_profile(profile.clone()).unwrap();
22016 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22017 let source_text = workspace.canonical_root().to_owned();
22018 let target_text = target.to_string_lossy().into_owned();
22019 let created = create_git_worktree(
22020 &source_text,
22021 &target_text,
22022 "gate4agent/mw-restart",
22023 Some("HEAD"),
22024 ).await.unwrap();
22025 let lease = ManagedWorktreeLeaseRecord {
22026 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22027 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22028 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22029 profile_id: profile.profile_id().clone(),
22030 profile_revision: profile.revision().clone(),
22031 target_root: created.path.clone(),
22032 branch: "gate4agent/mw-restart".to_owned(),
22033 base_commit: created.head.clone(),
22034 expected_head: None,
22035 retention: ManagedWorktreeRetention::Retain,
22036 state: ManagedWorktreeLeaseState::Allocating,
22037 session_holders: Vec::new(),
22038 record_holders: Vec::new(),
22039 cleanup_failure: None,
22040 created_at_unix_ms: 1,
22041 updated_at_unix_ms: 1,
22042 };
22043 let catalog = active_registry().unwrap();
22044 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22045 let shared = NodeShared::new_with_incarnation(
22046 handle,
22047 "fixture-token".to_owned(),
22048 NodeId::new("node-managed-restart").unwrap(),
22049 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22050 vec![workspace],
22051 vec![agent("claude")],
22052 ProviderRuntimeStatuses::default(),
22053 None,
22054 Vec::new(),
22055 Vec::new(),
22056 SpawnProfileRegistry::default(),
22057 None,
22058 None,
22059 None,
22060 None,
22061 Vec::new(),
22062 vec![lease.clone()],
22063 Vec::new(),
22064 Vec::new(),
22065 None,
22066 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22067 );
22068 shared.reconcile_managed_worktrees().await;
22069 let retained = shared.managed_worktrees.lock()
22070 .unwrap_or_else(|poisoned| poisoned.into_inner())
22071 .get(&lease.lease_id).unwrap().clone();
22072 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22073 assert!(shared.workspaces.read()
22074 .unwrap_or_else(|poisoned| poisoned.into_inner())
22075 .contains_key(&lease.workspace_id));
22076 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22077 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22078 assert!(!Path::new(&created.path).exists());
22079 std::fs::remove_dir_all(root).unwrap();
22080 }
22081
22082 fn run_test_git(root: &Path, arguments: &[&str]) {
22083 let output = std::process::Command::new("git")
22084 .arg("-C").arg(root).args(arguments).output().unwrap();
22085 assert!(
22086 output.status.success(),
22087 "git {:?} failed: {}",
22088 arguments,
22089 String::from_utf8_lossy(&output.stderr),
22090 );
22091 }
22092
22093 #[tokio::test]
22094 async fn spawn_spec_validation_fails_before_session_mutation() {
22095 let shared = terminal_test_shared();
22096 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22097 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22098 }));
22099 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22100 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22101 }));
22102 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22103 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22104 }));
22105
22106 let mut target_mismatch = spawn_spec_fixture();
22107 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22108 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22109 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22110
22111 let mut unknown_profile = spawn_spec_fixture();
22112 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22113 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22114 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22115 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22116
22117 let mut revision_mismatch = spawn_spec_fixture();
22118 revision_mismatch.idempotency_key =
22119 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22120 revision_mismatch.expected_profile_revision =
22121 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22122 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22123 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22124
22125 let mut unsupported_materializer = spawn_spec_fixture();
22126 unsupported_materializer.idempotency_key =
22127 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22128 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22129 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22130 };
22131 let failure = shared
22132 .spawn_from_spec(unsupported_materializer)
22133 .await
22134 .unwrap_err();
22135 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22136
22137 let failure = shared
22138 .spawn_session_with_deadline(
22139 WorkspaceId::new("primary").unwrap(),
22140 agent("claude"),
22141 SessionMode::Pty,
22142 gate4agent_types::TerminalSize {
22143 rows: 24,
22144 columns: 80,
22145 },
22146 None,
22147 None,
22148 None,
22149 None,
22150 None,
22151 None,
22152 SpawnRecordPolicy::ProviderIdentityOnly,
22153 Some(Instant::now()),
22154 &[],
22155 None,
22156 ApprovalLevel::default(),
22157 None,
22158 None,
22159 )
22160 .await
22161 .unwrap_err();
22162 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22163 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22164 assert!(shared.handle.snapshot().sessions.is_empty());
22165 }
22166
22167 #[test]
22168 fn worktree_selection_requires_its_separate_negotiated_capability() {
22169 let mut spec = spawn_spec_fixture();
22170 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22171 let request = NodeRequest::SpawnSpec { spec };
22172 let spawn_spec_capability =
22173 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22174 assert!(request_uses_unnegotiated_capability(
22175 &request,
22176 &[spawn_spec_capability.clone()],
22177 ));
22178 let worktree_capability =
22179 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22180 assert!(request_uses_unnegotiated_capability(
22181 &request,
22182 &[spawn_spec_capability.clone(), worktree_capability.clone()],
22183 ));
22184 let profile_revision_capability =
22185 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22186 assert!(request_uses_unnegotiated_capability(
22187 &request,
22188 &[
22189 spawn_spec_capability.clone(),
22190 worktree_capability.clone(),
22191 profile_revision_capability.clone(),
22192 ],
22193 ));
22194 let bundle_materialization_capability =
22201 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22202 assert!(request_uses_unnegotiated_capability(
22203 &request,
22204 &[
22205 spawn_spec_capability.clone(),
22206 worktree_capability.clone(),
22207 profile_revision_capability.clone(),
22208 bundle_materialization_capability.clone(),
22209 ],
22210 ));
22211 let history_context_pack_capability =
22212 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22213 assert!(!request_uses_unnegotiated_capability(
22214 &request,
22215 &[
22216 spawn_spec_capability,
22217 worktree_capability,
22218 profile_revision_capability,
22219 bundle_materialization_capability,
22220 history_context_pack_capability,
22221 ],
22222 ));
22223 }
22224
22225 #[tokio::test]
22226 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22227 let mut shared = terminal_test_shared();
22228 let spec = spawn_spec_fixture();
22229 let receipt = shared
22230 .resolve_spawn_spec(&spec)
22231 .unwrap()
22232 .receipt(shared.incarnation_id, terminal_address(1));
22233 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22234
22235 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22236 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22237 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22238
22239 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22240
22241 let mut revision_conflicting = spec.clone();
22242 revision_conflicting.expected_profile_revision =
22243 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22244 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22245 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22246
22247 let mut stale_cache_miss = spec.clone();
22248 stale_cache_miss.idempotency_key =
22249 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22250 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22251 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22252
22253 let mut conflicting = spec;
22254 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22255 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22256 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22257 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22258 assert!(shared.handle.snapshot().sessions.is_empty());
22259 }
22260
22261 #[tokio::test]
22262 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22263 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22264 let mut spec = spawn_spec_fixture();
22265 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22266 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22267 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22268
22269 let failure = shared
22270 .create_worktree(
22271 WorkspaceId::new("primary").unwrap(),
22272 WorkspaceId::new("new-worktree").unwrap(),
22273 "not-an-absolute-path".to_owned(),
22274 "invalid branch".to_owned(),
22275 None,
22276 )
22277 .await
22278 .unwrap_err();
22279 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22280
22281 let failure = shared
22282 .remove_worktree(
22283 WorkspaceId::new("primary").unwrap(),
22284 "not-an-absolute-path".to_owned(),
22285 )
22286 .await
22287 .unwrap_err();
22288 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22289 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22290 assert!(shared.handle.snapshot().sessions.is_empty());
22291 }
22292
22293 #[tokio::test]
22294 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22295 let root = temporary_workspace_root("manual-worktree-compensation");
22296 let source = root.join("source");
22297 let target = root.join("target");
22298 let invalid_state_parent = root.join("state-parent-is-a-file");
22299 let invalid_state_path = invalid_state_parent.join("state.json");
22300 std::fs::create_dir_all(&source).unwrap();
22301 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22302 run_test_git(&source, &["init"]);
22303 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22304 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22305 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22306 run_test_git(&source, &["add", "seed.txt"]);
22307 run_test_git(&source, &["commit", "-m", "seed"]);
22308
22309 let catalog = active_registry().unwrap();
22310 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22311 let workspace = WorkspaceConfig::new(
22312 WorkspaceId::new("primary").unwrap(),
22313 &source,
22314 ).unwrap();
22315 let mut shared = NodeShared::new(
22316 handle,
22317 "fixture-token".to_owned(),
22318 NodeId::new("node-worktree-compensation").unwrap(),
22319 vec![workspace],
22320 vec![agent("claude")],
22321 );
22322 shared.state_path = Some(invalid_state_path);
22323
22324 let error = shared.create_worktree(
22325 WorkspaceId::new("primary").unwrap(),
22326 WorkspaceId::new("compensated").unwrap(),
22327 target.to_string_lossy().into_owned(),
22328 "gate4agent/compensated".to_owned(),
22329 Some("HEAD".to_owned()),
22330 ).await.unwrap_err();
22331
22332 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22333 assert!(!target.exists());
22334 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22335 .iter()
22336 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22337 std::fs::remove_dir_all(root).unwrap();
22338 }
22339
22340 #[test]
22341 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22342 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22343 NativeGitWorktreeSnapshot {
22344 path: path.to_owned(),
22345 head: "0123456789abcdef".to_owned(),
22346 branch: Some("refs/heads/test".to_owned()),
22347 is_bare,
22348 is_main,
22349 locked: false,
22350 lock_reason: None,
22351 prunable: false,
22352 prunable_reason: None,
22353 workspace_id: None,
22354 }
22355 }
22356
22357 let source = r"C:\repo";
22358 let selected = r"C:\repo-worktree";
22359 let listed = vec![
22360 worktree(source, true, false),
22361 worktree(selected, false, false),
22362 ];
22363 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
22364
22365 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
22366 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
22367
22368 let bare = vec![
22369 worktree(source, true, false),
22370 worktree(selected, false, true),
22371 ];
22372 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
22373 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
22374
22375 let foreign_failure =
22376 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
22377 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
22378 }
22379
22380 fn terminal_frame(sequence: u64) -> TerminalFrame {
22381 TerminalFrame {
22382 sequence,
22383 size: gate4agent_types::TerminalSize {
22384 rows: 24,
22385 columns: 80,
22386 },
22387 cursor_row: 2,
22388 cursor_column: 3,
22389 contents: format!("frame-{sequence}"),
22390 formatted: vec![1, 2, 3],
22391 scrollback_formatted: Vec::new(),
22392 alternate_screen: false,
22393 mouse_protocol_enabled: false,
22394 mouse_protocol_encoding: Default::default(),
22395 produced_at_unix_ms: 0,
22396 screen_state: gate4agent_types::PtyScreenState::default(),
22397 bracketed_paste: None,
22398 }
22399 }
22400
22401 #[test]
22402 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
22403 let shared = terminal_test_shared();
22404 let mut receiver = shared.terminal_event_tx.subscribe();
22405 let original = terminal_address(1);
22406
22407 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22408 let first = receiver.try_recv().unwrap();
22409 assert_eq!(first.len(), 1);
22410 assert!(matches!(
22411 &first[0].event,
22412 NodeEvent::TerminalFrame { address, frame }
22413 if address == &original && frame.sequence == 7
22414 ));
22415
22416 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22417 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
22418 assert!(matches!(
22419 receiver.try_recv(),
22420 Err(broadcast::error::TryRecvError::Empty)
22421 ));
22422
22423 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
22424 let advanced = receiver.try_recv().unwrap();
22425 assert_eq!(advanced[0].sequence, 2);
22426
22427 let resumed = terminal_address(2);
22428 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
22429 let reset = receiver.try_recv().unwrap();
22430 assert_eq!(reset[0].sequence, 3);
22431 assert!(matches!(
22432 &reset[0].event,
22433 NodeEvent::TerminalFrame { address, frame }
22434 if address == &resumed && frame.sequence == 1
22435 ));
22436 shared.clear_terminal_frame_watermark(&resumed);
22437 assert!(!shared
22438 .terminal_frame_watermarks
22439 .lock()
22440 .unwrap_or_else(|poisoned| poisoned.into_inner())
22441 .contains_key(&resumed.session.instance_id));
22442 }
22443
22444 #[test]
22445 fn terminal_frame_batches_replace_without_entering_node_event_history() {
22446 let shared = terminal_test_shared();
22447 let mut receiver = shared.terminal_event_tx.subscribe();
22448 let address = terminal_address(1);
22449 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
22450
22451 for sequence in 1..=count {
22452 shared.publish_terminal_frame_candidates(vec![(
22453 address.clone(),
22454 terminal_frame(sequence),
22455 )]);
22456 }
22457
22458 let history = shared
22459 .history
22460 .lock()
22461 .unwrap_or_else(|poisoned| poisoned.into_inner());
22462 assert!(history.events.is_empty());
22463 assert_eq!(history.last_sequence, count);
22464 assert_eq!(history.replay_floor_sequence, 1);
22465 drop(history);
22466 let NodeResponse::Resync {
22467 event_sequence,
22468 oldest_available_sequence,
22469 events,
22470 ..
22471 } = shared.resync(0) else {
22472 unreachable!("resync helper returned another response")
22473 };
22474 assert_eq!(event_sequence, count);
22475 assert_eq!(oldest_available_sequence, 1);
22476 assert!(events.is_empty());
22477 assert!(matches!(
22478 resync_required_event(&shared).event,
22479 NodeEvent::ResyncRequired {
22480 oldest_available_sequence: 1,
22481 }
22482 ));
22483 assert!(matches!(
22484 receiver.try_recv(),
22485 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
22486 ));
22487 let latest = receiver.try_recv().unwrap();
22488 assert_eq!(latest.len(), 1);
22489 assert_eq!(latest[0].sequence, count);
22490 }
22491
22492 #[test]
22493 fn real_durable_eviction_advances_authoritative_replay_floor() {
22494 let shared = terminal_test_shared();
22495 for _ in 0..=NODE_EVENT_HISTORY_MAX {
22496 shared.publish(NodeEvent::ControllerChanged { controller: None });
22497 }
22498
22499 let NodeResponse::Resync {
22500 event_sequence,
22501 oldest_available_sequence,
22502 events,
22503 ..
22504 } = shared.resync(0) else {
22505 unreachable!("resync helper returned another response")
22506 };
22507 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
22508 assert_eq!(oldest_available_sequence, 2);
22509 assert_eq!(events.first().map(|event| event.sequence), Some(2));
22510 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
22511 assert!(matches!(
22512 resync_required_event(&shared).event,
22513 NodeEvent::ResyncRequired {
22514 oldest_available_sequence: 2,
22515 }
22516 ));
22517 }
22518
22519 #[test]
22520 fn legacy_terminal_frame_connection_has_no_live_subscription() {
22521 let shared = terminal_test_shared();
22522 let without_capability = terminal_event_subscription(&shared, false);
22523 let with_capability = terminal_event_subscription(&shared, true);
22524 assert!(without_capability.is_none());
22525 assert!(with_capability.is_some());
22526 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22527 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
22528 }));
22529 }
22530
22531 #[test]
22532 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
22533 let mut pending = Vec::new();
22534 let mut event_sequence = 0_u64;
22535 for frame_sequence in 1..=4 {
22536 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
22537 event_sequence += 1;
22538 let mut address = terminal_address(1);
22539 address.session.instance_id = AgentInstanceId(instance);
22540 queue_connection_event(
22541 &mut pending,
22542 NodeEventEnvelope {
22543 sequence: event_sequence,
22544 event: NodeEvent::TerminalFrame {
22545 address,
22546 frame: terminal_frame(frame_sequence),
22547 },
22548 },
22549 0,
22550 );
22551 }
22552 }
22553 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
22554 pending.sort_unstable_by_key(|event| event.sequence);
22555
22556 let mut uninterrupted = pending.clone();
22557 let first_burst = connection_event_burst_len(uninterrupted.len());
22558 uninterrupted.drain(..first_burst);
22559 let second_burst = connection_event_burst_len(uninterrupted.len());
22560 uninterrupted.drain(..second_burst);
22561 assert!(uninterrupted.is_empty());
22562
22563 let (frame_tx, mut frame_rx) =
22564 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
22565 frame_tx
22566 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
22567 request_id: 77,
22568 request: NodeRequest::Snapshot,
22569 })))
22570 .unwrap();
22571 let burst_len = connection_event_burst_len(pending.len());
22572 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
22573 pending.drain(..burst_len);
22574 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
22575 assert!(matches!(
22576 frame_rx.try_recv(),
22577 Ok(Ok(ClientFrame::Request(RequestEnvelope {
22578 request_id: 77,
22579 request: NodeRequest::Snapshot,
22580 })))
22581 ));
22582 }
22583
22584 #[test]
22585 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
22586 let mut pending = Vec::new();
22587 for sequence in [7_u64, 8] {
22588 let mut address = terminal_address(1);
22589 address.session.instance_id = AgentInstanceId(sequence);
22590 queue_connection_event(
22591 &mut pending,
22592 NodeEventEnvelope {
22593 sequence,
22594 event: NodeEvent::TerminalFrame {
22595 address,
22596 frame: terminal_frame(sequence),
22597 },
22598 },
22599 0,
22600 );
22601 }
22602 let mut discard_through = 0;
22603 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
22604 assert!(pending.is_empty());
22605
22606 let replayed = NodeEventEnvelope {
22607 sequence: 8,
22608 event: NodeEvent::TerminalFrame {
22609 address: terminal_address(1),
22610 frame: terminal_frame(8),
22611 },
22612 };
22613 let advanced = NodeEventEnvelope {
22614 sequence: 9,
22615 event: NodeEvent::TerminalFrame {
22616 address: terminal_address(2),
22617 frame: terminal_frame(1),
22618 },
22619 };
22620 queue_connection_event_batch(
22621 &mut pending,
22622 &[replayed, advanced.clone()],
22623 discard_through,
22624 );
22625 assert_eq!(pending, vec![advanced]);
22626 }
22627
22628 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
22629 ManagedSessionRecord {
22630 record_id: SessionRecordId::new(record_id).unwrap(),
22631 display_name: format!("{provider} record"),
22632 provider: agent(provider),
22633 mode: SessionMode::Pty,
22634 state: ManagedSessionState::Unavailable,
22635 workspace_id: WorkspaceId::new("primary").unwrap(),
22636 canonical_root: opaque_windows_path(
22637 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22638 ),
22639 provider_session: None,
22640 active_session: None,
22641 environment_profile: None,
22642 bundle: None,
22643 context_id: None,
22644 context: None,
22645 exported_context: None,
22646 task_binding: None,
22647 created_at_unix_ms: 1,
22648 updated_at_unix_ms: 1,
22649 last_error: None,
22650 }
22651 }
22652
22653 fn retention_test_record(
22654 workspace_id: &str,
22655 record_id: &str,
22656 state: ManagedSessionState,
22657 updated_at_unix_ms: u64,
22658 ) -> ManagedSessionRecord {
22659 ManagedSessionRecord {
22660 record_id: SessionRecordId::new(record_id).unwrap(),
22661 display_name: format!("{record_id} display"),
22662 provider: agent("claude"),
22663 mode: SessionMode::Pty,
22664 state,
22665 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
22666 canonical_root: opaque_windows_path(
22667 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22668 ),
22669 provider_session: None,
22670 active_session: None,
22671 environment_profile: None,
22672 bundle: None,
22673 context_id: None,
22674 context: None,
22675 exported_context: None,
22676 task_binding: None,
22677 created_at_unix_ms: updated_at_unix_ms,
22678 updated_at_unix_ms,
22679 last_error: None,
22680 }
22681 }
22682
22683 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
22691 vec![
22692 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
22693 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
22694 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
22695 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
22696 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
22697 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
22698 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
22699 retention_test_record(
22700 "workspace-a",
22701 "decoy-pending",
22702 ManagedSessionState::IdentityPending,
22703 now - 500_000,
22704 ),
22705 retention_test_record(
22706 "workspace-a",
22707 "decoy-dormant",
22708 ManagedSessionState::Dormant,
22709 now - 500_000,
22710 ),
22711 ]
22712 }
22713
22714 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
22715 let root = temporary_workspace_root("session-record-retention");
22716 std::fs::create_dir_all(&root).unwrap();
22717 let catalog = active_registry().unwrap();
22718 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22719 let workspace_a =
22720 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
22721 let workspace_b =
22722 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
22723 let mut shared = NodeShared::new(
22724 handle,
22725 "fixture-token".to_owned(),
22726 NodeId::new("node-retention-test").unwrap(),
22727 vec![workspace_a, workspace_b],
22728 vec![agent("claude")],
22729 );
22730 shared.state_path = Some(root.join("node-state-v10.json"));
22731 let now = unix_time_ms();
22732 for record in retention_fixture_records(now) {
22733 shared.insert_record(record).unwrap();
22734 }
22735 shared
22736 .persist_state_locked()
22737 .expect("fixture-only, empty-history persist against a fresh temp state path");
22738 (shared, runtime, root, now)
22739 }
22740
22741 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22742 shared
22743 .history
22744 .lock()
22745 .unwrap_or_else(|poisoned| poisoned.into_inner())
22746 .events
22747 .iter()
22748 .filter_map(|envelope| match &envelope.event {
22749 NodeEvent::SessionRecordRemoved { record_id } => {
22750 Some(record_id.as_str().to_owned())
22751 }
22752 _ => None,
22753 })
22754 .collect()
22755 }
22756
22757 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22758 shared
22759 .session_records
22760 .lock()
22761 .unwrap_or_else(|poisoned| poisoned.into_inner())
22762 .records
22763 .keys()
22764 .map(|record_id| record_id.as_str().to_owned())
22765 .collect()
22766 }
22767
22768 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
22769 values.iter().map(|value| (*value).to_owned()).collect()
22770 }
22771
22772 #[tokio::test]
22773 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
22774 let (mut shared, runtime, root, _now) = retention_fixture_shared();
22775 shared.session_record_retention = SessionRecordRetentionConfig {
22776 age_ms: 0,
22777 keep_per_workspace: 1,
22778 };
22779 shared.retire_unavailable_session_records().await;
22780
22781 assert_eq!(
22782 retention_remaining_record_ids(&shared),
22783 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22784 );
22785 assert_eq!(
22786 retention_removed_record_ids(&shared),
22787 owned(&["a1", "a2", "b1", "b2"]),
22788 );
22789 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
22790
22791 drop(shared);
22792 drop(runtime);
22793 std::fs::remove_dir_all(root).unwrap();
22794 }
22795
22796 #[tokio::test]
22802 async fn session_record_retention_both_set_retires_only_the_intersection() {
22803 let (mut shared, runtime, root, _now) = retention_fixture_shared();
22804 shared.session_record_retention = SessionRecordRetentionConfig {
22805 age_ms: 250_000,
22806 keep_per_workspace: 1,
22807 };
22808 shared.retire_unavailable_session_records().await;
22809
22810 assert_eq!(
22811 retention_remaining_record_ids(&shared),
22812 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22813 );
22814 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
22815 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
22816
22817 drop(shared);
22818 drop(runtime);
22819 std::fs::remove_dir_all(root).unwrap();
22820 }
22821
22822 #[tokio::test]
22823 async fn session_record_retention_both_disabled_is_a_no_op() {
22824 let (mut shared, runtime, root, _now) = retention_fixture_shared();
22825 shared.session_record_retention = SessionRecordRetentionConfig::default();
22826 shared.retire_unavailable_session_records().await;
22827
22828 assert_eq!(
22829 retention_remaining_record_ids(&shared),
22830 owned(&[
22831 "a1", "a2", "a3", "b1", "b2", "b3",
22832 "decoy-live", "decoy-pending", "decoy-dormant",
22833 ]),
22834 );
22835 assert!(retention_removed_record_ids(&shared).is_empty());
22836 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
22837
22838 drop(shared);
22839 drop(runtime);
22840 std::fs::remove_dir_all(root).unwrap();
22841 }
22842
22843 #[test]
22844 fn session_record_context_export_selects_one_exact_history_identity() {
22845 let candidates = vec![
22846 HistoryCandidateSummary {
22847 id: "candidate-other".to_owned(),
22848 session_id_hint: "provider-other".to_owned(),
22849 modified_at_unix_ms: Some(1),
22850 },
22851 HistoryCandidateSummary {
22852 id: "candidate-exact".to_owned(),
22853 session_id_hint: "provider-exact".to_owned(),
22854 modified_at_unix_ms: Some(2),
22855 },
22856 ];
22857 assert_eq!(
22858 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
22859 "candidate-exact",
22860 );
22861 }
22862
22863 #[test]
22864 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
22865 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
22866 id: id.to_owned(),
22867 session_id_hint: hint.to_owned(),
22868 modified_at_unix_ms: None,
22869 };
22870 let missing = select_history_candidate(
22871 &[candidate("candidate-other", "provider-other")],
22872 Some("provider-exact"),
22873 )
22874 .unwrap_err();
22875 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
22876 let ambiguous = select_history_candidate(
22877 &[
22878 candidate("candidate-a", "provider-exact"),
22879 candidate("candidate-b", "provider-exact"),
22880 ],
22881 Some("provider-exact"),
22882 )
22883 .unwrap_err();
22884 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
22885 }
22886
22887 #[test]
22895 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
22896 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
22897 id: id.to_owned(),
22898 session_id_hint: hint.to_owned(),
22899 modified_at_unix_ms: None,
22900 };
22901 assert_eq!(
22902 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
22903 .unwrap(),
22904 "candidate-sole",
22905 );
22906 let missing = select_history_candidate(&[], None).unwrap_err();
22907 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
22908 let ambiguous = select_history_candidate(
22909 &[
22910 candidate("candidate-a", "sidecar-session-a"),
22911 candidate("candidate-b", "sidecar-session-b"),
22912 ],
22913 None,
22914 )
22915 .unwrap_err();
22916 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
22917 }
22918
22919 #[test]
22920 fn session_record_context_export_revalidation_rejects_record_change() {
22921 let session = terminal_address(1);
22922 let identity = ProviderSessionIdentity {
22923 key: ProviderSessionKey::SessionId,
22924 id: "provider-exact".to_owned(),
22925 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
22926 };
22927 let mut expected = record("claude", "record-context-export");
22928 expected.state = ManagedSessionState::Live;
22929 expected.provider_session = Some(identity.clone());
22930 expected.active_session = Some(session.clone());
22931 let binding = SessionBinding {
22932 workspace_id: session.workspace_id.clone(),
22933 generation: session.session.generation,
22934 runtime_policy: ProviderRuntimePolicy::raw_pty(),
22935 pending_resume: None,
22936 record_id: Some(expected.record_id.clone()),
22937 managed_worktree_lease_id: None,
22938 environment_profile: None,
22939 bundle: None,
22940 context: None,
22941 materialization_id: None,
22942 };
22943 let current_root = windows_path_text(&expected.canonical_root).to_owned();
22944 assert!(session_record_context_export_binding_is_exact(
22945 &expected,
22946 &expected,
22947 Some(&identity),
22948 &session,
22949 &binding,
22950 &expected.provider,
22951 ¤t_root,
22952 false,
22953 ));
22954 let mut changed = expected.clone();
22955 changed.updated_at_unix_ms += 1;
22956 assert!(!session_record_context_export_binding_is_exact(
22957 &expected,
22958 &changed,
22959 Some(&identity),
22960 &session,
22961 &binding,
22962 &expected.provider,
22963 ¤t_root,
22964 false,
22965 ));
22966 assert!(!session_record_context_export_binding_is_exact(
22971 &expected,
22972 &changed,
22973 Some(&identity),
22974 &session,
22975 &binding,
22976 &expected.provider,
22977 ¤t_root,
22978 true,
22979 ));
22980 let mut detached = expected.clone();
22981 detached.state = ManagedSessionState::Dormant;
22982 detached.active_session = None;
22983 detached.updated_at_unix_ms += 1;
22984 assert!(!session_record_context_export_binding_is_exact(
22985 &expected,
22986 &detached,
22987 Some(&identity),
22988 &session,
22989 &binding,
22990 &expected.provider,
22991 ¤t_root,
22992 false,
22993 ));
22994 assert!(session_record_context_export_binding_is_exact(
22995 &expected,
22996 &detached,
22997 Some(&identity),
22998 &session,
22999 &binding,
23000 &expected.provider,
23001 ¤t_root,
23002 true,
23003 ));
23004 }
23005
23006 #[test]
23013 fn session_record_context_export_binding_is_exact_without_identity() {
23014 let session = terminal_address(1);
23015 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23016 expected.state = ManagedSessionState::Live;
23017 expected.active_session = Some(session.clone());
23018 let binding = SessionBinding {
23019 workspace_id: session.workspace_id.clone(),
23020 generation: session.session.generation,
23021 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23022 pending_resume: None,
23023 record_id: Some(expected.record_id.clone()),
23024 managed_worktree_lease_id: None,
23025 environment_profile: None,
23026 bundle: None,
23027 context: None,
23028 materialization_id: None,
23029 };
23030 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23031 assert!(session_record_context_export_binding_is_exact(
23032 &expected,
23033 &expected,
23034 None,
23035 &session,
23036 &binding,
23037 &expected.provider,
23038 ¤t_root,
23039 false,
23040 ));
23041 let mut drifted = expected.clone();
23045 drifted.provider_session = Some(ProviderSessionIdentity {
23046 key: ProviderSessionKey::SessionId,
23047 id: "unexpected-identity".to_owned(),
23048 transcript_path: None,
23049 });
23050 assert!(!session_record_context_export_binding_is_exact(
23051 &expected,
23052 &drifted,
23053 None,
23054 &session,
23055 &binding,
23056 &expected.provider,
23057 ¤t_root,
23058 false,
23059 ));
23060 }
23061
23062 #[test]
23063 fn session_record_context_export_preflight_requires_live_usable_source() {
23064 let mut source = record("claude", "record-context-export-preflight");
23065 source.state = ManagedSessionState::Live;
23066 assert!(session_record_context_export_source_is_usable(
23067 &source,
23068 &SessionStatus::Running,
23069 false,
23070 ));
23071 assert!(session_record_context_export_source_is_usable(
23072 &source,
23073 &SessionStatus::Exited { exit_code: Some(0) },
23074 false,
23075 ));
23076
23077 source.state = ManagedSessionState::Dormant;
23078 assert!(!session_record_context_export_source_is_usable(
23079 &source,
23080 &SessionStatus::Running,
23081 false,
23082 ));
23083 assert!(session_record_context_export_source_is_usable(
23087 &source,
23088 &SessionStatus::Exited { exit_code: Some(0) },
23089 true,
23090 ));
23091 assert!(!session_record_context_export_source_is_usable(
23092 &source,
23093 &SessionStatus::Exited { exit_code: Some(0) },
23094 false,
23095 ));
23096 source.state = ManagedSessionState::Live;
23097 assert!(!session_record_context_export_source_is_usable(
23098 &source,
23099 &SessionStatus::Starting,
23100 false,
23101 ));
23102 }
23103
23104 #[test]
23105 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23106 let catalog = active_registry().unwrap();
23107 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23108 let workspace = WorkspaceConfig::new(
23109 WorkspaceId::new("primary").unwrap(),
23110 std::env::current_dir().unwrap(),
23111 )
23112 .unwrap();
23113 let shared = NodeShared::new(
23114 handle,
23115 "fixture-token".to_owned(),
23116 NodeId::new("node-context-export-conflict").unwrap(),
23117 vec![workspace],
23118 vec![agent("claude")],
23119 );
23120 let history = HistorySessionRecord {
23121 session_id: "provider-exact".to_owned(),
23122 title: None,
23123 cwd: None,
23124 model: None,
23125 message_count: 1,
23126 completed_turn_count: None,
23127 total_tokens: 0,
23128 messages: vec![gate4agent_types::HistoryMessageRecord {
23129 role: gate4agent_types::HistoryMessageRole::User,
23130 text: "bounded context".to_owned(),
23131 }],
23132 };
23133 let pack = NodeContextPack::export(
23134 ContextPackLineageReceipt {
23135 source_node_id: shared.node_id.clone(),
23136 source_session: terminal_address(1),
23137 source_provider: agent("claude"),
23138 },
23139 &history,
23140 )
23141 .unwrap();
23142 let context_id = pack.receipt().id.clone();
23143 let session = terminal_address(1);
23144 let identity = ProviderSessionIdentity {
23145 key: ProviderSessionKey::SessionId,
23146 id: history.session_id.clone(),
23147 transcript_path: None,
23148 };
23149 let mut expected = record("claude", "record-context-export-final-conflict");
23150 expected.state = ManagedSessionState::Live;
23151 expected.provider_session = Some(identity.clone());
23152 expected.active_session = Some(session.clone());
23153 let mut changed = expected.clone();
23154 changed.updated_at_unix_ms += 1;
23155 shared.insert_record(changed).unwrap();
23156
23157 let error = shared
23158 .commit_context_pack_for_session_record(
23159 &expected,
23160 Some(&identity),
23161 &session,
23162 "candidate-exact",
23163 &history,
23164 pack,
23165 false,
23166 )
23167 .unwrap_err();
23168
23169 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23170 assert!(shared
23171 .context_catalog
23172 .read()
23173 .unwrap_or_else(|poisoned| poisoned.into_inner())
23174 .get(&context_id)
23175 .is_none());
23176 }
23177
23178 #[tokio::test]
23179 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23180 let root = temporary_workspace_root("session-task-binding");
23181 std::fs::create_dir_all(&root).unwrap();
23182 let state_path = root.join("node-state.json");
23183 let catalog = active_registry().unwrap();
23184 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23185 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23186 let node_id = NodeId::new("node-1").unwrap();
23187 let mut shared = NodeShared::new(
23188 handle,
23189 "fixture-token".to_owned(),
23190 node_id.clone(),
23191 vec![workspace],
23192 vec![agent("claude")],
23193 );
23194 shared.state_path = Some(state_path.clone());
23195 let record = record("claude", "sr-task");
23196 let record_id = record.record_id.clone();
23197 shared.insert_record(record).unwrap();
23198 shared.persist_state().unwrap();
23199 shared
23200 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23201 .unwrap();
23202
23203 let minted = process_request_inner(
23204 &shared,
23205 77,
23206 ClientRole::Operator,
23207 NodeRequest::SetSessionTask {
23208 record_id: record_id.clone(),
23209 expected_revision: 0,
23210 target: SessionTaskTargetV1::New,
23211 },
23212 )
23213 .await
23214 .unwrap();
23215 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23216 panic!("session task mutation returned another response");
23217 };
23218 let minted_binding = minted.task_binding.clone().unwrap();
23219 let minted_task_id = minted_binding.task_id.clone().unwrap();
23220 assert_eq!(minted_binding.revision, 1);
23221 assert!(minted_task_id.as_str().starts_with("task-"));
23222 assert_eq!(minted_task_id.as_str().len(), 29);
23223
23224 let event_count = shared.current_sequence();
23225 let repeated = process_request_inner(
23226 &shared,
23227 77,
23228 ClientRole::Operator,
23229 NodeRequest::SetSessionTask {
23230 record_id: record_id.clone(),
23231 expected_revision: 1,
23232 target: SessionTaskTargetV1::Existing {
23233 task_id: minted_task_id.clone(),
23234 },
23235 },
23236 )
23237 .await
23238 .unwrap();
23239 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23240 assert_eq!(shared.current_sequence(), event_count);
23241
23242 let stale = process_request_inner(
23243 &shared,
23244 77,
23245 ClientRole::Operator,
23246 NodeRequest::SetSessionTask {
23247 record_id: record_id.clone(),
23248 expected_revision: 0,
23249 target: SessionTaskTargetV1::Existing {
23250 task_id: minted_task_id,
23251 },
23252 },
23253 )
23254 .await
23255 .unwrap_err();
23256 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23257
23258 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23259 let assigned = process_request_inner(
23260 &shared,
23261 77,
23262 ClientRole::Operator,
23263 NodeRequest::SetSessionTask {
23264 record_id: record_id.clone(),
23265 expected_revision: 1,
23266 target: SessionTaskTargetV1::Existing {
23267 task_id: assigned_id.clone(),
23268 },
23269 },
23270 )
23271 .await
23272 .unwrap();
23273 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23274 panic!("session task assignment returned another response");
23275 };
23276 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23277 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23278
23279 let cleared = process_request_inner(
23280 &shared,
23281 77,
23282 ClientRole::Operator,
23283 NodeRequest::SetSessionTask {
23284 record_id: record_id.clone(),
23285 expected_revision: 2,
23286 target: SessionTaskTargetV1::Clear,
23287 },
23288 )
23289 .await
23290 .unwrap();
23291 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23292 panic!("session task clear returned another response");
23293 };
23294 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23295 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23296 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23297 assert_eq!(
23298 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23299 cleared.updated_at_unix_ms,
23300 );
23301
23302 let persisted = std::fs::read(&state_path).unwrap();
23303 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23304 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23305 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23306 std::fs::remove_dir_all(root).unwrap();
23307 }
23308
23309 #[test]
23310 fn session_task_capability_strips_snapshots_events_and_replies() {
23311 let mut bound = record("claude", "sr-task-projection");
23312 bound.updated_at_unix_ms = 2;
23313 bound.task_binding = Some(SessionTaskBindingV1 {
23314 revision: 1,
23315 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23316 changed_at_unix_ms: 2,
23317 });
23318 let request = NodeRequest::SetSessionTask {
23319 record_id: bound.record_id.clone(),
23320 expected_revision: 1,
23321 target: SessionTaskTargetV1::Clear,
23322 };
23323 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23324 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23325 }));
23326 assert!(!request_uses_unnegotiated_capability(
23327 &request,
23328 &baseline_capabilities().unwrap(),
23329 ));
23330 assert!(request_uses_unnegotiated_capability(&request, &[]));
23331
23332 let mut snapshot = terminal_test_shared().snapshot();
23333 snapshot.session_records.push(bound.clone());
23334 let mut reply = ResponseEnvelope {
23335 request_id: 1,
23336 result: Ok(NodeResponse::Resync {
23337 event_sequence: 1,
23338 oldest_available_sequence: 1,
23339 snapshot,
23340 events: vec![NodeEventEnvelope {
23341 sequence: 1,
23342 event: NodeEvent::SessionRecordUpserted {
23343 record: bound.clone(),
23344 },
23345 }],
23346 }),
23347 };
23348 project_response_without_session_task_binding(&mut reply);
23349 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23350 panic!("task projection changed the response kind");
23351 };
23352 assert!(snapshot.session_records[0].task_binding.is_none());
23353 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
23354 panic!("task projection changed the event kind");
23355 };
23356 assert!(record.task_binding.is_none());
23357
23358 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
23359 sequence: 2,
23360 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
23361 });
23362 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
23363 panic!("task event projection changed the event kind");
23364 };
23365 assert!(record.task_binding.is_none());
23366
23367 let mut direct = ResponseEnvelope {
23368 request_id: 2,
23369 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
23370 };
23371 project_response_without_session_task_binding(&mut direct);
23372 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
23373 panic!("task reply projection changed the response kind");
23374 };
23375 assert!(record.task_binding.is_none());
23376 }
23377
23378 #[test]
23379 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
23380 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
23381 let workspace = WorkspaceConfig::new(
23382 WorkspaceId::new("primary").unwrap(),
23383 std::env::current_dir().unwrap(),
23384 )
23385 .unwrap();
23386 let config = NodeServerConfig::new(
23387 DEFAULT_NODE_ENDPOINT,
23388 "fixture-token",
23389 NodeId::new("node-1").unwrap(),
23390 [workspace],
23391 )
23392 .unwrap();
23393 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
23394 }
23395
23396 #[cfg(all(feature = "fixture", windows))]
23397 #[test]
23398 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
23399 let root = temporary_workspace_root("provider-bundle-argv-hold");
23400 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
23401 std::fs::create_dir_all(&root).unwrap();
23402 std::fs::create_dir_all(&outside).unwrap();
23403 let proof_path = root.join("provider.proof");
23404 let release_signal = root.join("release.signal");
23405 let config = NodeServerConfig::new(
23406 format!(
23407 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
23408 std::process::id(),
23409 ),
23410 "fixture-token",
23411 NodeId::new("provider-bundle-hold-node").unwrap(),
23412 [WorkspaceConfig::new(
23413 WorkspaceId::new("primary").unwrap(),
23414 std::env::current_dir().unwrap(),
23415 )
23416 .unwrap()],
23417 )
23418 .unwrap()
23419 .with_spawn_profiles(SpawnProfileRegistry::new([
23420 crate::protocol::SpawnProfileDefaults {
23421 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
23422 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
23423 .unwrap(),
23424 provider: agent("kimi"),
23425 mode: SessionMode::Pty,
23426 terminal_size: gate4agent_types::TerminalSize {
23427 rows: 24,
23428 columns: 80,
23429 },
23430 prompt: None,
23431 bundle_id: None,
23432 context_id: None,
23433 environment_profile_id: None,
23434 },
23435 ])
23436 .unwrap());
23437 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
23438 config,
23439 agent("kimi"),
23440 proof_path.clone(),
23441 release_signal.clone(),
23442 )
23443 .unwrap();
23444 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
23445
23446 let spec = NodeServer::provider_bundle_argv_fixture_spec(
23447 agent("kimi"),
23448 proof_path.clone(),
23449 Some(release_signal.clone()),
23450 )
23451 .unwrap();
23452 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
23453 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
23454 let release_wait = wrapper
23455 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
23456 .unwrap();
23457 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
23458 assert!(proof_write < release_wait);
23459 assert!(release_wait < fixture_ready);
23460 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
23461 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
23462 assert_eq!(
23463 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
23464 &[
23465 proof_path.to_string_lossy().into_owned(),
23466 release_signal.to_string_lossy().into_owned(),
23467 ],
23468 );
23469
23470 let error = NodeServer::provider_bundle_argv_fixture_spec(
23471 agent("kimi"),
23472 proof_path,
23473 Some(outside.join("release.signal")),
23474 )
23475 .unwrap_err();
23476 assert!(matches!(
23477 error,
23478 NodeServerError::Registry(message)
23479 if message == "provider bundle argv release path must share the proof parent"
23480 ));
23481 drop(server);
23482 std::fs::remove_dir_all(root).unwrap();
23483 std::fs::remove_dir_all(outside).unwrap();
23484 }
23485
23486 #[cfg(feature = "fixture")]
23487 #[test]
23488 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
23489 let root = temporary_workspace_root("context-fixture-registry");
23490 std::fs::create_dir_all(&root).unwrap();
23491 let proof_path = root.join("context.proof");
23492 let config = NodeServerConfig::new(
23493 format!(
23494 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
23495 std::process::id(),
23496 ),
23497 "fixture-token",
23498 NodeId::new("context-fixture-node").unwrap(),
23499 [WorkspaceConfig::new(
23500 WorkspaceId::new("primary").unwrap(),
23501 std::env::current_dir().unwrap(),
23502 )
23503 .unwrap()],
23504 )
23505 .unwrap();
23506 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
23507 let mut enabled = server
23508 .shared
23509 .enabled_providers
23510 .iter()
23511 .map(AgentId::as_str)
23512 .collect::<Vec<_>>();
23513 enabled.sort_unstable();
23514 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
23515 for provider in &server.shared.enabled_providers {
23516 assert!(server
23517 .shared
23518 .provider_adapter_contracts
23519 .iter()
23520 .any(|contract| {
23521 &contract.provider == provider
23522 && contract.family == AdapterFamily::History
23523 }));
23524 }
23525 drop(server);
23526 std::fs::remove_dir_all(root).unwrap();
23527 }
23528
23529 #[cfg(feature = "fixture")]
23530 #[test]
23531 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
23532 let root = temporary_workspace_root("monitoring-context-fixture");
23533 std::fs::create_dir_all(&root).unwrap();
23534 let proof_path = root.join("context.proof");
23535 let standard = NodeServer::context_pack_fixture_catalog(
23536 proof_path.clone(),
23537 None,
23538 false,
23539 ).unwrap();
23540 let monitoring = NodeServer::context_pack_fixture_catalog(
23541 proof_path.clone(),
23542 None,
23543 true,
23544 ).unwrap();
23545 let claude = AgentId::new("claude").unwrap();
23546 let standard_claude = standard.get(&claude).unwrap();
23547 let monitoring_claude = monitoring.get(&claude).unwrap();
23548 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
23552 assert_eq!(
23553 monitoring_claude.capabilities.adapters.history,
23554 standard_claude.capabilities.adapters.history,
23555 );
23556 assert_ne!(monitoring_claude.launch, standard_claude.launch);
23557 let codex = AgentId::new("codex").unwrap();
23558 let standard_codex = standard.get(&codex).unwrap();
23559 let monitoring_codex = monitoring.get(&codex).unwrap();
23560 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
23561 assert_eq!(
23562 monitoring_codex.capabilities.adapters.history,
23563 standard_codex.capabilities.adapters.history,
23564 );
23565 assert_ne!(monitoring_codex.launch, standard_codex.launch);
23566 let codex_validation = monitoring_codex.launch.fixed_args.iter()
23567 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
23568 .unwrap();
23569 for required in [
23570 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
23571 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
23572 "@('role', 'text')",
23573 "g4a-private-provider-session-canary",
23574 "private-provider-login-identity",
23575 "$rawProof",
23576 "$env:GATE4AGENT_HOOK_URL",
23577 "g4a-private-provider-session-canary",
23578 ] {
23579 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
23580 }
23581 for provider in ["grok", "kimi"] {
23582 let provider = AgentId::new(provider).unwrap();
23583 assert_eq!(monitoring.get(&provider), standard.get(&provider));
23584 }
23585
23586 let config = NodeServerConfig::new(
23587 format!(
23588 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
23589 std::process::id(),
23590 ),
23591 "fixture-token",
23592 NodeId::new("monitoring-context-fixture-node").unwrap(),
23593 [WorkspaceConfig::new(
23594 WorkspaceId::new("primary").unwrap(),
23595 std::env::current_dir().unwrap(),
23596 )
23597 .unwrap()],
23598 )
23599 .unwrap();
23600 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
23601 assert!(server.shared.fixture_semantic_hook_policy);
23602 drop(server);
23603 std::fs::remove_dir_all(root).unwrap();
23604 }
23605
23606 #[cfg(feature = "fixture")]
23607 #[test]
23608 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
23609 let root = temporary_workspace_root("kimi-context-only-fixture");
23610 std::fs::create_dir_all(&root).unwrap();
23611 let proof_path = root.join("context.proof");
23612 let standard = NodeServer::context_pack_fixture_catalog(
23613 proof_path.clone(),
23614 None,
23615 false,
23616 ).unwrap();
23617 let kimi = AgentId::new("kimi").unwrap();
23618 let context_only = NodeServer::context_pack_fixture_catalog(
23619 proof_path,
23620 Some(&kimi),
23621 false,
23622 )
23623 .unwrap();
23624
23625 let codex = AgentId::new("codex").unwrap();
23626 assert_eq!(
23627 standard.get(&codex).unwrap().launch.fixed_args,
23628 context_only.get(&codex).unwrap().launch.fixed_args,
23629 );
23630 let script = context_only
23631 .get(&kimi)
23632 .unwrap()
23633 .launch
23634 .fixed_args
23635 .iter()
23636 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
23637 .expect("Kimi context-only validation script is missing");
23638 for required in [
23639 "$bundleArgs.Count -ne 0",
23640 "GATE4AGENT_CONTEXT_ROOT",
23641 "context-pack.json",
23642 "$entries.Count -ne 1",
23643 "g4a-context-pack-v1",
23644 "$document.PSObject.Properties['cwd']",
23645 "'claude', 'codex', 'grok', 'kimi'",
23646 "$hasUser",
23647 "$hasAssistant",
23648 "SHA256",
23649 "context-only",
23650 ] {
23651 assert!(script.contains(required), "missing validation: {required}");
23652 }
23653 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
23654 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
23655 }
23656 assert!(!standard
23657 .get(&kimi)
23658 .unwrap()
23659 .launch
23660 .fixed_args
23661 .iter()
23662 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
23663
23664 std::fs::remove_dir_all(root).unwrap();
23665 }
23666
23667 #[cfg(all(feature = "fixture", windows))]
23668 #[tokio::test]
23669 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
23670 let root = temporary_workspace_root("kimi-context-only-spawn");
23671 let workspace_root = root.join("workspace");
23672 std::fs::create_dir_all(&workspace_root).unwrap();
23673 let materialization_root = root.join("private-materializations");
23674 let proof_path = root.join("context.proof");
23675 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
23676 let workspace_id = WorkspaceId::new("primary").unwrap();
23677 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
23678 let endpoint = format!(
23679 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
23680 std::process::id(),
23681 unix_time_ms(),
23682 );
23683 let spawn_profiles = SpawnProfileRegistry::new([
23684 crate::protocol::SpawnProfileDefaults {
23685 profile_id: profile_id.clone(),
23686 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
23687 provider: agent("kimi"),
23688 mode: SessionMode::Pty,
23689 terminal_size: gate4agent_types::TerminalSize {
23690 rows: 30,
23691 columns: 120,
23692 },
23693 prompt: None,
23694 bundle_id: None,
23695 context_id: None,
23696 environment_profile_id: None,
23697 },
23698 ])
23699 .unwrap();
23700 let mut config = NodeServerConfig::new(
23701 &endpoint,
23702 "fixture-token",
23703 node_id.clone(),
23704 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
23705 )
23706 .unwrap()
23707 .with_spawn_profiles(spawn_profiles)
23708 .with_session_environment_materialization(
23709 materialization_root,
23710 Arc::new(FixtureSecretResolver {
23711 deny: Arc::new(AtomicBool::new(false)),
23712 }),
23713 )
23714 .unwrap();
23715 config.fixture_raw_pty_runtime = true;
23716 let server = NodeServer::new_context_only_proof_fixture(
23717 config,
23718 agent("kimi"),
23719 proof_path.clone(),
23720 )
23721 .unwrap();
23722 let shared = Arc::clone(&server.shared);
23723 let shutdown = server.shutdown_handle();
23724
23725 let history = HistorySessionRecord {
23726 session_id: "context-only-source-history".to_owned(),
23727 title: None,
23728 cwd: None,
23729 model: None,
23730 message_count: 2,
23731 completed_turn_count: None,
23732 total_tokens: 0,
23733 messages: vec![
23734 gate4agent_types::HistoryMessageRecord {
23735 role: gate4agent_types::HistoryMessageRole::User,
23736 text: "continue the bounded parent run through native C2".to_owned(),
23737 },
23738 gate4agent_types::HistoryMessageRecord {
23739 role: gate4agent_types::HistoryMessageRole::Assistant,
23740 text: "the exact parent context is ready for the child run".to_owned(),
23741 },
23742 ],
23743 };
23744 let pack = NodeContextPack::export(
23745 ContextPackLineageReceipt {
23746 source_node_id: node_id.clone(),
23747 source_session: SessionAddress {
23748 workspace_id: workspace_id.clone(),
23749 session: SessionKey {
23750 instance_id: AgentInstanceId(71),
23751 generation: SessionGeneration(1),
23752 },
23753 },
23754 source_provider: agent("codex"),
23755 },
23756 &history,
23757 )
23758 .unwrap();
23759 let expected_context_hash = digest(&SHA256, pack.bytes())
23760 .as_ref()
23761 .iter()
23762 .map(|byte| format!("{byte:02x}"))
23763 .collect::<String>();
23764 let context = shared
23765 .context_catalog
23766 .write()
23767 .unwrap_or_else(|poisoned| poisoned.into_inner())
23768 .insert(pack)
23769 .unwrap();
23770 let spec = SpawnSpec {
23771 target: crate::protocol::SpawnTarget {
23772 node_id,
23773 workspace_id: workspace_id.clone(),
23774 worktree_id: None,
23775 },
23776 profile_id,
23777 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
23778 "target-kimi-r1",
23779 ).unwrap(),
23780 overrides: crate::protocol::SpawnOverrides {
23781 context_id: crate::protocol::SpawnOverride::Set {
23782 value: context.id.clone(),
23783 },
23784 ..crate::protocol::SpawnOverrides::default()
23785 },
23786 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
23787 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
23788 required_capabilities: SpawnRequiredCapabilities::default(),
23789 };
23790 let resolved = match shared.resolve_spawn_spec(&spec) {
23791 Ok(resolved) => resolved,
23792 Err(error) => panic!(
23793 "Kimi context-only resolve rejected: exact_code={:?} message={}",
23794 error.code,
23795 error.message,
23796 ),
23797 };
23798 let resolved_context = match shared.resolve_context(&resolved) {
23799 Ok(Some(resolved_context)) => resolved_context,
23800 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
23801 Err(error) => panic!(
23802 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
23803 error.code,
23804 error.message,
23805 ),
23806 };
23807 assert_eq!(resolved_context, context);
23808 let runtime_policy = match shared
23809 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
23810 .await
23811 {
23812 Ok(runtime_policy) => runtime_policy,
23813 Err(error) => panic!(
23814 "Kimi RawPty admission rejected: exact_code={:?} message={}",
23815 error.code,
23816 error.message,
23817 ),
23818 };
23819 assert!(runtime_policy.raw_pty_lifecycle);
23820
23821 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
23822 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
23823 agent("kimi"),
23824 TransportKind::Pty,
23825 vec![EnvMutation {
23826 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
23827 value: Some(OsString::from("must-remain-rejected")),
23828 }],
23829 )
23830 .unwrap();
23831 assert_eq!(
23832 launch_control
23833 .install_native_launch_environment_overlay(
23834 AgentInstanceId(72),
23835 generic_overlay,
23836 )
23837 .unwrap_err(),
23838 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
23839 );
23840
23841 let diagnostic_address = SessionAddress {
23842 workspace_id: workspace_id.clone(),
23843 session: SessionKey {
23844 instance_id: AgentInstanceId(73),
23845 generation: SessionGeneration(1),
23846 },
23847 };
23848 let (diagnostic_overlay, diagnostic_materialization) = match shared
23849 .prepare_session_materialization(
23850 &diagnostic_address,
23851 &agent("kimi"),
23852 SessionMode::Pty,
23853 None,
23854 None,
23855 Some(&resolved_context),
23856 None,
23857 )
23858 {
23859 Ok(Some(prepared)) => prepared,
23860 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
23861 Err(error) => panic!(
23862 "Kimi context-only materialization rejected: exact_code={:?} message={}",
23863 error.code,
23864 error.message,
23865 ),
23866 };
23867 let diagnostic_overlay = diagnostic_overlay
23868 .expect("Kimi context-only materialization omitted its environment overlay");
23869 assert!(matches!(
23870 &diagnostic_overlay,
23871 PreparedNativeLaunchOverlay::Environment(_),
23872 ));
23873 match shared.install_prepared_launch_overlay(
23874 diagnostic_address.session.instance_id,
23875 diagnostic_overlay,
23876 ) {
23877 Ok(None) => {}
23878 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
23879 Err(error) => panic!(
23880 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
23881 error.code,
23882 error.message,
23883 ),
23884 }
23885 assert!(launch_control.clear_native_instance_launch_overlay(
23886 diagnostic_address.session.instance_id,
23887 ));
23888 drop(diagnostic_materialization);
23889 assert!(shared
23890 .materializations
23891 .lock()
23892 .unwrap_or_else(|poisoned| poisoned.into_inner())
23893 .is_empty());
23894
23895 let node_task = tokio::spawn(server.run());
23896 let ready_client = timeout(Duration::from_secs(5), async {
23897 loop {
23898 match gate4agent_node_wire::LocalNodeClient::connect(
23899 &endpoint,
23900 &shared.node_id,
23901 ClientRole::Observer,
23902 "fixture-token",
23903 )
23904 .await
23905 {
23906 Ok(client) => break client,
23907 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
23908 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
23909 }
23910 }
23911 })
23912 .await
23913 .expect("Kimi context-only Node did not become ready");
23914 assert_eq!(
23915 ready_client
23916 .hello()
23917 .snapshot
23918 .provider_runtime_statuses
23919 .iter()
23920 .find(|status| status.provider().as_str() == "kimi")
23921 .expect("Kimi runtime status is missing")
23922 .mode(),
23923 crate::protocol::ProviderRuntimeMode::RawPassthrough,
23924 );
23925 drop(ready_client);
23926
23927 let receipt = match shared.spawn_from_spec(spec).await {
23928 Ok(receipt) => receipt,
23929 Err(error) => panic!(
23930 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
23931 error.code,
23932 error.message,
23933 ),
23934 };
23935 assert_eq!(receipt.provider, agent("kimi"));
23936 assert_eq!(receipt.context.as_ref(), Some(&context));
23937 assert!(receipt.context_binding_is_valid());
23938 timeout(Duration::from_secs(5), async {
23939 while !proof_path.is_file() {
23940 sleep(Duration::from_millis(10)).await;
23941 }
23942 })
23943 .await
23944 .expect("Kimi context-only fixture did not write its proof");
23945
23946 let proof = std::fs::read_to_string(&proof_path).unwrap();
23947 let lines = proof.lines().collect::<Vec<_>>();
23948 assert_eq!(lines.len(), 7);
23949 assert_eq!(lines[0], "context-only");
23950 assert!(Path::new(lines[1]).is_absolute());
23951 assert_ne!(
23952 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
23953 std::fs::canonicalize(&workspace_root).unwrap(),
23954 );
23955 assert_eq!(
23956 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
23957 std::fs::canonicalize(&workspace_root).unwrap(),
23958 );
23959 assert_eq!(lines[3], expected_context_hash);
23960 assert_eq!(lines[4], "g4a-context-pack-v1");
23961 assert_eq!(lines[5], "codex");
23962 assert_eq!(lines[6], "2");
23963 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
23964 session.instance_id == receipt.session.session.instance_id
23965 && session.agent_id.as_str() == "kimi"
23966 }));
23967
23968 shutdown.request_shutdown().await.unwrap();
23969 timeout(Duration::from_secs(5), node_task)
23970 .await
23971 .unwrap()
23972 .unwrap()
23973 .unwrap();
23974 drop(shutdown);
23975 drop(shared);
23976 std::fs::remove_dir_all(root).unwrap();
23977 }
23978
23979 #[cfg(feature = "fixture")]
23980 #[test]
23981 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
23982 let root = temporary_workspace_root("clean-exit-constructor");
23983 std::fs::create_dir_all(&root).unwrap();
23984 let config = construction_test_config("clean-exit-constructor")
23985 .with_state_path(root.join("node-state.json"))
23986 .unwrap();
23987 let server = NodeServer::new_clean_exit_fixture(
23988 config,
23989 root.clone(),
23990 root.join("started.marker"),
23991 root.join("release.signal"),
23992 )
23993 .unwrap();
23994 assert_eq!(
23995 server
23996 .shared
23997 .enabled_providers
23998 .iter()
23999 .map(AgentId::as_str)
24000 .collect::<Vec<_>>(),
24001 ["claude"],
24002 );
24003 drop(server);
24004 std::fs::remove_dir_all(root).unwrap();
24005 }
24006
24007 #[test]
24008 fn production_registry_provider_contract_manifest_is_exact() {
24009 let registry = active_registry().unwrap();
24010 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24011 assert_eq!(
24012 providers
24013 .iter()
24014 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24015 .collect::<Vec<_>>(),
24016 vec![
24017 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24018 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24019 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24020 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24021 ]
24022 );
24023 assert_eq!(
24024 adapters
24025 .iter()
24026 .map(|contract| (
24027 contract.provider.as_str(),
24028 contract.family,
24029 contract.adapter_id.as_str(),
24030 contract.revision.as_str(),
24031 ))
24032 .collect::<Vec<_>>(),
24033 vec![
24034 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24035 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24036 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24037 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24038 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24039 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24040 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24041 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24042 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24043 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24044 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24045 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24046 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24047 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24048 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24049 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24050 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24051 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24052 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24053 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24054 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24055 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24056 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24057 ]
24058 );
24059 }
24060
24061 fn construction_test_config(name: &str) -> NodeServerConfig {
24062 let workspace = WorkspaceConfig::new(
24063 WorkspaceId::new("primary").unwrap(),
24064 std::env::current_dir().unwrap(),
24065 )
24066 .unwrap();
24067 NodeServerConfig::new(
24068 format!(r"\\.\pipe\gate4agent-{name}"),
24069 "fixture-token",
24070 NodeId::new(name).unwrap(),
24071 [workspace],
24072 )
24073 .unwrap()
24074 }
24075
24076 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24077 let adapter = AdapterBinding::new(
24078 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24079 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24080 gate4agent_types::AdapterVerification::SyntheticFixture,
24081 )
24082 .unwrap();
24083 let agent_ids = ["claude", "codex", "kimi"]
24084 .into_iter()
24085 .map(|agent_id| AgentId::new(agent_id).unwrap())
24086 .collect::<Vec<_>>();
24087 let adapters = gate4agent_catalog::AdapterRegistry::new(
24088 [
24089 AdapterFamily::PtySemantic,
24090 AdapterFamily::Pipe,
24091 AdapterFamily::OneShot,
24092 AdapterFamily::Acp,
24093 AdapterFamily::Hook,
24094 AdapterFamily::ManagedHook,
24095 AdapterFamily::History,
24096 AdapterFamily::Resume,
24097 AdapterFamily::SessionOptions,
24098 AdapterFamily::CapabilityProbe,
24099 ]
24100 .into_iter()
24101 .map(|family| gate4agent_catalog::AdapterDescriptor {
24102 family,
24103 binding: adapter.clone(),
24104 agents: agent_ids.clone(),
24105 }),
24106 )
24107 .unwrap();
24108 let specs = agent_ids
24109 .iter()
24110 .map(|agent_id| {
24111 let agent_id = agent_id.as_str();
24112 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24113 spec.revision =
24114 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24115 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24116 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24117 adapter: adapter.clone(),
24118 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24119 launch_override: None,
24120 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24121 });
24122 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24123 adapter: adapter.clone(),
24124 launch_override: None,
24125 });
24126 spec.capabilities.adapters.hook = Some(adapter.clone());
24127 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24128 spec.capabilities.adapters.one_shot = Some(adapter.clone());
24129 spec.capabilities.adapters.history = Some(adapter.clone());
24130 spec.capabilities.adapters.resume = Some(adapter.clone());
24131 spec.capabilities.adapters.session_options = Some(adapter.clone());
24132 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24133 spec
24134 })
24135 .collect::<Vec<_>>();
24136 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24137 }
24138
24139 #[test]
24147 fn node_server_construction_accepts_the_production_manifest() {
24148 let registry = active_registry().unwrap();
24149 let (providers, _) = provider_contract_manifest(®istry).unwrap();
24150 assert_eq!(providers.len(), registry.iter().count());
24151 assert!(!providers.is_empty());
24152 let server = NodeServer::new_with_registry(
24153 construction_test_config("manifest-production-bound"),
24154 registry,
24155 )
24156 .unwrap();
24157 drop(server);
24158 }
24159
24160 #[test]
24161 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24162 let registry = maximum_size_provider_manifest_registry();
24163 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24164 assert_eq!(providers.len(), 3);
24165 assert_eq!(adapters.len(), 30);
24166 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24167 let error = match NodeServer::new_with_registry(
24168 construction_test_config("manifest-handshake-overflow"),
24169 registry,
24170 ) {
24171 Ok(_) => panic!("oversized negotiated manifest was accepted"),
24172 Err(error) => error,
24173 };
24174 assert!(matches!(
24175 error,
24176 NodeServerError::ProviderContractManifest(message)
24177 if message.contains("authentication binding length")
24178 && message.contains("8192-byte limit")
24179 ));
24180 }
24181
24182 #[test]
24183 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24184 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24185 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24186 let registry = AgentRegistry::new([spec]).unwrap();
24187 assert!(matches!(
24188 provider_contract_manifest(®istry),
24189 Err(NodeServerError::ProviderContractManifest(message))
24190 if message.contains("exceeds the 128-byte limit")
24191 ));
24192 }
24193
24194 #[test]
24195 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24196 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24197 spec.revision = "invalid revision".to_owned();
24198 let registry = AgentRegistry::new([spec]).unwrap();
24199 assert!(matches!(
24200 provider_contract_manifest(®istry),
24201 Err(NodeServerError::ProviderContractManifest(message))
24202 if message.contains("bounded lowercase ASCII")
24203 ));
24204 }
24205
24206 #[test]
24207 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24208 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24209 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24210 let conflicting_binding = builtin_registry()
24211 .get_by_id("codex")
24212 .unwrap()
24213 .capabilities
24214 .adapters
24215 .one_shot
24216 .clone()
24217 .unwrap();
24218 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24219 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24220 pipe.adapter = transport_binding;
24221 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24222 let registry = AgentRegistry::new([spec]).unwrap();
24223 assert!(matches!(
24224 provider_contract_manifest(®istry),
24225 Err(NodeServerError::ProviderContractManifest(message))
24226 if message.contains("conflicting OneShot adapter bindings")
24227 ));
24228 }
24229
24230 #[test]
24231 fn public_node_failures_expose_only_fixed_categories() {
24232 let provider = failure(
24233 NodeFailureCode::BackendOperationFailed,
24234 r"provider bearer-secret failed at C:\private\session.jsonl",
24235 );
24236 assert_eq!(provider.message, "backend-operation-failed");
24237 assert!(!provider.message.contains("bearer-secret"));
24238 assert!(!provider.message.contains(r"C:\private"));
24239 let persistence = persistence_failure(io::Error::new(
24240 io::ErrorKind::Other,
24241 r"state write failed at C:\private\state-v1.json",
24242 ));
24243 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24244 assert!(!persistence.message.contains(r"C:\private"));
24245
24246 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24247 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24248 assert_eq!(
24249 generic.to_string(),
24250 "node durable state failed: durable-state-load-failed",
24251 );
24252 }
24253 }
24254
24255 #[test]
24256 fn terminal_byte_sequences_are_bounded_before_dispatch() {
24257 let sequence = b"\x1b[1;5D".to_vec();
24258 assert_eq!(
24259 terminal_bytes_action(sequence.clone()).unwrap(),
24260 InputAction::TerminalBytes(sequence),
24261 );
24262 assert_eq!(
24263 terminal_bytes_action(Vec::new()).unwrap_err().code,
24264 NodeFailureCode::InvalidRequest,
24265 );
24266 assert_eq!(
24267 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24268 .unwrap_err()
24269 .code,
24270 NodeFailureCode::InvalidRequest,
24271 );
24272 }
24273
24274 #[test]
24275 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24276 let root = temporary_workspace_root("exclusive-state-owner");
24277 std::fs::create_dir_all(&root).unwrap();
24278 let workspace = WorkspaceConfig::new(
24279 WorkspaceId::new("primary").unwrap(),
24280 &root,
24281 )
24282 .unwrap();
24283 let state_path = root.join("state-v1.json");
24284 let endpoint = format!(
24285 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24286 std::process::id(),
24287 std::time::SystemTime::now()
24288 .duration_since(std::time::UNIX_EPOCH)
24289 .unwrap()
24290 .as_nanos(),
24291 );
24292 let config = NodeServerConfig::new(
24293 endpoint,
24294 "fixture-token",
24295 NodeId::new("node-1").unwrap(),
24296 [workspace],
24297 )
24298 .unwrap()
24299 .with_state_path(&state_path)
24300 .unwrap();
24301
24302 let first = NodeServer::new(config.clone()).unwrap();
24303 let error = match NodeServer::new(config.clone()) {
24304 Ok(_) => panic!("a second node server acquired the same durable state path"),
24305 Err(error) => error,
24306 };
24307 assert!(matches!(
24308 &error,
24309 NodeServerError::DurableState(error)
24310 if error.kind() == io::ErrorKind::WouldBlock
24311 ));
24312 assert_eq!(
24313 error.to_string(),
24314 "node durable state failed: durable-state-lock-failed",
24315 );
24316 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24317 drop(first);
24318 let second = NodeServer::new(config).unwrap();
24319 drop(second);
24320 std::fs::remove_dir_all(root).unwrap();
24321 }
24322
24323 #[test]
24324 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24325 let catalog = active_registry().unwrap();
24326 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24327 let workspace_id = WorkspaceId::new("primary").unwrap();
24328 let workspace = WorkspaceConfig::new(
24329 workspace_id.clone(),
24330 std::env::current_dir().unwrap(),
24331 )
24332 .unwrap();
24333 let shared = NodeShared::new(
24334 handle,
24335 "fixture-token".to_owned(),
24336 NodeId::new("node-1").unwrap(),
24337 vec![workspace.clone()],
24338 vec![agent("claude"), agent("codex"), agent("kimi")],
24339 );
24340 let instance_id = AgentInstanceId(17);
24341 let generation = SessionGeneration(3);
24342 assert!(shared.address_for(instance_id, generation).is_none());
24343 shared.bind_session(instance_id, workspace_id.clone(), generation);
24344 let address = SessionAddress {
24345 workspace_id,
24346 session: SessionKey { instance_id, generation },
24347 };
24348 assert_eq!(
24349 shared.workspace_root(&address.workspace_id).unwrap(),
24350 workspace.canonical_root,
24351 );
24352 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
24353 shared.remove_binding(&address);
24354 assert!(shared.address_for(instance_id, generation).is_none());
24355 }
24356
24357 #[test]
24358 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
24359 let address = SessionAddress {
24360 workspace_id: WorkspaceId::new("primary").unwrap(),
24361 session: SessionKey {
24362 instance_id: AgentInstanceId(7),
24363 generation: SessionGeneration(2),
24364 },
24365 };
24366 let record_id = SessionRecordId::new("record-a").unwrap();
24367 let size = gate4agent_types::TerminalSize {
24368 rows: 24,
24369 columns: 80,
24370 };
24371 let address_requests = [
24372 NodeRequest::Resume {
24373 session: address.clone(),
24374 terminal_size: size,
24375 initial_prompt: None,
24376 },
24377 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
24378 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
24379 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
24380 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
24381 NodeRequest::TerminalControl {
24382 session: address.clone(),
24383 control: TerminalControl::Enter,
24384 },
24385 NodeRequest::Resize { session: address.clone(), size },
24386 NodeRequest::Interrupt { session: address.clone() },
24387 NodeRequest::Stop { session: address.clone(), force: false },
24388 NodeRequest::Remove { session: address },
24389 ];
24390 for request in &address_requests {
24391 assert!(request_requires_open_provider_ids_with(
24392 request,
24393 |_| Some(agent("third-party-agent")),
24394 |_| Some(agent("claude")),
24395 ));
24396 assert!(!request_requires_open_provider_ids_with(
24397 request,
24398 |_| Some(agent("claude")),
24399 |_| Some(agent("claude")),
24400 ));
24401 assert!(request_requires_open_provider_ids_with(
24402 request,
24403 |_| None,
24404 |_| Some(agent("claude")),
24405 ));
24406 }
24407
24408 let record_requests = [
24409 NodeRequest::RenameSessionRecord {
24410 record_id: record_id.clone(),
24411 display_name: "renamed".to_owned(),
24412 },
24413 NodeRequest::ResumeSessionRecord {
24414 record_id: record_id.clone(),
24415 terminal_size: size,
24416 initial_prompt: None,
24417 },
24418 NodeRequest::ForgetSessionRecord { record_id },
24419 ];
24420 for request in &record_requests {
24421 assert!(request_requires_open_provider_ids_with(
24422 request,
24423 |_| Some(agent("claude")),
24424 |_| Some(agent("grok")),
24425 ));
24426 assert!(!request_requires_open_provider_ids_with(
24427 request,
24428 |_| Some(agent("claude")),
24429 |_| Some(agent("kimi")),
24430 ));
24431 assert!(request_requires_open_provider_ids_with(
24432 request,
24433 |_| Some(agent("claude")),
24434 |_| None,
24435 ));
24436 }
24437
24438 assert!(request_requires_open_provider_ids_with(
24439 &NodeRequest::Spawn {
24440 workspace_id: WorkspaceId::new("primary").unwrap(),
24441 provider: agent("third-party-agent"),
24442 mode: SessionMode::Pty,
24443 terminal_size: size,
24444 initial_prompt: None,
24445 },
24446 |_| None,
24447 |_| None,
24448 ));
24449 assert!(!request_requires_open_provider_ids_with(
24450 &NodeRequest::Snapshot,
24451 |_| None,
24452 |_| None,
24453 ));
24454 assert!(request_requires_open_provider_ids_with(
24455 &NodeRequest::ForgetContextPack {
24456 context_id: SpawnContextId::new("ctx-forget").unwrap(),
24457 },
24458 |_| None,
24459 |_| None,
24460 ));
24461 }
24462
24463 #[test]
24464 fn legacy_projection_filters_open_record_replies_and_event_history() {
24465 let catalog = active_registry().unwrap();
24466 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24467 let workspace = WorkspaceConfig::new(
24468 WorkspaceId::new("primary").unwrap(),
24469 std::env::current_dir().unwrap(),
24470 )
24471 .unwrap();
24472 let shared = NodeShared::new(
24473 handle,
24474 "fixture-token".to_owned(),
24475 NodeId::new("node-1").unwrap(),
24476 vec![workspace],
24477 vec![agent("claude"), agent("grok")],
24478 );
24479 let legacy = record("claude", "legacy-record");
24480 let open = record("grok", "open-record");
24481 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24482 record: legacy.clone(),
24483 });
24484 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24485 record: open.clone(),
24486 });
24487 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24488 record_id: legacy.record_id.clone(),
24489 });
24490 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24491 record_id: open.record_id.clone(),
24492 });
24493 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
24494 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
24495 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
24496 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
24497
24498 let mut record_reply = ResponseEnvelope {
24499 request_id: 1,
24500 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
24501 };
24502 project_response_legacy_provider_ids(&shared, &mut record_reply);
24503 assert!(matches!(
24504 record_reply.result,
24505 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
24506 ));
24507
24508 let mut resync = ResponseEnvelope {
24509 request_id: 2,
24510 result: Ok(NodeResponse::Resync {
24511 event_sequence: open_removed.sequence,
24512 oldest_available_sequence: 1,
24513 snapshot: shared.snapshot(),
24514 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
24515 }),
24516 };
24517 project_response_legacy_provider_ids(&shared, &mut resync);
24518 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
24519 panic!("legacy projection changed the resync response kind");
24520 };
24521 assert!(snapshot.session_records.iter().all(|item| {
24522 provider_id_is_legacy(&item.provider)
24523 }));
24524 assert_eq!(events.len(), 2);
24525 assert!(events.iter().all(|event| matches!(
24526 &event.event,
24527 NodeEvent::SessionRecordUpserted { record }
24528 if provider_id_is_legacy(&record.provider)
24529 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
24530 }
24531
24532 #[test]
24533 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
24534 let catalog = active_registry().unwrap();
24535 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24536 let workspace_id = WorkspaceId::new("primary").unwrap();
24537 let workspace = WorkspaceConfig::new(
24538 workspace_id.clone(),
24539 std::env::current_dir().unwrap(),
24540 )
24541 .unwrap();
24542 let shared = NodeShared::new(
24543 handle,
24544 "fixture-token".to_owned(),
24545 NodeId::new("node-1").unwrap(),
24546 vec![workspace],
24547 vec![agent("claude")],
24548 );
24549 for index in 0..CONTROL_SESSIONS_MAX {
24550 shared.bind_session(
24551 AgentInstanceId((index + 1) as u64),
24552 workspace_id.clone(),
24553 SessionGeneration(1),
24554 );
24555 }
24556 let error = shared.ensure_binding_capacity().unwrap_err();
24557 assert_eq!(error.code, NodeFailureCode::BackendBusy);
24558 }
24559
24560 #[tokio::test]
24561 async fn provider_spawn_admission_rejects_before_session_mutation() {
24562 let catalog = active_registry().unwrap();
24563 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24564 let workspace = WorkspaceConfig::new(
24565 WorkspaceId::new("primary").unwrap(),
24566 std::env::current_dir().unwrap(),
24567 )
24568 .unwrap();
24569 let mut shared = NodeShared::new(
24570 handle,
24571 "fixture-token".to_owned(),
24572 NodeId::new("node-provider-admission").unwrap(),
24573 vec![workspace],
24574 vec![agent("claude")],
24575 );
24576 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24577 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
24578 ])
24579 .unwrap();
24580 let terminal_size = gate4agent_types::TerminalSize {
24581 rows: 24,
24582 columns: 80,
24583 };
24584
24585 let unavailable = shared
24586 .spawn_session(
24587 WorkspaceId::new("primary").unwrap(),
24588 agent("claude"),
24589 SessionMode::Pty,
24590 terminal_size,
24591 None,
24592 )
24593 .await
24594 .unwrap_err();
24595 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
24596 assert_eq!(unavailable.message, "backend-operation-failed");
24597
24598 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24599 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
24600 agent("claude"),
24601 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
24602 ),
24603 ])
24604 .unwrap();
24605 let semantic = shared
24606 .spawn_session(
24607 WorkspaceId::new("primary").unwrap(),
24608 agent("claude"),
24609 SessionMode::Inline,
24610 terminal_size,
24611 None,
24612 )
24613 .await
24614 .unwrap_err();
24615 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
24616 assert_eq!(semantic.message, "unsupported-capability");
24617
24618 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
24619 assert!(shared
24620 .session_bindings
24621 .lock()
24622 .unwrap_or_else(|poisoned| poisoned.into_inner())
24623 .is_empty());
24624 assert!(shared
24625 .session_records
24626 .lock()
24627 .unwrap_or_else(|poisoned| poisoned.into_inner())
24628 .records
24629 .is_empty());
24630 assert!(shared.handle.snapshot().sessions.is_empty());
24631 }
24632
24633 #[tokio::test]
24634 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
24635 let catalog = active_registry().unwrap();
24636 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24637 let workspace_id = WorkspaceId::new("primary").unwrap();
24638 let workspace = WorkspaceConfig::new(
24639 workspace_id.clone(),
24640 std::env::current_dir().unwrap(),
24641 )
24642 .unwrap();
24643 let shared = NodeShared::new(
24644 handle,
24645 "fixture-token".to_owned(),
24646 NodeId::new("node-raw-spawn-record").unwrap(),
24647 vec![workspace],
24648 vec![agent("claude")],
24649 );
24650 let address = SessionAddress {
24651 workspace_id,
24652 session: SessionKey {
24653 instance_id: AgentInstanceId(71),
24654 generation: SessionGeneration::default(),
24655 },
24656 };
24657
24658 let record_id = shared
24659 .bind_spawn_session(
24660 &address,
24661 agent("claude"),
24662 SessionMode::Pty,
24663 ProviderRuntimePolicy::raw_pty(),
24664 SpawnRecordPolicy::Always,
24665 None,
24666 )
24667 .unwrap()
24668 .unwrap();
24669 shared
24670 .handle
24671 .dispatch(shared.prepare_command(ControlCommand::Register {
24672 instance_id: address.session.instance_id,
24673 agent_id: agent("claude"),
24674 transport: TransportKind::Pty,
24675 }))
24676 .unwrap();
24677 runtime.tick().await;
24678
24679 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24680 session.instance_id == address.session.instance_id
24681 && session.generation == address.session.generation
24682 }));
24683 let record = shared.record(&record_id).unwrap();
24684 assert_eq!(record.display_name, "claude #1");
24685 assert_eq!(record.state, ManagedSessionState::Live);
24686 assert_eq!(record.active_session.as_ref(), Some(&address));
24687 assert!(record.provider_session.is_none());
24688 let binding_record_id = shared
24689 .session_bindings
24690 .lock()
24691 .unwrap_or_else(|poisoned| poisoned.into_inner())
24692 .get(&address.session.instance_id)
24693 .and_then(|binding| binding.record_id.clone());
24694 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
24695
24696 let renamed = shared
24697 .rename_session_record(&record_id, "raw review".to_owned())
24698 .unwrap();
24699 assert_eq!(renamed.record_id, record_id);
24700 assert_eq!(renamed.display_name, "raw review");
24701 shared.reconcile_managed_record(
24702 &record_id,
24703 &address,
24704 &ControlEvent {
24705 sequence: 1,
24706 command_id: None,
24707 instance_id: address.session.instance_id,
24708 generation: address.session.generation,
24709 event: ControlEventKind::Running { process_id: None },
24710 },
24711 false,
24712 false,
24713 );
24714 let running = shared.record(&record_id).unwrap();
24715 assert_eq!(running.state, ManagedSessionState::Live);
24716 assert_eq!(running.active_session.as_ref(), Some(&address));
24717 assert_eq!(running.display_name, "raw review");
24718
24719 shared.reconcile_managed_record(
24720 &record_id,
24721 &address,
24722 &ControlEvent {
24723 sequence: 2,
24724 command_id: None,
24725 instance_id: address.session.instance_id,
24726 generation: address.session.generation,
24727 event: ControlEventKind::Exited {
24728 exit_code: Some(0),
24729 forced: false,
24730 },
24731 },
24732 false,
24733 false,
24734 );
24735 let detached = shared.record(&record_id).unwrap();
24736 assert_eq!(detached.state, ManagedSessionState::Unavailable);
24737 assert!(detached.active_session.is_none());
24738 assert_eq!(detached.display_name, "raw review");
24739 let renamed_detached = shared
24740 .rename_session_record(&record_id, "raw review detached".to_owned())
24741 .unwrap();
24742 assert_eq!(renamed_detached.record_id, record_id);
24743 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
24744 assert!(renamed_detached.active_session.is_none());
24745 assert_eq!(renamed_detached.display_name, "raw review detached");
24746 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
24747 }
24748
24749 #[test]
24750 fn legacy_raw_spawn_binding_remains_untracked() {
24751 let catalog = active_registry().unwrap();
24752 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24753 let workspace_id = WorkspaceId::new("primary").unwrap();
24754 let workspace = WorkspaceConfig::new(
24755 workspace_id.clone(),
24756 std::env::current_dir().unwrap(),
24757 )
24758 .unwrap();
24759 let shared = NodeShared::new(
24760 handle,
24761 "fixture-token".to_owned(),
24762 NodeId::new("node-legacy-raw-spawn").unwrap(),
24763 vec![workspace],
24764 vec![agent("claude")],
24765 );
24766 let address = SessionAddress {
24767 workspace_id,
24768 session: SessionKey {
24769 instance_id: AgentInstanceId(72),
24770 generation: SessionGeneration::default(),
24771 },
24772 };
24773
24774 let record_id = shared
24775 .bind_spawn_session(
24776 &address,
24777 agent("claude"),
24778 SessionMode::Pty,
24779 ProviderRuntimePolicy::raw_pty(),
24780 SpawnRecordPolicy::ProviderIdentityOnly,
24781 None,
24782 )
24783 .unwrap();
24784
24785 assert!(record_id.is_none());
24786 let binding_record_id = shared
24787 .session_bindings
24788 .lock()
24789 .unwrap_or_else(|poisoned| poisoned.into_inner())
24790 .get(&address.session.instance_id)
24791 .and_then(|binding| binding.record_id.clone());
24792 assert!(binding_record_id.is_none());
24793 assert!(shared.snapshot().session_records.is_empty());
24794 }
24795
24796 #[tokio::test]
24797 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
24798 let catalog = active_registry().unwrap();
24799 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24800 let workspace_id = WorkspaceId::new("primary").unwrap();
24801 let workspace = WorkspaceConfig::new(
24802 workspace_id.clone(),
24803 std::env::current_dir().unwrap(),
24804 )
24805 .unwrap();
24806 let mut shared = NodeShared::new(
24807 handle,
24808 "fixture-token".to_owned(),
24809 NodeId::new("node-raw-semantic-gate").unwrap(),
24810 vec![workspace],
24811 vec![agent("claude")],
24812 );
24813 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24814 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
24815 ])
24816 .unwrap();
24817 shared
24818 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24819 .unwrap();
24820 let address = SessionAddress {
24821 workspace_id,
24822 session: SessionKey {
24823 instance_id: AgentInstanceId(72),
24824 generation: SessionGeneration::default(),
24825 },
24826 };
24827 shared.bind_session_with_policy(
24828 &address,
24829 ProviderRuntimePolicy::raw_pty(),
24830 None,
24831 );
24832 shared
24833 .handle
24834 .dispatch(shared.prepare_command(ControlCommand::Register {
24835 instance_id: address.session.instance_id,
24836 agent_id: agent("claude"),
24837 transport: TransportKind::Pty,
24838 }))
24839 .unwrap();
24840 runtime.tick().await;
24841 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
24842 let revision_before = shared.handle.snapshot().revision;
24843 let history_sequence_before = shared.current_sequence();
24844 let terminal_size = gate4agent_types::TerminalSize {
24845 rows: 24,
24846 columns: 80,
24847 };
24848
24849 for request in [
24850 NodeRequest::Prompt {
24851 session: address.clone(),
24852 text: "prompt".to_owned(),
24853 },
24854 NodeRequest::Paste {
24855 session: address.clone(),
24856 text: "paste".to_owned(),
24857 },
24858 NodeRequest::Resume {
24864 session: address.clone(),
24865 terminal_size,
24866 initial_prompt: Some("resume".to_owned()),
24867 },
24868 ] {
24869 let error = process_request_inner(
24870 &shared,
24871 77,
24872 ClientRole::Operator,
24873 request,
24874 )
24875 .await
24876 .unwrap_err();
24877 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
24878 }
24879
24880 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
24881 assert_eq!(shared.handle.snapshot().revision, revision_before);
24882 assert_eq!(shared.current_sequence(), history_sequence_before);
24883 let bindings = shared
24884 .session_bindings
24885 .lock()
24886 .unwrap_or_else(|poisoned| poisoned.into_inner());
24887 let binding = bindings.get(&address.session.instance_id).unwrap();
24888 assert_eq!(binding.generation, address.session.generation);
24889 assert!(binding.pending_resume.is_none());
24890 assert!(binding.record_id.is_none());
24891 }
24892
24893 #[tokio::test]
24894 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
24895 let catalog = active_registry().unwrap();
24896 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24897 let primary = WorkspaceConfig::new(
24898 WorkspaceId::new("primary").unwrap(),
24899 std::env::current_dir().unwrap(),
24900 )
24901 .unwrap();
24902 let shared = NodeShared::new(
24903 handle,
24904 "fixture-token".to_owned(),
24905 NodeId::new("node-1").unwrap(),
24906 vec![primary],
24907 vec![agent("claude")],
24908 );
24909 shared
24910 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24911 .unwrap();
24912 let before = shared.snapshot();
24913 let error = process_request_inner(
24914 &shared,
24915 77,
24916 ClientRole::Operator,
24917 NodeRequest::RegisterWorkspace {
24918 workspace_id: WorkspaceId::new("foreign").unwrap(),
24919 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
24920 },
24921 )
24922 .await
24923 .unwrap_err();
24924 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
24925 assert_eq!(error.message, "invalid-request");
24926 assert_eq!(shared.snapshot().workspaces, before.workspaces);
24927 }
24928
24929 #[tokio::test]
24930 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
24931 let catalog = active_registry().unwrap();
24932 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24933 let primary_id = WorkspaceId::new("primary").unwrap();
24934 let primary = WorkspaceConfig::new(
24935 primary_id.clone(),
24936 std::env::current_dir().unwrap(),
24937 )
24938 .unwrap();
24939 let shared = NodeShared::new(
24940 handle,
24941 "fixture-token".to_owned(),
24942 NodeId::new("node-1").unwrap(),
24943 vec![primary.clone()],
24944 vec![agent("claude")],
24945 );
24946 let secondary_id = WorkspaceId::new("secondary").unwrap();
24947 let secondary = shared
24948 .register_workspace(
24949 secondary_id.clone(),
24950 std::env::temp_dir().to_string_lossy().into_owned(),
24951 )
24952 .await
24953 .unwrap();
24954 assert_eq!(secondary.workspace_id, secondary_id);
24955 assert!(secondary.sessions.is_empty());
24956 assert_eq!(shared.snapshot().workspaces.len(), 2);
24957
24958 let duplicate_id = shared
24959 .register_workspace(
24960 secondary_id.clone(),
24961 primary.canonical_root().to_owned(),
24962 )
24963 .await
24964 .unwrap_err();
24965 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
24966 let duplicate_root = shared
24967 .register_workspace(
24968 WorkspaceId::new("duplicate-root").unwrap(),
24969 windows_path_text(&secondary.canonical_root).to_owned(),
24970 )
24971 .await
24972 .unwrap_err();
24973 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
24974
24975 let address = SessionAddress {
24976 workspace_id: secondary_id.clone(),
24977 session: SessionKey {
24978 instance_id: AgentInstanceId(41),
24979 generation: SessionGeneration(1),
24980 },
24981 };
24982 shared.bind_session(
24983 address.session.instance_id,
24984 secondary_id.clone(),
24985 address.session.generation,
24986 );
24987 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
24988 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
24989 shared.remove_binding(&address);
24990 shared.unregister_workspace(&secondary_id).unwrap();
24991 assert_eq!(shared.snapshot().workspaces.len(), 1);
24992
24993 let last = shared.unregister_workspace(&primary_id).unwrap_err();
24994 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
24995 let history = shared
24996 .history
24997 .lock()
24998 .unwrap_or_else(|poisoned| poisoned.into_inner());
24999 assert!(matches!(
25000 &history.events[0],
25001 NodeEventEnvelope {
25002 sequence: 1,
25003 event: NodeEvent::WorkspaceAdded { workspace },
25004 } if workspace.workspace_id == secondary_id
25005 ));
25006 assert!(matches!(
25007 &history.events[1],
25008 NodeEventEnvelope {
25009 sequence: 2,
25010 event: NodeEvent::WorkspaceRemoved { workspace_id },
25011 } if workspace_id == &secondary_id
25012 ));
25013 }
25014
25015 #[tokio::test]
25016 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25017 let secondary_root = temporary_workspace_root("provider-session-reference-index");
25018 std::fs::create_dir_all(&secondary_root).unwrap();
25019 let catalog = active_registry().unwrap();
25020 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25021 let primary_id = WorkspaceId::new("primary").unwrap();
25022 let secondary_id = WorkspaceId::new("secondary").unwrap();
25023 let shared = NodeShared::new(
25024 handle,
25025 "fixture-token".to_owned(),
25026 NodeId::new("node-session-reference-index").unwrap(),
25027 vec![
25028 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25029 .unwrap(),
25030 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25031 ],
25032 vec![agent("claude"), agent("codex")],
25033 );
25034 let identity = ProviderSessionIdentity {
25035 key: gate4agent_types::ProviderSessionKey::SessionId,
25036 id: "provider-session-42".to_owned(),
25037 transcript_path: None,
25038 };
25039 let index_request = |workspace_id: WorkspaceId,
25040 provider: AgentId,
25041 identity: ProviderSessionIdentity,
25042 display_name: &str| {
25043 NodeRequest::IndexProviderSession {
25044 workspace_id,
25045 provider,
25046 identity,
25047 display_name: display_name.to_owned(),
25048 }
25049 };
25050 let request = index_request(
25051 primary_id.clone(),
25052 agent("claude"),
25053 identity.clone(),
25054 "release shepherd",
25055 );
25056 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25057 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25058 }));
25059 let observer = process_request_inner(
25060 &shared,
25061 77,
25062 ClientRole::Observer,
25063 request.clone(),
25064 )
25065 .await
25066 .unwrap_err();
25067 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25068 let uncontrolled = process_request_inner(
25069 &shared,
25070 77,
25071 ClientRole::Operator,
25072 request.clone(),
25073 )
25074 .await
25075 .unwrap_err();
25076 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25077 shared
25078 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25079 .unwrap();
25080
25081 let indexed = process_request_inner(
25082 &shared,
25083 77,
25084 ClientRole::Operator,
25085 request.clone(),
25086 )
25087 .await
25088 .unwrap();
25089 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25090 panic!("provider session reference index returned another response");
25091 };
25092 assert_eq!(record.state, ManagedSessionState::Dormant);
25093 assert_eq!(record.mode, SessionMode::Pty);
25094 assert_eq!(record.workspace_id, primary_id);
25095 assert_eq!(record.provider_session.as_ref(), Some(&identity));
25096 assert!(record.active_session.is_none());
25097 assert!(record.environment_profile.is_none());
25098 assert!(record.bundle.is_none());
25099 assert!(record.context.is_none());
25100
25101 let repeated = process_request_inner(
25102 &shared,
25103 77,
25104 ClientRole::Operator,
25105 index_request(
25106 primary_id.clone(),
25107 agent("claude"),
25108 identity.clone(),
25109 "ignored replacement",
25110 ),
25111 )
25112 .await
25113 .unwrap();
25114 assert!(matches!(
25115 repeated,
25116 NodeResponse::ProviderSessionIndexed { record: ref existing }
25117 if existing.record_id == record.record_id
25118 && existing.display_name == "release shepherd"
25119 ));
25120 let conflicting_workspace = process_request_inner(
25121 &shared,
25122 77,
25123 ClientRole::Operator,
25124 index_request(
25125 secondary_id,
25126 agent("claude"),
25127 identity.clone(),
25128 "release shepherd",
25129 ),
25130 )
25131 .await
25132 .unwrap_err();
25133 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25134 let conflicting_provider = process_request_inner(
25135 &shared,
25136 77,
25137 ClientRole::Operator,
25138 index_request(
25139 primary_id.clone(),
25140 agent("codex"),
25141 identity.clone(),
25142 "release shepherd",
25143 ),
25144 )
25145 .await
25146 .unwrap_err();
25147 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25148 let transcript_rejected = process_request_inner(
25149 &shared,
25150 77,
25151 ClientRole::Operator,
25152 index_request(
25153 primary_id,
25154 agent("claude"),
25155 ProviderSessionIdentity {
25156 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25157 ..identity
25158 },
25159 "release shepherd",
25160 ),
25161 )
25162 .await
25163 .unwrap_err();
25164 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25165 let upserts = shared
25166 .history
25167 .lock()
25168 .unwrap_or_else(|poisoned| poisoned.into_inner())
25169 .events
25170 .iter()
25171 .filter(|event| matches!(
25172 &event.event,
25173 NodeEvent::SessionRecordUpserted { record: published }
25174 if published.record_id == record.record_id
25175 ))
25176 .count();
25177 assert_eq!(upserts, 1);
25178 std::fs::remove_dir_all(secondary_root).unwrap();
25179 }
25180
25181 #[tokio::test]
25182 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25183 let fixture_root = temporary_workspace_root("standalone-independent");
25184 let target = fixture_root.join("independent");
25185 std::fs::create_dir_all(&fixture_root).unwrap();
25186 let catalog = active_registry().unwrap();
25187 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25188 let selected = WorkspaceConfig::new(
25189 WorkspaceId::new("primary").unwrap(),
25190 std::env::current_dir().unwrap(),
25191 )
25192 .unwrap();
25193 let selected_root = selected.canonical_root().to_owned();
25194 let shared = NodeShared::new(
25195 handle,
25196 "fixture-token".to_owned(),
25197 NodeId::new("node-standalone").unwrap(),
25198 vec![selected],
25199 vec![agent("claude")],
25200 );
25201 let workspace_id = WorkspaceId::new("independent").unwrap();
25202 let request = NodeRequest::CreateStandaloneWorkspace {
25203 workspace_id: workspace_id.clone(),
25204 root: opaque_windows_path(target.to_string_lossy().into_owned()),
25205 initial_branch: Some("main".to_owned()),
25206 };
25207 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25208 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25209 }));
25210 assert!(!request_uses_unnegotiated_capability(
25211 &request,
25212 &baseline_capabilities().unwrap(),
25213 ));
25214 assert!(request_uses_unnegotiated_capability(&request, &[]));
25215
25216 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25217 .await
25218 .unwrap_err();
25219 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25220 assert!(!target.exists());
25221 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25222 .await
25223 .unwrap_err();
25224 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25225 assert!(!target.exists());
25226 shared
25227 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25228 .unwrap();
25229
25230 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25231 .await
25232 .unwrap();
25233 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25234 panic!("standalone workspace creation returned another response");
25235 };
25236 assert_eq!(workspace.workspace_id, workspace_id);
25237 assert!(target.join(".git").is_dir());
25238 let branch = run_git_bounded(
25239 target.to_str().unwrap(),
25240 &["branch", "--show-current"],
25241 GIT_OUTPUT_MAX_BYTES,
25242 )
25243 .await
25244 .unwrap();
25245 assert!(branch.success);
25246 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25247 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25248 assert_eq!(worktrees.len(), 1);
25249 let listed_root = WorkspaceConfig::new(
25250 WorkspaceId::new("listed-independent").unwrap(),
25251 &worktrees[0].path,
25252 )
25253 .unwrap();
25254 assert!(platform::roots_equal(
25255 listed_root.canonical_root(),
25256 windows_path_text(&workspace.canonical_root),
25257 ));
25258 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25259 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25260 snapshot.workspace_id == workspace_id
25261 && snapshot.canonical_root == workspace.canonical_root
25262 }));
25263 assert!(shared
25264 .history
25265 .lock()
25266 .unwrap_or_else(|poisoned| poisoned.into_inner())
25267 .events
25268 .iter()
25269 .any(|event| matches!(
25270 &event.event,
25271 NodeEvent::WorkspaceAdded { workspace }
25272 if workspace.workspace_id == workspace_id
25273 )));
25274 std::fs::remove_dir_all(fixture_root).unwrap();
25275 }
25276
25277 #[tokio::test]
25278 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25279 let fixture_root = temporary_workspace_root("standalone-compensation");
25280 let created_target = fixture_root.join("created-target");
25281 let existing_target = fixture_root.join("existing-target");
25282 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25283 let invalid_state_path = invalid_state_parent.join("state.json");
25284 std::fs::create_dir_all(&existing_target).unwrap();
25285 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25286 let catalog = active_registry().unwrap();
25287 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25288 let primary = WorkspaceConfig::new(
25289 WorkspaceId::new("primary").unwrap(),
25290 std::env::current_dir().unwrap(),
25291 )
25292 .unwrap();
25293 let mut shared = NodeShared::new(
25294 handle,
25295 "fixture-token".to_owned(),
25296 NodeId::new("node-standalone-compensation").unwrap(),
25297 vec![primary],
25298 vec![agent("claude")],
25299 );
25300 shared.state_path = Some(invalid_state_path);
25301
25302 let created_recovery = shared
25303 .create_standalone_workspace(
25304 WorkspaceId::new("compensated").unwrap(),
25305 created_target.to_string_lossy().into_owned(),
25306 Some("main".to_owned()),
25307 )
25308 .await
25309 .unwrap_err();
25310 assert_eq!(
25311 created_recovery.code,
25312 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25313 );
25314 assert!(created_target.join(".git").is_dir());
25315 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25316 workspace.workspace_id.as_str() != "compensated"
25317 }));
25318
25319 let existing_recovery = shared
25320 .create_standalone_workspace(
25321 WorkspaceId::new("restored").unwrap(),
25322 existing_target.to_string_lossy().into_owned(),
25323 None,
25324 )
25325 .await
25326 .unwrap_err();
25327 assert_eq!(
25328 existing_recovery.code,
25329 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25330 );
25331 assert!(existing_target.is_dir());
25332 assert!(existing_target.join(".git").is_dir());
25333 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25334 workspace.workspace_id.as_str() != "restored"
25335 }));
25336
25337 let registered_empty = fixture_root.join("registered-empty");
25338 std::fs::create_dir(®istered_empty).unwrap();
25339 let catalog = active_registry().unwrap();
25340 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25341 let registered = WorkspaceConfig::new(
25342 WorkspaceId::new("registered-empty").unwrap(),
25343 ®istered_empty,
25344 )
25345 .unwrap();
25346 let duplicate_shared = NodeShared::new(
25347 handle,
25348 "fixture-token".to_owned(),
25349 NodeId::new("node-standalone-duplicate-root").unwrap(),
25350 vec![registered],
25351 vec![agent("claude")],
25352 );
25353 let duplicate = duplicate_shared
25354 .create_standalone_workspace(
25355 WorkspaceId::new("duplicate-root").unwrap(),
25356 registered_empty.to_string_lossy().into_owned(),
25357 None,
25358 )
25359 .await
25360 .unwrap_err();
25361 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
25362 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
25363 std::fs::remove_dir_all(fixture_root).unwrap();
25364 }
25365
25366 #[test]
25367 fn managed_record_id_collision_preserves_the_existing_record() {
25368 let catalog = active_registry().unwrap();
25369 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25370 let workspace = WorkspaceConfig::new(
25371 WorkspaceId::new("primary").unwrap(),
25372 std::env::current_dir().unwrap(),
25373 )
25374 .unwrap();
25375 let shared = NodeShared::new(
25376 handle,
25377 "fixture-token".to_owned(),
25378 NodeId::new("node-1").unwrap(),
25379 vec![workspace.clone()],
25380 vec![agent("claude")],
25381 );
25382 let record_id = SessionRecordId::new("sr-collision").unwrap();
25383 let original = ManagedSessionRecord {
25384 record_id: record_id.clone(),
25385 display_name: "original".to_owned(),
25386 provider: agent("claude"),
25387 mode: SessionMode::Pty,
25388 state: ManagedSessionState::Dormant,
25389 workspace_id: workspace.workspace_id,
25390 canonical_root: opaque_windows_path(workspace.canonical_root),
25391 provider_session: None,
25392 active_session: None,
25393 environment_profile: None,
25394 bundle: None,
25395 context_id: None,
25396 context: None,
25397 exported_context: None,
25398 task_binding: None,
25399 created_at_unix_ms: 1,
25400 updated_at_unix_ms: 1,
25401 last_error: None,
25402 };
25403 shared.insert_record(original.clone()).unwrap();
25404 let mut collision = original.clone();
25405 collision.display_name = "replacement".to_owned();
25406 let error = shared.insert_record(collision).unwrap_err();
25407 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
25408 assert_eq!(shared.record(&record_id).unwrap(), original);
25409 }
25410
25411 #[test]
25412 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
25413 let catalog = active_registry().unwrap();
25414 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25415 let workspace = WorkspaceConfig::new(
25416 WorkspaceId::new("primary").unwrap(),
25417 std::env::current_dir().unwrap(),
25418 )
25419 .unwrap();
25420 let shared = NodeShared::new(
25421 handle,
25422 "fixture-token".to_owned(),
25423 NodeId::new("node-1").unwrap(),
25424 vec![workspace.clone()],
25425 vec![agent("claude")],
25426 );
25427 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
25428 shared
25429 .insert_record(ManagedSessionRecord {
25430 record_id: record_id.clone(),
25431 display_name: "runtime error".to_owned(),
25432 provider: agent("claude"),
25433 mode: SessionMode::Pty,
25434 state: ManagedSessionState::IdentityPending,
25435 workspace_id: workspace.workspace_id,
25436 canonical_root: opaque_windows_path(workspace.canonical_root),
25437 provider_session: None,
25438 active_session: None,
25439 environment_profile: None,
25440 bundle: None,
25441 context_id: None,
25442 context: None,
25443 exported_context: None,
25444 task_binding: None,
25445 created_at_unix_ms: 1,
25446 updated_at_unix_ms: 1,
25447 last_error: None,
25448 })
25449 .unwrap();
25450
25451 shared
25452 .mark_record_error(
25453 &record_id,
25454 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
25455 )
25456 .unwrap();
25457 let record = shared.record(&record_id).unwrap();
25458 assert_eq!(record.state, ManagedSessionState::Unavailable);
25459 assert_eq!(
25460 record.last_error.as_deref(),
25461 Some("provider-runtime-failed"),
25462 );
25463 let snapshot = shared.snapshot();
25464 assert_eq!(snapshot.session_records, vec![record]);
25465 }
25466
25467 #[test]
25476 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
25477 let catalog = active_registry().unwrap();
25478 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25479 let workspace = WorkspaceConfig::new(
25480 WorkspaceId::new("primary").unwrap(),
25481 std::env::current_dir().unwrap(),
25482 )
25483 .unwrap();
25484 let shared = NodeShared::new(
25485 handle,
25486 "fixture-token".to_owned(),
25487 NodeId::new("node-1").unwrap(),
25488 vec![workspace],
25489 vec![agent("claude")],
25490 );
25491
25492 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25493
25494 shared.record_rejected_command(
25495 CommandId(1),
25496 "agent 'claude' does not support transport Acp".to_owned(),
25497 true,
25498 );
25499 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
25501
25502 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
25503 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
25504 assert!(reason.unsupported_transport);
25505
25506 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25508
25509 shared.record_rejected_command(
25514 CommandId(3),
25515 "backend busy".to_owned(),
25516 false,
25517 );
25518 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
25519 assert!(!unclassified.unsupported_transport);
25520 }
25521
25522 #[test]
25523 fn provider_identity_update_is_ignored_without_exact_session_policy() {
25524 let catalog = active_registry().unwrap();
25525 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25526 let workspace = WorkspaceConfig::new(
25527 WorkspaceId::new("primary").unwrap(),
25528 std::env::current_dir().unwrap(),
25529 )
25530 .unwrap();
25531 let shared = NodeShared::new(
25532 handle,
25533 "fixture-token".to_owned(),
25534 NodeId::new("node-identity-policy").unwrap(),
25535 vec![workspace.clone()],
25536 vec![agent("claude")],
25537 );
25538 let address = SessionAddress {
25539 workspace_id: workspace.workspace_id.clone(),
25540 session: SessionKey {
25541 instance_id: AgentInstanceId(90),
25542 generation: SessionGeneration(1),
25543 },
25544 };
25545 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
25546 shared
25547 .insert_record(ManagedSessionRecord {
25548 record_id: record_id.clone(),
25549 display_name: "policy denied".to_owned(),
25550 provider: agent("claude"),
25551 mode: SessionMode::Pty,
25552 state: ManagedSessionState::IdentityPending,
25553 workspace_id: workspace.workspace_id,
25554 canonical_root: opaque_windows_path(workspace.canonical_root),
25555 provider_session: None,
25556 active_session: Some(address.clone()),
25557 environment_profile: None,
25558 bundle: None,
25559 context_id: None,
25560 context: None,
25561 exported_context: None,
25562 task_binding: None,
25563 created_at_unix_ms: 1,
25564 updated_at_unix_ms: 1,
25565 last_error: None,
25566 })
25567 .unwrap();
25568 let event = ControlEvent {
25569 sequence: 1,
25570 command_id: None,
25571 instance_id: address.session.instance_id,
25572 generation: address.session.generation,
25573 event: ControlEventKind::ProviderEvent {
25574 sequence: 1,
25575 source: gate4agent_types::ProviderSource {
25576 family: AdapterFamily::PtySemantic,
25577 binding: AdapterBinding::new(
25578 gate4agent_types::AdapterId::new("claude").unwrap(),
25579 "fixture/v1",
25580 gate4agent_types::AdapterVerification::SyntheticFixture,
25581 )
25582 .unwrap(),
25583 },
25584 source_sequence: 1,
25585 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25586 identity: gate4agent_types::ProviderSessionIdentity {
25587 key: gate4agent_types::ProviderSessionKey::SessionId,
25588 id: "provider-session".to_owned(),
25589 transcript_path: None,
25590 },
25591 },
25592 },
25593 };
25594
25595 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
25596
25597 let record = shared.record(&record_id).unwrap();
25598 assert!(record.provider_session.is_none());
25599 assert_eq!(record.state, ManagedSessionState::IdentityPending);
25600 assert_eq!(record.active_session.as_ref(), Some(&address));
25601 }
25602
25603 #[test]
25604 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
25605 let secondary_root = temporary_workspace_root("identity-scope");
25606 std::fs::create_dir_all(&secondary_root).unwrap();
25607 let catalog = active_registry().unwrap();
25608 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25609 let primary = WorkspaceConfig::new(
25610 WorkspaceId::new("primary").unwrap(),
25611 std::env::current_dir().unwrap(),
25612 )
25613 .unwrap();
25614 let secondary = WorkspaceConfig::new(
25615 WorkspaceId::new("secondary").unwrap(),
25616 &secondary_root,
25617 )
25618 .unwrap();
25619 let shared = NodeShared::new(
25620 handle,
25621 "fixture-token".to_owned(),
25622 NodeId::new("node-1").unwrap(),
25623 vec![primary.clone(), secondary.clone()],
25624 vec![agent("claude")],
25625 );
25626 let identity = gate4agent_types::ProviderSessionIdentity {
25627 key: gate4agent_types::ProviderSessionKey::SessionId,
25628 id: "provider-session".to_owned(),
25629 transcript_path: None,
25630 };
25631 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
25632 shared
25633 .insert_record(ManagedSessionRecord {
25634 record_id: dormant_id.clone(),
25635 display_name: "dormant".to_owned(),
25636 provider: agent("claude"),
25637 mode: SessionMode::Pty,
25638 state: ManagedSessionState::Dormant,
25639 workspace_id: primary.workspace_id,
25640 canonical_root: opaque_windows_path(primary.canonical_root),
25641 provider_session: Some(identity.clone()),
25642 active_session: None,
25643 environment_profile: None,
25644 bundle: None,
25645 context_id: None,
25646 context: None,
25647 exported_context: None,
25648 task_binding: None,
25649 created_at_unix_ms: 1,
25650 updated_at_unix_ms: 1,
25651 last_error: None,
25652 })
25653 .unwrap();
25654 let address = SessionAddress {
25655 workspace_id: secondary.workspace_id.clone(),
25656 session: SessionKey {
25657 instance_id: AgentInstanceId(91),
25658 generation: SessionGeneration(1),
25659 },
25660 };
25661 let current_id = SessionRecordId::new("sr-current").unwrap();
25662 shared
25663 .insert_record(ManagedSessionRecord {
25664 record_id: current_id.clone(),
25665 display_name: "current".to_owned(),
25666 provider: agent("claude"),
25667 mode: SessionMode::Pty,
25668 state: ManagedSessionState::IdentityPending,
25669 workspace_id: secondary.workspace_id,
25670 canonical_root: opaque_windows_path(secondary.canonical_root),
25671 provider_session: None,
25672 active_session: Some(address.clone()),
25673 environment_profile: None,
25674 bundle: None,
25675 context_id: None,
25676 context: None,
25677 exported_context: None,
25678 task_binding: None,
25679 created_at_unix_ms: 2,
25680 updated_at_unix_ms: 2,
25681 last_error: None,
25682 })
25683 .unwrap();
25684 shared.bind_managed_session(
25685 &address,
25686 current_id.clone(),
25687 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25688 None,
25689 );
25690 let event = ControlEvent {
25691 sequence: 1,
25692 command_id: None,
25693 instance_id: address.session.instance_id,
25694 generation: address.session.generation,
25695 event: ControlEventKind::ProviderEvent {
25696 sequence: 1,
25697 source: gate4agent_types::ProviderSource {
25698 family: gate4agent_types::AdapterFamily::PtySemantic,
25699 binding: gate4agent_types::AdapterBinding::new(
25700 gate4agent_types::AdapterId::new("claude").unwrap(),
25701 "fixture/v1",
25702 gate4agent_types::AdapterVerification::SyntheticFixture,
25703 )
25704 .unwrap(),
25705 },
25706 source_sequence: 1,
25707 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25708 identity,
25709 },
25710 },
25711 };
25712 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
25713
25714 let binding = shared
25715 .session_bindings
25716 .lock()
25717 .unwrap_or_else(|poisoned| poisoned.into_inner())
25718 .get(&address.session.instance_id)
25719 .cloned()
25720 .unwrap();
25721 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
25722 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
25723 let current = shared.record(¤t_id).unwrap();
25724 assert_eq!(current.state, ManagedSessionState::Unavailable);
25725 assert_eq!(current.active_session.as_ref(), Some(&address));
25726 assert_eq!(
25727 current.last_error.as_deref(),
25728 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
25729 );
25730 std::fs::remove_dir_all(secondary_root).unwrap();
25731 }
25732
25733 #[tokio::test]
25734 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
25735 let root = temporary_workspace_root("semantic-provider-session");
25736 std::fs::create_dir_all(&root).unwrap();
25737 let state_path = root.join("state-v1.json");
25738 let catalog = active_registry().unwrap();
25739 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25740 let workspace = WorkspaceConfig::new(
25741 WorkspaceId::new("primary").unwrap(),
25742 &root,
25743 )
25744 .unwrap();
25745 let node_id = NodeId::new("node-semantic-identity").unwrap();
25746 let shared = NodeShared::new_with_incarnation(
25747 handle,
25748 "fixture-token".to_owned(),
25749 node_id.clone(),
25750 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
25751 vec![workspace.clone()],
25752 vec![agent("claude")],
25753 ProviderRuntimeStatuses::default(),
25754 None,
25755 Vec::new(),
25756 Vec::new(),
25757 SpawnProfileRegistry::default(),
25758 None,
25759 None,
25760 None,
25761 Some(state_path.clone()),
25762 Vec::new(),
25763 Vec::new(),
25764 Vec::new(),
25765 Vec::new(),
25766 None,
25767 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
25768 );
25769 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
25770 key: gate4agent_types::ProviderSessionKey::SessionId,
25771 id: "provider-session".to_owned(),
25772 transcript_path: None,
25773 };
25774 let durable_id = SessionRecordId::new("sr-durable").unwrap();
25775 shared
25776 .insert_record(ManagedSessionRecord {
25777 record_id: durable_id.clone(),
25778 display_name: "durable".to_owned(),
25779 provider: agent("claude"),
25780 mode: SessionMode::Pty,
25781 state: ManagedSessionState::Dormant,
25782 workspace_id: workspace.workspace_id.clone(),
25783 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25784 provider_session: Some(persisted_identity.clone()),
25785 active_session: None,
25786 environment_profile: None,
25787 bundle: None,
25788 context_id: None,
25789 context: None,
25790 exported_context: None,
25791 task_binding: None,
25792 created_at_unix_ms: 1,
25793 updated_at_unix_ms: 1,
25794 last_error: None,
25795 })
25796 .unwrap();
25797 let address = SessionAddress {
25798 workspace_id: workspace.workspace_id.clone(),
25799 session: SessionKey {
25800 instance_id: AgentInstanceId(92),
25801 generation: SessionGeneration(2),
25802 },
25803 };
25804 let pending_id = SessionRecordId::new("sr-pending").unwrap();
25805 shared
25806 .insert_record(ManagedSessionRecord {
25807 record_id: pending_id.clone(),
25808 display_name: "pending".to_owned(),
25809 provider: agent("claude"),
25810 mode: SessionMode::Pty,
25811 state: ManagedSessionState::IdentityPending,
25812 workspace_id: workspace.workspace_id.clone(),
25813 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25814 provider_session: None,
25815 active_session: Some(address.clone()),
25816 environment_profile: None,
25817 bundle: None,
25818 context_id: None,
25819 context: None,
25820 exported_context: None,
25821 task_binding: None,
25822 created_at_unix_ms: 2,
25823 updated_at_unix_ms: 2,
25824 last_error: None,
25825 })
25826 .unwrap();
25827 shared.bind_managed_session(
25828 &address,
25829 pending_id.clone(),
25830 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25831 None,
25832 );
25833 let observed_identity = gate4agent_types::ProviderSessionIdentity {
25834 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
25835 ..persisted_identity
25836 };
25837 let event = ControlEvent {
25838 sequence: 1,
25839 command_id: None,
25840 instance_id: address.session.instance_id,
25841 generation: address.session.generation,
25842 event: ControlEventKind::ProviderEvent {
25843 sequence: 1,
25844 source: gate4agent_types::ProviderSource {
25845 family: gate4agent_types::AdapterFamily::PtySemantic,
25846 binding: gate4agent_types::AdapterBinding::new(
25847 gate4agent_types::AdapterId::new("claude").unwrap(),
25848 "fixture/v1",
25849 gate4agent_types::AdapterVerification::SyntheticFixture,
25850 )
25851 .unwrap(),
25852 },
25853 source_sequence: 1,
25854 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25855 identity: observed_identity.clone(),
25856 },
25857 },
25858 };
25859 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
25860
25861 let snapshot = shared.snapshot();
25862 assert_eq!(snapshot.session_records.len(), 1);
25863 let live = &snapshot.session_records[0];
25864 assert_eq!(live.record_id, durable_id);
25865 assert_eq!(live.state, ManagedSessionState::Live);
25866 assert_eq!(live.active_session.as_ref(), Some(&address));
25867 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
25868 let binding = shared
25869 .session_bindings
25870 .lock()
25871 .unwrap_or_else(|poisoned| poisoned.into_inner())
25872 .get(&address.session.instance_id)
25873 .cloned()
25874 .unwrap();
25875 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
25876
25877 let mut duplicate = live.clone();
25878 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
25879 duplicate.display_name = "duplicate".to_owned();
25880 duplicate.state = ManagedSessionState::Dormant;
25881 duplicate.active_session = None;
25882 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
25883 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
25884 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
25885 assert_eq!(shared.snapshot().session_records.len(), 1);
25886
25887 let terminal_size = gate4agent_types::TerminalSize {
25888 rows: 24,
25889 columns: 80,
25890 };
25891 let (first_busy, second_busy) = tokio::join!(
25892 shared.resume_session_record(&durable_id, terminal_size, None),
25893 shared.resume_session_record(&durable_id, terminal_size, None),
25894 );
25895 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
25896 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
25897 assert_eq!(busy.message, "session-record-busy");
25898 }
25899
25900 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
25901 assert_eq!(loaded.records.len(), 1);
25902 assert_eq!(loaded.records[0].record_id, durable_id);
25903 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
25904 assert_eq!(
25905 loaded.records[0]
25906 .provider_session
25907 .as_ref()
25908 .unwrap()
25909 .transcript_path,
25910 None,
25911 );
25912 let catalog = active_registry().unwrap();
25913 let (reloaded_handle, _reloaded_runtime) =
25914 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25915 let reloaded = NodeShared::new_with_incarnation(
25916 reloaded_handle,
25917 "fixture-token".to_owned(),
25918 node_id,
25919 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
25920 vec![workspace],
25921 vec![agent("claude")],
25922 ProviderRuntimeStatuses::default(),
25923 None,
25924 Vec::new(),
25925 Vec::new(),
25926 SpawnProfileRegistry::default(),
25927 None,
25928 None,
25929 None,
25930 None,
25931 loaded.records,
25932 loaded.managed_worktrees,
25933 loaded.managed_worktree_tombstones,
25934 loaded.materializations,
25935 loaded.warning,
25936 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
25937 );
25938 assert_eq!(reloaded.snapshot().session_records.len(), 1);
25939 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
25940 std::fs::remove_dir_all(root).unwrap();
25941 }
25942
25943 #[tokio::test]
25944 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
25945 let secondary_root = temporary_workspace_root("workspace-restore");
25946 let other_root = temporary_workspace_root("workspace-conflict");
25947 std::fs::create_dir_all(&secondary_root).unwrap();
25948 std::fs::create_dir_all(&other_root).unwrap();
25949 let catalog = active_registry().unwrap();
25950 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25951 let primary = WorkspaceConfig::new(
25952 WorkspaceId::new("primary").unwrap(),
25953 std::env::current_dir().unwrap(),
25954 )
25955 .unwrap();
25956 let secondary = WorkspaceConfig::new(
25957 WorkspaceId::new("secondary").unwrap(),
25958 &secondary_root,
25959 )
25960 .unwrap();
25961 let shared = NodeShared::new(
25962 handle,
25963 "fixture-token".to_owned(),
25964 NodeId::new("node-1").unwrap(),
25965 vec![primary, secondary.clone()],
25966 vec![agent("claude")],
25967 );
25968 let record_id = SessionRecordId::new("sr-workspace").unwrap();
25969 shared
25970 .insert_record(ManagedSessionRecord {
25971 record_id: record_id.clone(),
25972 display_name: "workspace record".to_owned(),
25973 provider: agent("claude"),
25974 mode: SessionMode::Pty,
25975 state: ManagedSessionState::Dormant,
25976 workspace_id: secondary.workspace_id.clone(),
25977 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
25978 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
25979 key: gate4agent_types::ProviderSessionKey::SessionId,
25980 id: "provider-session".to_owned(),
25981 transcript_path: None,
25982 }),
25983 active_session: None,
25984 environment_profile: None,
25985 bundle: None,
25986 context_id: None,
25987 context: None,
25988 exported_context: None,
25989 task_binding: None,
25990 created_at_unix_ms: 1,
25991 updated_at_unix_ms: 1,
25992 last_error: None,
25993 })
25994 .unwrap();
25995 shared.unregister_workspace(&secondary.workspace_id).unwrap();
25996 let unavailable = shared.record(&record_id).unwrap();
25997 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
25998 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
25999
26000 let conflict = shared
26001 .register_workspace(
26002 secondary.workspace_id.clone(),
26003 other_root.to_string_lossy().into_owned(),
26004 )
26005 .await
26006 .unwrap_err();
26007 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26008 shared
26009 .register_workspace(
26010 secondary.workspace_id,
26011 secondary_root.to_string_lossy().into_owned(),
26012 )
26013 .await
26014 .unwrap();
26015 let restored = shared.record(&record_id).unwrap();
26016 assert_eq!(restored.state, ManagedSessionState::Dormant);
26017 assert!(restored.last_error.is_none());
26018 std::fs::remove_dir_all(secondary_root).unwrap();
26019 std::fs::remove_dir_all(other_root).unwrap();
26020 }
26021
26022 #[test]
26023 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26024 let error = validate_workspace_request_root(
26025 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26026 )
26027 .unwrap_err();
26028 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26029 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26030 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26031 }
26032
26033 #[test]
26034 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26035 let catalog = active_registry().unwrap();
26036 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26037 let workspace = WorkspaceConfig::new(
26038 WorkspaceId::new("primary").unwrap(),
26039 std::env::current_dir().unwrap(),
26040 )
26041 .unwrap();
26042 let shared = NodeShared::new(
26043 handle,
26044 "fixture-token".to_owned(),
26045 NodeId::new("node-1").unwrap(),
26046 vec![workspace],
26047 vec![agent("claude")],
26048 );
26049 let publishers = 4_u64;
26050 let events_per_publisher = 256_u64;
26051 let start = std::sync::Barrier::new(publishers as usize + 1);
26052 let finished = AtomicU64::new(0);
26053
26054 std::thread::scope(|scope| {
26055 for _ in 0..publishers {
26056 scope.spawn(|| {
26057 start.wait();
26058 for _ in 0..events_per_publisher {
26059 shared.publish(NodeEvent::ControllerChanged { controller: None });
26060 }
26061 finished.fetch_add(1, Ordering::Release);
26062 });
26063 }
26064 start.wait();
26065 while finished.load(Ordering::Acquire) != publishers {
26066 assert_resync_history_is_linearized(shared.resync(0));
26067 std::thread::yield_now();
26068 }
26069 });
26070 let response = shared.resync(0);
26071 assert_resync_history_is_linearized(response.clone());
26072 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26073 unreachable!("resync helper returned another response");
26074 };
26075 assert_eq!(event_sequence, publishers * events_per_publisher);
26076 assert_eq!(events.len() as u64, event_sequence);
26077 }
26078
26079 fn assert_resync_history_is_linearized(response: NodeResponse) {
26080 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26081 unreachable!("resync helper returned another response");
26082 };
26083 assert!(events.windows(2).all(|pair| {
26084 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26085 }));
26086 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26087 }
26088
26089 #[test]
26090 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26091 assert_eq!(
26092 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26093 r"C:\repo\worktree",
26094 );
26095 assert_eq!(
26096 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26097 r"\\server\share\repo",
26098 );
26099 assert!(validate_workspace_root(
26100 &WorkspaceId::new("network").unwrap(),
26101 r"\\server\share\repo",
26102 )
26103 .is_err());
26104 let workspace = WorkspaceConfig::new(
26105 WorkspaceId::new("primary").unwrap(),
26106 std::env::current_dir().unwrap(),
26107 )
26108 .unwrap();
26109 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26110 }
26111
26112 #[test]
26113 fn resume_rebinds_only_on_the_expected_authorized_generation() {
26114 let catalog = active_registry().unwrap();
26115 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26116 let workspace_id = WorkspaceId::new("primary").unwrap();
26117 let workspace = WorkspaceConfig::new(
26118 workspace_id.clone(),
26119 std::env::current_dir().unwrap(),
26120 )
26121 .unwrap();
26122 let shared = NodeShared::new(
26123 handle,
26124 "fixture-token".to_owned(),
26125 NodeId::new("node-1").unwrap(),
26126 vec![workspace],
26127 vec![agent("claude")],
26128 );
26129 let instance_id = AgentInstanceId(17);
26130 let original = SessionAddress {
26131 workspace_id,
26132 session: SessionKey {
26133 instance_id,
26134 generation: SessionGeneration(3),
26135 },
26136 };
26137 shared.bind_session(
26138 instance_id,
26139 original.workspace_id.clone(),
26140 original.session.generation,
26141 );
26142 shared
26143 .arm_resume(
26144 &original,
26145 CommandId(41),
26146 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26147 )
26148 .unwrap();
26149
26150 shared.publish_control(ControlEvent {
26151 sequence: 1,
26152 command_id: None,
26153 instance_id,
26154 generation: SessionGeneration(4),
26155 event: ControlEventKind::Running { process_id: None },
26156 });
26157 assert_eq!(
26158 shared.address_for(instance_id, SessionGeneration(3)),
26159 Some(original.clone()),
26160 );
26161 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26162
26163 shared.publish_control(ControlEvent {
26164 sequence: 2,
26165 command_id: Some(CommandId(42)),
26166 instance_id,
26167 generation: SessionGeneration(3),
26168 event: ControlEventKind::CommandRejected {
26169 message: "unrelated command".to_owned(),
26170 },
26171 });
26172
26173 shared.publish_control(ControlEvent {
26174 sequence: 3,
26175 command_id: None,
26176 instance_id,
26177 generation: SessionGeneration(4),
26178 event: ControlEventKind::ResumeAuthorized {
26179 session: gate4agent_types::ResumeSessionSummary {
26180 key: gate4agent_types::ProviderSessionKey::SessionId,
26181 id: "fixture-session".to_owned(),
26182 },
26183 },
26184 });
26185 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26186 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26187 assert_eq!(rebound.workspace_id, original.workspace_id);
26188 let history = shared
26189 .history
26190 .lock()
26191 .unwrap_or_else(|poisoned| poisoned.into_inner());
26192 assert!(history.events.iter().any(|envelope| matches!(
26193 &envelope.event,
26194 NodeEvent::Control { address, event }
26195 if address == &rebound
26196 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26197 )));
26198 }
26199
26200 #[test]
26201 fn all_node_pty_prompts_use_bracketed_paste_framing() {
26202 for agent in ["claude", "codex", "kimi"] {
26203 assert_eq!(
26204 prompt_framing(&AgentId::new(agent).unwrap()),
26205 PromptFraming::BracketedPaste
26206 );
26207 }
26208 }
26209
26210 fn generous_walk_deadline() -> Instant {
26211 Instant::now() + Duration::from_secs(30)
26212 }
26213
26214 #[test]
26215 fn workspace_tree_is_relative_and_skips_heavy_directories() {
26216 let root = temporary_workspace_root("tree");
26217 std::fs::create_dir_all(root.join("src")).unwrap();
26218 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26219 std::fs::create_dir_all(root.join("target/debug")).unwrap();
26220 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26221 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26222 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26223 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26229 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26230 std::fs::create_dir_all(root.join("target-notes")).unwrap();
26234 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26235
26236 let (entries, truncated, _budget) =
26237 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26238 assert!(!truncated);
26239 assert!(entries.iter().any(|entry| {
26240 entry.relative_path.as_utf8() == Some("src")
26241 && entry.kind == WorkspaceEntryKind::Directory
26242 }));
26243 assert!(entries.iter().any(|entry| {
26244 entry.relative_path.as_utf8() == Some("src/lib.rs")
26245 && entry.kind == WorkspaceEntryKind::File
26246 }));
26247 assert!(entries.iter().any(|entry| {
26248 entry.relative_path.as_utf8() == Some("target-notes")
26249 && entry.kind == WorkspaceEntryKind::Directory
26250 }));
26251 assert!(entries.iter().any(|entry| {
26252 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26253 && entry.kind == WorkspaceEntryKind::File
26254 }));
26255 assert!(entries.iter().all(|entry| {
26256 let path = entry.relative_path.as_utf8().unwrap();
26257 !path.starts_with(".git")
26258 && !(path == "target" || path.starts_with("target/"))
26259 && !(path == "target-a2" || path.starts_with("target-a2/"))
26260 && !path.starts_with("node_modules")
26261 }));
26262
26263 std::fs::remove_dir_all(&root).unwrap();
26264 }
26265
26266 #[test]
26267 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26268 let root = temporary_workspace_root("tree-entry-cap");
26269 std::fs::create_dir_all(&root).unwrap();
26270 for index in 0..20 {
26271 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26272 }
26273
26274 let (entries, truncated, budget) =
26275 collect_workspace_entries(&root, generous_walk_deadline(), 5);
26276 assert!(truncated);
26277 assert!(budget.entry_cap_exceeded);
26278 assert!(!budget.time_budget_exceeded);
26279 assert!(entries.len() <= 5);
26280
26281 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26282 .expect("an entry-cap-truncated walk must report a truncation marker");
26283 assert!(inspection_truncation.walk_entry_cap_exceeded);
26284 assert!(!inspection_truncation.walk_time_budget_exceeded);
26285 assert!(!inspection_truncation.git_time_budget_exceeded);
26286 assert!(inspection_truncation.entries_visited >= 5);
26287
26288 std::fs::remove_dir_all(&root).unwrap();
26289 }
26290
26291 #[test]
26292 fn workspace_walk_within_budget_reports_no_truncation() {
26293 let root = temporary_workspace_root("tree-no-truncation");
26294 std::fs::create_dir_all(&root).unwrap();
26295 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26296
26297 let (_entries, truncated, budget) =
26298 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26299 assert!(!truncated);
26300 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26301
26302 std::fs::remove_dir_all(&root).unwrap();
26303 }
26304
26305 #[test]
26306 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26307 let root = temporary_workspace_root("tree-prefix-siblings");
26308 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26309 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26310 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26311 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26312
26313 let (entries, truncated, _budget) =
26314 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26315 assert!(!truncated);
26316 let paths = entries
26317 .iter()
26318 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26319 .collect::<Vec<_>>();
26320 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26321 assert_eq!(
26322 paths,
26323 vec![
26324 "gate4agent-c2".to_owned(),
26325 "gate4agent-c2-client".to_owned(),
26326 "gate4agent-c2-client/lib.rs".to_owned(),
26327 "gate4agent-c2/src".to_owned(),
26328 "gate4agent-c2/src/lib.rs".to_owned(),
26329 ],
26330 );
26331
26332 std::fs::remove_dir_all(&root).unwrap();
26333 }
26334
26335 #[test]
26336 fn git_parsers_cap_status_and_commit_results() {
26337 let mut status_output = Vec::new();
26338 for index in 0..=GIT_STATUS_MAX_ENTRIES {
26339 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26340 }
26341 let mut snapshot = git_snapshot_for_parser();
26342 parse_git_status(&status_output, &mut snapshot);
26343 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26344 assert!(snapshot.truncated);
26345 assert_eq!(snapshot.status[0].index_status, " ");
26346 assert_eq!(snapshot.status[0].worktree_status, "M");
26347
26348 snapshot.truncated = false;
26349 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26350 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
26351 .collect::<String>();
26352 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
26353 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
26354 assert!(snapshot.truncated);
26355 }
26356
26357 #[test]
26358 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
26359 let mut snapshot = git_snapshot_for_parser();
26360 parse_git_status(
26361 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
26362 &mut snapshot,
26363 );
26364
26365 assert!(!snapshot.truncated);
26366 assert_eq!(snapshot.status.len(), 3);
26367 assert_eq!(snapshot.status[0].index_status, "R");
26368 assert_eq!(snapshot.status[0].worktree_status, " ");
26369 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
26370 assert_eq!(
26371 snapshot.status[0]
26372 .previous_path
26373 .as_ref()
26374 .and_then(RepositoryPath::as_utf8),
26375 Some("src/previous name.rs"),
26376 );
26377 assert_eq!(snapshot.status[1].index_status, " ");
26378 assert_eq!(snapshot.status[1].worktree_status, "C");
26379 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
26380 assert_eq!(
26381 snapshot.status[1]
26382 .previous_path
26383 .as_ref()
26384 .and_then(RepositoryPath::as_utf8),
26385 Some("src/original.rs"),
26386 );
26387 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
26388 assert!(snapshot.status[2].previous_path.is_none());
26389 }
26390
26391 #[test]
26392 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
26393 let mut snapshot = git_snapshot_for_parser();
26394 parse_git_status(
26395 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
26396 &mut snapshot,
26397 );
26398
26399 assert!(snapshot.truncated);
26400 assert_eq!(snapshot.status.len(), 1);
26401 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
26402 assert!(snapshot.status[0].previous_path.is_none());
26403 }
26404
26405 #[tokio::test]
26406 async fn observer_can_browse_host_directories_without_controller() {
26407 let root = temporary_workspace_root("host-directory-observer");
26408 std::fs::create_dir_all(root.join("directory-b")).unwrap();
26409 std::fs::create_dir_all(root.join("directory-a")).unwrap();
26410 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
26411 let workspace_id = WorkspaceId::new("primary").unwrap();
26412 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
26413 let catalog = active_registry().unwrap();
26414 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26415 let shared = NodeShared::new(
26416 handle,
26417 "fixture-token".to_owned(),
26418 NodeId::new("node-1").unwrap(),
26419 vec![workspace],
26420 vec![agent("claude")],
26421 );
26422 let request = NodeRequest::BrowseHostDirectories {
26423 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
26424 after: None,
26425 };
26426 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26427 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
26428 }));
26429 assert!(!request_uses_unnegotiated_capability(
26430 &request,
26431 &baseline_capabilities().unwrap(),
26432 ));
26433 assert!(request_uses_unnegotiated_capability(&request, &[]));
26434
26435 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
26436 .await
26437 .unwrap();
26438 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
26439 panic!("host directory browse returned another response");
26440 };
26441 assert_eq!(
26442 listing
26443 .entries
26444 .iter()
26445 .map(|entry| entry.display_name.as_str())
26446 .collect::<Vec<_>>(),
26447 vec!["directory-a", "directory-b"],
26448 );
26449 assert!(listing.entries.iter().all(|entry| !entry.is_link));
26450 assert!(!listing.incomplete);
26451 std::fs::remove_dir_all(root).unwrap();
26452 }
26453
26454 #[tokio::test]
26455 async fn observer_can_inspect_only_a_registered_workspace() {
26456 let root = temporary_workspace_root("observer");
26457 std::fs::create_dir_all(root.join("src")).unwrap();
26458 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
26459 let workspace_id = WorkspaceId::new("primary").unwrap();
26460 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26461 let catalog = active_registry().unwrap();
26462 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26463 let shared = NodeShared::new(
26464 handle,
26465 "fixture-token".to_owned(),
26466 NodeId::new("node-1").unwrap(),
26467 vec![workspace],
26468 vec![agent("claude")],
26469 );
26470
26471 let response = process_request_inner(
26472 &shared,
26473 77,
26474 ClientRole::Observer,
26475 NodeRequest::InspectWorkspace {
26476 workspace_id: workspace_id.clone(),
26477 },
26478 )
26479 .await
26480 .unwrap();
26481 let NodeResponse::WorkspaceInspected { inspection } = response else {
26482 panic!("workspace inspection returned another response");
26483 };
26484 assert_eq!(inspection.workspace_id, workspace_id);
26485 assert!(inspection.entries.iter().any(|entry| {
26486 entry.relative_path.as_utf8() == Some("src/main.rs")
26487 }));
26488
26489 let unknown = process_request_inner(
26490 &shared,
26491 77,
26492 ClientRole::Observer,
26493 NodeRequest::InspectWorkspace {
26494 workspace_id: WorkspaceId::new("unknown").unwrap(),
26495 },
26496 )
26497 .await
26498 .unwrap_err();
26499 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
26500 std::fs::remove_dir_all(&root).unwrap();
26501 }
26502
26503 #[test]
26504 fn context_pack_source_accepts_only_running_or_successful_exit() {
26505 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
26506 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
26507 exit_code: Some(0),
26508 }));
26509 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26510 exit_code: Some(1),
26511 }));
26512 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26513 exit_code: None,
26514 }));
26515 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
26516 message: "provider failed".to_owned(),
26517 }));
26518 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
26519 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
26520 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
26521 }
26522
26523 #[test]
26524 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
26525 let git = git_snapshot_for_parser();
26526 let first = ContextPackRepositoryObservation {
26527 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26528 git: git.clone(),
26529 files: Vec::new(),
26530 };
26531 let second = ContextPackRepositoryObservation {
26532 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
26533 git,
26534 files: Vec::new(),
26535 };
26536
26537 let error = stable_context_pack_repository(first, second).unwrap_err();
26538
26539 assert_eq!(
26540 error.code,
26541 NodeFailureCode::ContextPackMaterializationFailed,
26542 );
26543 }
26544
26545 #[test]
26546 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
26547 let git = git_snapshot_for_parser();
26548 let first = ContextPackRepositoryObservation {
26549 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26550 git: git.clone(),
26551 files: vec![ContextPackRepositoryFileSource::utf8(
26552 "README.md",
26553 "first\n".to_owned(),
26554 6,
26555 )],
26556 };
26557 let second = ContextPackRepositoryObservation {
26558 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26559 git,
26560 files: vec![ContextPackRepositoryFileSource::utf8(
26561 "README.md",
26562 "other\n".to_owned(),
26563 6,
26564 )],
26565 };
26566
26567 let error = stable_context_pack_repository(first, second).unwrap_err();
26568
26569 assert_eq!(
26570 error.code,
26571 NodeFailureCode::ContextPackMaterializationFailed,
26572 );
26573 }
26574
26575 #[tokio::test]
26576 async fn native_session_catalog_zero_active_session_fixture() {
26577 let workspace_root = temporary_workspace_root("native-catalog-workspace");
26578 let history_root = temporary_workspace_root("native-catalog-history");
26579 let outside_root = temporary_workspace_root("native-catalog-outside");
26580 std::fs::create_dir_all(&workspace_root).unwrap();
26581 std::fs::create_dir_all(&history_root).unwrap();
26582 std::fs::create_dir_all(&outside_root).unwrap();
26583 let transcript = |session_id: &str, cwd: &Path, title: &str| {
26584 [
26585 serde_json::json!({
26586 "type": "user",
26587 "sessionId": session_id,
26588 "cwd": cwd.to_str().unwrap(),
26589 "message": { "content": "raw user text must not cross the catalog wire" }
26590 }),
26591 serde_json::json!({
26592 "type": "assistant",
26593 "sessionId": session_id,
26594 "cwd": cwd.to_str().unwrap(),
26595 "message": { "model": "claude-sonnet", "content": "raw answer" }
26596 }),
26597 serde_json::json!({
26598 "type": "ai-title",
26599 "sessionId": session_id,
26600 "aiTitle": title
26601 }),
26602 ]
26603 .into_iter()
26604 .map(|record| record.to_string())
26605 .collect::<Vec<_>>()
26606 .join("\n")
26607 };
26608 std::fs::write(
26609 history_root.join("stable-session.jsonl"),
26610 transcript("stable-session", &workspace_root, "Workspace review"),
26611 )
26612 .unwrap();
26613 std::fs::write(
26614 history_root.join("outside-session.jsonl"),
26615 transcript("outside-session", &outside_root, "Outside review"),
26616 )
26617 .unwrap();
26618 let history = NativeHistoryConfig::new(vec![
26619 NativeHistoryRoot::new(
26620 gate4agent_types::AdapterId::new("claude-code").unwrap(),
26621 HistorySourceLayout::SingleNdjson,
26622 history_root.clone(),
26623 )
26624 .unwrap(),
26625 ])
26626 .unwrap();
26627 let catalog = active_registry().unwrap();
26628 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26629 assert!(handle.snapshot().sessions.is_empty());
26630 let workspace_id = WorkspaceId::new("primary").unwrap();
26631 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
26632 let mut shared = NodeShared::new(
26633 handle,
26634 "fixture-token".to_owned(),
26635 NodeId::new("node-native-catalog").unwrap(),
26636 vec![workspace],
26637 vec![agent("claude")],
26638 );
26639 shared.native_session_catalog = Some(Arc::new(Mutex::new(
26640 NativeSessionCatalogAuthority::new(history),
26641 )));
26642 let route = NativeSessionCatalogRoute::workspace(
26643 workspace_id.clone(),
26644 agent("claude"),
26645 );
26646 let request = NodeRequest::CatalogNativeSessions {
26647 route: route.clone(),
26648 limit: 2,
26649 };
26650 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
26651 .await
26652 .unwrap_err();
26653 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26654 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
26655 .await
26656 .unwrap();
26657 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
26658 panic!("native catalog returned a different response");
26659 };
26660 let summary = summary.expect("node must emit native session catalog summary metadata");
26661 assert_eq!(entries.len(), 1);
26662 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
26663 assert_eq!(entries[0].external_group, None);
26664 assert_eq!(entries[0].record_id, None);
26665 assert_eq!(entries[0].title, None);
26666 assert_eq!(entries[0].model, None);
26667 assert_eq!(entries[0].message_count, 0);
26668 let encoded_entries = serde_json::to_string(&entries).unwrap();
26669 assert!(!encoded_entries.contains("stable-session"));
26670 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
26671 let page_request = NodeRequest::PageNativeSessions {
26672 route: route.clone(),
26673 window: crate::protocol::NativeSessionCatalogWindow::Recent,
26674 catalog_revision: summary.catalog_revision,
26675 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26676 after_selection_id: None,
26677 limit: 2,
26678 };
26679 let observer = process_request_inner(
26680 &shared,
26681 7,
26682 ClientRole::Observer,
26683 page_request.clone(),
26684 )
26685 .await
26686 .unwrap_err();
26687 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26688 let page_response = process_request_inner(
26689 &shared,
26690 7,
26691 ClientRole::Operator,
26692 page_request.clone(),
26693 )
26694 .await
26695 .unwrap();
26696 assert!(matches!(
26697 page_response,
26698 NodeResponse::NativeSessionsPaged { page, .. }
26699 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
26700 ));
26701 let stale = process_request_inner(
26702 &shared,
26703 7,
26704 ClientRole::Operator,
26705 NodeRequest::PageNativeSessions {
26706 route: route.clone(),
26707 window: crate::protocol::NativeSessionCatalogWindow::Recent,
26708 catalog_revision: summary.catalog_revision.wrapping_add(1),
26709 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26710 after_selection_id: None,
26711 limit: 2,
26712 },
26713 )
26714 .await
26715 .unwrap_err();
26716 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
26717 let selection = NativeSessionSelection {
26718 route: route.clone(),
26719 catalog_revision: summary.catalog_revision,
26720 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26721 selection_id: entries[0].selection_id.clone(),
26722 };
26723 let preview_response = process_request_inner(
26724 &shared,
26725 7,
26726 ClientRole::Operator,
26727 NodeRequest::PreviewNativeSession {
26728 selection: selection.clone(),
26729 message_limit: 1,
26730 },
26731 )
26732 .await
26733 .unwrap();
26734 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
26735 panic!("native preview returned a different response");
26736 };
26737 assert_eq!(preview.messages.len(), 1);
26738 assert!(preview.message_count_exact);
26739 assert_eq!(preview.messages[0].text, "raw answer");
26740 assert!(preview.truncated);
26741
26742 let index_request = NodeRequest::IndexNativeSession {
26743 selection: selection.clone(),
26744 display_name: "Saved Claude".to_owned(),
26745 };
26746 let observer = process_request_inner(
26747 &shared,
26748 7,
26749 ClientRole::Observer,
26750 index_request.clone(),
26751 )
26752 .await
26753 .unwrap_err();
26754 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26755 let without_controller = process_request_inner(
26756 &shared,
26757 7,
26758 ClientRole::Operator,
26759 index_request.clone(),
26760 )
26761 .await
26762 .unwrap_err();
26763 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
26764 let acquired = process_request_inner(
26765 &shared,
26766 7,
26767 ClientRole::Operator,
26768 NodeRequest::AcquireController { lease_ms: 5_000 },
26769 )
26770 .await
26771 .unwrap();
26772 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
26773 let indexed = process_request_inner(
26774 &shared,
26775 7,
26776 ClientRole::Operator,
26777 index_request,
26778 )
26779 .await
26780 .unwrap();
26781 assert!(indexed.native_session_index_contract_is_valid());
26782 let NodeResponse::NativeSessionIndexed {
26783 selection: echoed_selection,
26784 record,
26785 } = indexed else {
26786 panic!("native session index returned a different response");
26787 };
26788 assert_eq!(echoed_selection, selection);
26789 let recataloged = process_request_inner(
26790 &shared,
26791 7,
26792 ClientRole::Operator,
26793 NodeRequest::CatalogNativeSessions {
26794 route,
26795 limit: 2,
26796 },
26797 )
26798 .await
26799 .unwrap();
26800 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
26801 panic!("native catalog returned a different response after indexing");
26802 };
26803 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
26804
26805 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
26806 let external_catalog = process_request_inner(
26807 &shared,
26808 7,
26809 ClientRole::Operator,
26810 NodeRequest::CatalogNativeSessions {
26811 route: unregistered_route.clone(),
26812 limit: 2,
26813 },
26814 )
26815 .await
26816 .unwrap();
26817 let external_json = serde_json::to_string(&external_catalog).unwrap();
26818 assert!(!external_json.contains("outside-session"));
26819 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
26820 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
26821 assert!(!external_json.contains("\"session_id\""));
26822 assert!(!external_json.contains("\"cwd\""));
26823 assert!(!external_json.contains("\"path\""));
26824 let NodeResponse::NativeSessionsCataloged {
26825 route: echoed_route,
26826 entries: external_entries,
26827 summary: Some(external_summary),
26828 } = external_catalog
26829 else {
26830 panic!("unregistered native catalog returned a different response");
26831 };
26832 assert_eq!(echoed_route, unregistered_route);
26833 assert_eq!(external_entries.len(), 1);
26834 assert_eq!(external_entries[0].record_id, None);
26835 let external_group = external_entries[0]
26836 .external_group
26837 .as_ref()
26838 .expect("unregistered session must have an external group");
26839 assert_eq!(
26840 external_group.kind,
26841 gate4agent_types::NativeSessionExternalGroupKind::Project,
26842 );
26843 assert!(external_group.group_id.starts_with("external-"));
26844 assert_eq!(
26845 external_group.display_name,
26846 outside_root.file_name().unwrap().to_string_lossy(),
26847 );
26848 let external_selection = NativeSessionSelection {
26849 route: unregistered_route,
26850 catalog_revision: external_summary.catalog_revision,
26851 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
26852 selection_id: external_entries[0].selection_id.clone(),
26853 };
26854 let external_preview = process_request_inner(
26855 &shared,
26856 7,
26857 ClientRole::Operator,
26858 NodeRequest::PreviewNativeSession {
26859 selection: external_selection.clone(),
26860 message_limit: 1,
26861 },
26862 )
26863 .await
26864 .unwrap();
26865 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
26866 assert!(!external_preview_json.contains("outside-session"));
26867 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
26868 assert!(!external_preview_json.contains("\"session_id\""));
26869 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
26870 panic!("unregistered native preview returned a different response");
26871 };
26872 assert_eq!(preview.messages.len(), 1);
26873 let external_index = process_request_inner(
26874 &shared,
26875 7,
26876 ClientRole::Operator,
26877 NodeRequest::IndexNativeSession {
26878 selection: external_selection,
26879 display_name: "External Claude".to_owned(),
26880 },
26881 )
26882 .await
26883 .unwrap_err();
26884 assert_eq!(
26885 external_index.code,
26886 NodeFailureCode::WorkspaceRegistrationRequired,
26887 );
26888 let record_response = process_request_inner(
26889 &shared,
26890 7,
26891 ClientRole::Operator,
26892 NodeRequest::PreviewSessionRecord {
26893 record_id: record.record_id.clone(),
26894 message_limit: 2,
26895 },
26896 )
26897 .await
26898 .unwrap();
26899 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
26900 panic!("record preview returned a different response");
26901 };
26902 assert_eq!(record_id, record.record_id);
26903 assert_eq!(preview.messages.len(), 2);
26904 let encoded = serde_json::to_string(&preview).unwrap();
26905 assert!(!encoded.contains("stable-session"));
26906 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
26907 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
26908 panic!("record preview history summary returned a different response");
26909 };
26910 let summaries = events
26911 .iter()
26912 .filter_map(|envelope| match &envelope.event {
26913 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
26914 if record_id == &record.record_id =>
26915 {
26916 Some(*summary)
26917 }
26918 _ => None,
26919 })
26920 .collect::<Vec<_>>();
26921 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
26922 let summary_json = serde_json::to_string(&summaries).unwrap();
26923 for private in [
26924 "stable-session",
26925 "raw user text must not cross the catalog wire",
26926 "raw answer",
26927 "Workspace review",
26928 "claude-sonnet",
26929 ] {
26930 assert!(!summary_json.contains(private), "{private}");
26931 }
26932 let identity = record.provider_session.clone().unwrap();
26933 assert_eq!(
26934 shared.revalidate_session_record_preview(&record, &identity),
26935 Ok(())
26936 );
26937 shared.remove_record_memory(&record.record_id);
26938 assert_eq!(
26939 shared
26940 .revalidate_session_record_preview(&record, &identity)
26941 .unwrap_err()
26942 .code,
26943 NodeFailureCode::UnknownSessionRecord
26944 );
26945 let released = process_request_inner(
26946 &shared,
26947 7,
26948 ClientRole::Operator,
26949 NodeRequest::ReleaseController,
26950 )
26951 .await
26952 .unwrap();
26953 assert!(matches!(released, NodeResponse::Controller { controller: None }));
26954 assert!(shared.controller_state().is_none());
26955 std::fs::remove_dir_all(workspace_root).unwrap();
26956 std::fs::remove_dir_all(history_root).unwrap();
26957 std::fs::remove_dir_all(outside_root).unwrap();
26958 }
26959
26960 #[tokio::test]
26961 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
26962 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
26963 std::fs::create_dir_all(&history_root).unwrap();
26964 let history = NativeHistoryConfig::new(vec![
26965 NativeHistoryRoot::new(
26966 gate4agent_types::AdapterId::new("claude-code").unwrap(),
26967 HistorySourceLayout::SingleNdjson,
26968 history_root.clone(),
26969 )
26970 .unwrap(),
26971 ])
26972 .unwrap();
26973 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
26974 let held = catalog.lock().unwrap();
26975 let result = timeout(
26976 Duration::from_secs(1),
26977 run_native_session_catalog_operation(
26978 Arc::clone(&catalog),
26979 "native session catalog",
26980 |_| Ok::<(), std::convert::Infallible>(()),
26981 ),
26982 )
26983 .await
26984 .expect("concurrent native catalog request must not wait for the operation deadline")
26985 .unwrap_err();
26986 assert_eq!(result.code, NodeFailureCode::BackendBusy);
26987 drop(held);
26988 std::fs::remove_dir_all(history_root).unwrap();
26989 }
26990
26991 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
26992 let unique = std::time::SystemTime::now()
26993 .duration_since(std::time::UNIX_EPOCH)
26994 .unwrap()
26995 .as_nanos();
26996 std::env::temp_dir().join(format!(
26997 "gate4agent-node-{label}-{}-{unique}",
26998 std::process::id(),
26999 ))
27000 }
27001
27002 fn git_snapshot_for_parser() -> GitSnapshot {
27003 GitSnapshot {
27004 is_repository: true,
27005 branch: Some("main".to_owned()),
27006 status: Vec::new(),
27007 recent_commits: Vec::new(),
27008 worktrees: Vec::new(),
27009 managed_worktree: None,
27010 truncated: false,
27011 diagnostic: None,
27012 }
27013 }
27014
27015 #[tokio::test]
27016 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27017 let root = temporary_workspace_root("managed-git-scope");
27018 std::fs::create_dir_all(&root).unwrap();
27019 run_test_git(&root, &["init"]);
27020 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27021 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27022 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27023 run_test_git(&root, &["add", "seed.txt"]);
27024 run_test_git(&root, &["commit", "-m", "seed"]);
27025 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27026
27027 let workspace_id = WorkspaceId::new("managed-a").unwrap();
27028 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27029 let catalog = active_registry().unwrap();
27030 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27031 let shared = NodeShared::new(
27032 handle,
27033 "fixture-token".to_owned(),
27034 NodeId::new("node-1").unwrap(),
27035 vec![workspace],
27036 vec![agent("claude")],
27037 );
27038 let mut lease = ManagedWorktreeLeaseRecord {
27039 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27040 source_workspace_id: WorkspaceId::new("primary").unwrap(),
27041 workspace_id: workspace_id.clone(),
27042 profile_id: WorktreeProfileId::new("default").unwrap(),
27043 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27044 target_root: root.to_string_lossy().into_owned(),
27045 branch: "gate4agent/a".to_owned(),
27046 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27047 expected_head: None,
27048 retention: ManagedWorktreeRetention::Retain,
27049 state: ManagedWorktreeLeaseState::InUse,
27050 session_holders: vec![ManagedWorktreeSessionHolder {
27051 incarnation_id: shared.incarnation_id,
27052 instance_id: AgentInstanceId(7),
27053 generation: SessionGeneration(1),
27054 }],
27055 record_holders: Vec::new(),
27056 cleanup_failure: None,
27057 created_at_unix_ms: 1,
27058 updated_at_unix_ms: 1,
27059 };
27060 *shared
27061 .managed_worktrees
27062 .lock()
27063 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27064 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27065
27066 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27067 let scope = inspection.git.managed_worktree.unwrap();
27068 assert_eq!(scope.lease_id, lease.lease_id);
27069 assert_eq!(scope.branch, "gate4agent/a");
27070 assert_eq!(scope.active_session_count, 1);
27071
27072 lease.branch = "gate4agent/b".to_owned();
27073 *shared
27074 .managed_worktrees
27075 .lock()
27076 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27077 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27078 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27079 assert!(mismatch.git.managed_worktree.is_none());
27080 std::fs::remove_dir_all(root).unwrap();
27081 }
27082
27083 #[tokio::test]
27084 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27085 let root = temporary_workspace_root("workspace-file-write-controller");
27086 std::fs::create_dir_all(root.join("src")).unwrap();
27087 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27088 let workspace_id = WorkspaceId::new("primary").unwrap();
27089 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27090 let catalog = active_registry().unwrap();
27091 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27092 let shared = NodeShared::new(
27093 handle,
27094 "fixture-token".to_owned(),
27095 NodeId::new("node-1").unwrap(),
27096 vec![workspace],
27097 vec![agent("claude")],
27098 );
27099 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27100 let expected_revision = workspace_file_revision(b"old\n");
27101 let request = NodeRequest::WriteWorkspaceFile {
27102 workspace_id: workspace_id.clone(),
27103 path: path.clone(),
27104 expected_revision: expected_revision.clone(),
27105 text: "new\n".to_owned(),
27106 };
27107 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27108 .await
27109 .unwrap_err();
27110 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27111 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27112 .await
27113 .unwrap_err();
27114 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27115 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27116 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27117 let NodeResponse::WorkspaceFileWritten { file } = response else {
27118 panic!("workspace file write returned another response");
27119 };
27120 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27121 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27122 let stale = process_request_inner(
27123 &shared,
27124 77,
27125 ClientRole::Operator,
27126 NodeRequest::WriteWorkspaceFile {
27127 workspace_id,
27128 path,
27129 expected_revision,
27130 text: "lost update\n".to_owned(),
27131 },
27132 )
27133 .await
27134 .unwrap_err();
27135 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27136 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27137 std::fs::remove_dir_all(root).unwrap();
27138 }
27139
27140 #[tokio::test]
27141 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27142 let root = temporary_workspace_root("workspace-entry-create-controller");
27143 std::fs::create_dir_all(root.join("src")).unwrap();
27144 let workspace_id = WorkspaceId::new("primary").unwrap();
27145 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27146 let catalog = active_registry().unwrap();
27147 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27148 let shared = NodeShared::new(
27149 handle,
27150 "fixture-token".to_owned(),
27151 NodeId::new("node-1").unwrap(),
27152 vec![workspace],
27153 vec![agent("claude")],
27154 );
27155 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27156 let request = NodeRequest::CreateWorkspaceFile {
27157 workspace_id: workspace_id.clone(),
27158 path: path.clone(),
27159 };
27160 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27161 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27162 }));
27163 assert!(!request_uses_unnegotiated_capability(
27164 &request,
27165 &baseline_capabilities().unwrap(),
27166 ));
27167 assert!(request_uses_unnegotiated_capability(&request, &[]));
27168 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27169 .await
27170 .unwrap_err();
27171 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27172 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27173 .await
27174 .unwrap_err();
27175 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27176 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27177
27178 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27179 .await
27180 .unwrap();
27181 let NodeResponse::WorkspaceFileCreated { file } = response else {
27182 panic!("workspace file create returned another response");
27183 };
27184 assert_eq!(file.workspace_id, workspace_id);
27185 assert_eq!(file.path, path);
27186 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27187 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27188
27189 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27190 .await
27191 .unwrap_err();
27192 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27193
27194 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27195 let response = process_request_inner(
27196 &shared,
27197 77,
27198 ClientRole::Operator,
27199 NodeRequest::CreateWorkspaceDirectory {
27200 workspace_id: workspace_id.clone(),
27201 path: directory_path.clone(),
27202 },
27203 )
27204 .await
27205 .unwrap();
27206 assert_eq!(
27207 response,
27208 NodeResponse::WorkspaceDirectoryCreated {
27209 workspace_id: workspace_id.clone(),
27210 entry: WorkspaceEntry {
27211 relative_path: directory_path,
27212 kind: WorkspaceEntryKind::Directory,
27213 },
27214 },
27215 );
27216 assert!(root.join("src/nested").is_dir());
27217
27218 let missing_parent = process_request_inner(
27219 &shared,
27220 77,
27221 ClientRole::Operator,
27222 NodeRequest::CreateWorkspaceFile {
27223 workspace_id: workspace_id.clone(),
27224 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27225 },
27226 )
27227 .await
27228 .unwrap_err();
27229 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27230 let non_directory_parent = process_request_inner(
27231 &shared,
27232 77,
27233 ClientRole::Operator,
27234 NodeRequest::CreateWorkspaceFile {
27235 workspace_id,
27236 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27237 },
27238 )
27239 .await
27240 .unwrap_err();
27241 assert_eq!(
27242 non_directory_parent.code,
27243 NodeFailureCode::RepositoryParentNotDirectory,
27244 );
27245 std::fs::remove_dir_all(root).unwrap();
27246 }
27247
27248 #[tokio::test]
27249 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27250 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27251 std::fs::create_dir_all(root.join("src")).unwrap();
27252 #[cfg(windows)]
27253 let root = std::fs::canonicalize(root).unwrap();
27254 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27255 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27256 let barrier = Arc::new(std::sync::Barrier::new(2));
27257 let done = Arc::new(AtomicBool::new(false));
27258 let task_commit_state = Arc::clone(&commit_state);
27259 let task_barrier = Arc::clone(&barrier);
27260 let task_done = Arc::clone(&done);
27261 let task_root = root.clone();
27262 let task = tokio::task::spawn_blocking(move || {
27263 task_barrier.wait();
27264 let result = create_workspace_file_on_disk(
27265 &task_root,
27266 &path,
27267 &task_commit_state,
27268 );
27269 task_done.store(true, Ordering::Release);
27270 result
27271 });
27272
27273 let error = settle_workspace_entry_create(
27274 task,
27275 Arc::clone(&commit_state),
27276 Duration::from_millis(10),
27277 "fixture deadline",
27278 "fixture task failed",
27279 )
27280 .await
27281 .unwrap_err();
27282 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27283 assert_eq!(
27284 commit_state.load(Ordering::Acquire),
27285 WORKSPACE_ENTRY_CREATE_CANCELED,
27286 );
27287
27288 barrier.wait();
27289 timeout(Duration::from_secs(1), async {
27290 while !done.load(Ordering::Acquire) {
27291 tokio::task::yield_now().await;
27292 }
27293 })
27294 .await
27295 .expect("canceled blocking create did not settle");
27296 assert!(!root.join("src/late.rs").exists());
27297 std::fs::remove_dir_all(root).unwrap();
27298 }
27299
27300}