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;
210pub mod node_wake;
211
212mod browser_bridge;
213
214use gate4agent_node_core::drive_loop_profile;
215
216#[path = "bundle_delivery.rs"]
217mod bundle_delivery;
218
219#[path = "context_pack_store.rs"]
220mod context_pack_store;
221
222#[cfg(windows)]
223pub use crate::platform::DEFAULT_NODE_ENDPOINT;
224
225const NODE_EVENT_HISTORY_MAX: usize = 4_096;
226const NODE_BROADCAST_CAPACITY: usize = 1_024;
227const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
228const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
234const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
241const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
242const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
243const MAX_PREAUTH_CONNECTIONS: usize = 32;
244const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
245const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
246const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
247const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
248const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
249const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
257const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
265const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
272const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
277const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
278const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
279const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
280const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
281const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
282const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
283const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
284const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
285const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
286const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
287const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
288const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
289const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
292const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
299const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
302const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
303const GIT_STATUS_MAX_ENTRIES: usize = 128;
304const GIT_COMMIT_MAX_ENTRIES: usize = 12;
305const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
306const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
307const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
308const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
309const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
310const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
311const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
312const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
313const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
314const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
315 "provider-session-identity-allocation-failed";
316const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
317const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
318const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
319const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
320const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
321const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
322const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
323 "durable-state-path-semantics-unsupported";
324
325fn opaque_windows_path(value: String) -> OpaqueHostPath {
326 OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
327}
328
329fn agent_progress_from_provider_snapshot(
330 address: SessionAddress,
331 provider: &ProviderSnapshot,
332) -> Option<SessionAgentProgress> {
333 let mut truncated = false;
334 let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
335 let mut active_tool_labels = Vec::with_capacity(
336 provider
337 .active_tools
338 .len()
339 .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
340 );
341 for tool in &provider.active_tools {
342 if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
343 truncated = true;
344 break;
345 }
346 if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
347 active_tool_labels.push(label);
348 }
349 }
350 let attention = provider
351 .interactions
352 .iter()
353 .find(|interaction| {
354 matches!(
355 interaction.status,
356 ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
357 )
358 })
359 .map(|interaction| AgentProgressAttentionV1 {
360 kind: match interaction.interaction_kind {
361 ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
362 ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
363 },
364 tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
365 });
366 let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
367 let usage = (provider.completed_turns > 0
368 || provider.usage.input_tokens > 0
369 || provider.usage.output_tokens > 0
370 || provider.usage.cache_read_tokens > 0
371 || provider.usage.cache_write_tokens > 0
372 || provider.usage.reasoning_tokens > 0)
373 .then_some(AgentProgressUsageV1 {
374 input_tokens: provider.usage.input_tokens,
375 output_tokens: provider.usage.output_tokens,
376 cache_read_tokens: provider.usage.cache_read_tokens,
377 cache_write_tokens: provider.usage.cache_write_tokens,
378 reasoning_tokens: provider.usage.reasoning_tokens,
379 });
380 let progress = AgentProgressV1 {
381 provider_sequence: provider.sequence,
382 activity: provider.activity,
383 completed_turns: provider.completed_turns,
384 usage,
385 current: AgentProgressCurrentV1::from(provider.activity),
386 active_tool_labels,
387 active_tool_count,
388 attention,
389 subagent_count,
390 last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
391 gap_count: provider.gap_count,
392 stale: provider.stale,
393 truncated,
394 };
395 let entry = SessionAgentProgress { address, progress };
396 serde_json::to_vec(&entry)
397 .ok()
398 .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
399 .map(|_| entry)
400}
401
402fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
403 match u32::try_from(count) {
404 Ok(count) => count,
405 Err(_) => {
406 *truncated = true;
407 u32::MAX
408 }
409 }
410}
411
412fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
413 if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
414 return None;
415 }
416 Some(match event {
417 ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
418 ProviderEvent::SessionIdentityObserved { .. } => {
419 AgentProgressEventKindV1::SessionIdentityObserved
420 }
421 ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
422 ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
423 ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
424 ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
425 ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
426 ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
427 ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
428 ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
429 ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
430 ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
431 ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
432 ProviderEvent::InteractionRequested { .. } => {
433 AgentProgressEventKindV1::InteractionRequested
434 }
435 ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
436 ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
437 ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
438 ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
439 ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
440 ProviderEvent::UnrecognizedNotification { .. } => {
441 AgentProgressEventKindV1::UnrecognizedNotification
442 }
443 ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
444 ProviderEvent::UserMessage { .. }
455 | ProviderEvent::Plan { .. }
456 | ProviderEvent::AvailableCommandsUpdated { .. }
457 | ProviderEvent::ModeChanged { .. }
458 | ProviderEvent::SessionInfoUpdated { .. }
459 | ProviderEvent::UsageUpdated { .. }
460 | ProviderEvent::ConfigOptionsUpdated { .. } => {
461 AgentProgressEventKindV1::UnrecognizedNotification
462 }
463 })
464}
465
466fn provider_family_is_live(family: AdapterFamily) -> bool {
479 match family {
480 AdapterFamily::PtySemantic
481 | AdapterFamily::Hook
482 | AdapterFamily::ManagedHook
483 | AdapterFamily::Pipe
484 | AdapterFamily::OneShot
485 | AdapterFamily::Acp => true,
486 AdapterFamily::History
487 | AdapterFamily::Resume
488 | AdapterFamily::SessionOptions
489 | AdapterFamily::CapabilityProbe => false,
490 }
491}
492
493fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
494 match by {
495 ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
496 ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
497 ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
498 ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
499 }
500}
501
502fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
514 match authority {
515 BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
516 BlockAuthorityV1::HostPolicy => "denied by host policy",
517 BlockAuthorityV1::HostDeadline => {
518 "denied by host policy after the operator deadline expired"
519 }
520 BlockAuthorityV1::Operator => "declined by the operator",
521 BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
522 BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
523 BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
524 BlockAuthorityV1::ProviderRefusal => "refused by the provider",
525 BlockAuthorityV1::UserRejected => "rejected by the user",
526 BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
527 BlockAuthorityV1::Unknown => "blocked; no reason was reported",
528 }
529}
530
531fn host_request_denied_block(
534 method: &str,
535 by: ProviderHostDecisionAuthority,
536 reason: Option<&str>,
537) -> (String, BlockAuthorityV1, String) {
538 let authority = block_authority(by);
539 let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
540 let (reason_text, _) =
544 truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
545 (tool_class_label(method), authority, reason_text)
546}
547
548fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
567 match kind? {
568 "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
569 "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
570 _ => None,
571 }
572}
573
574fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
586 let trimmed = text.trim();
587 let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
588 return (trimmed.to_owned(), None);
589 };
590 let tail = after_reason.trim_start();
591 let Some((reason, help)) = tail.split_once(". ") else {
592 let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
596 return (reason.to_owned(), None);
597 };
598 let help = help.trim();
599 if help.is_empty() {
600 (reason.trim().to_owned(), None)
601 } else {
602 (reason.trim().to_owned(), Some(help.to_owned()))
603 }
604}
605
606fn tool_class_from_denial_help(help: Option<&str>) -> String {
616 help.and_then(|text| text.split("add a ").nth(1))
617 .and_then(|tail| tail.split(' ').next())
618 .filter(|word| !word.is_empty())
619 .map(str::to_owned)
620 .unwrap_or_else(|| "Tool".to_owned())
621}
622
623fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
631 fn names_quota(s: &str) -> bool {
632 let lower = s.to_ascii_lowercase();
633 lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
634 }
635 vendor_code.is_some_and(names_quota) || names_quota(message)
636}
637
638fn provider_session_end_block(
654 stop_reason: &ProviderStopReason,
655) -> Option<(BlockAuthorityV1, String, String)> {
656 match stop_reason {
657 ProviderStopReason::Refusal => Some((
658 BlockAuthorityV1::ProviderRefusal,
659 "refusal".to_owned(),
660 "the provider ended the turn with stopReason: refusal".to_owned(),
661 )),
662 ProviderStopReason::ProviderError { message, vendor_code, .. }
663 if provider_error_is_quota(vendor_code.as_deref(), message) =>
664 {
665 let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
666 Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
667 }
668 _ => None,
669 }
670}
671
672fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
713 let ControlEventKind::ProviderEvent {
714 sequence: provider_sequence,
715 source,
716 source_sequence,
717 event: provider_event,
718 } = &event.event
719 else {
720 return None;
721 };
722 if !provider_family_is_live(source.family) {
723 return None;
724 }
725 let kind = match provider_event {
726 ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
727 text: text.clone(),
728 is_delta: *is_delta,
729 },
730 ProviderEvent::Thinking { text } => {
731 AgentStreamChunkKindV1::Thinking { text: text.clone() }
732 }
733 ProviderEvent::InteractionRequested {
734 interaction_kind,
735 tool_name,
736 title,
737 prompt,
738 options,
739 ..
740 } => AgentStreamChunkKindV1::InteractionPrompt {
741 correlation_id: interaction_correlation(
742 event.instance_id,
743 event.generation,
744 *provider_sequence,
745 ),
746 interaction_kind: *interaction_kind,
747 tool_name: tool_name.clone(),
748 title: title.clone(),
757 prompt: prompt.clone(),
758 options: options
759 .iter()
760 .map(|option| AgentStreamInteractionOptionV1 {
761 option_id: option.option_id.clone(),
762 name: option.name.clone(),
763 kind: option.kind.clone(),
764 })
765 .collect(),
766 },
767 ProviderEvent::ConfigOptionsUpdated { options } => {
768 AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
769 }
770 ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
781 current: Some(mode_id.clone()),
782 available: available
783 .iter()
784 .map(|mode| AgentStreamNamedIdV1 {
785 id: mode.id.clone(),
786 name: mode.name.clone(),
787 description: mode.description.clone(),
788 })
789 .collect(),
790 },
791 ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
795 let ProviderHostRequestDecision::Denied { by } = decision else {
796 return None;
797 };
798 let (tool_class, authority, reason_text) =
799 host_request_denied_block(method, *by, reason.as_deref());
800 AgentStreamChunkKindV1::Blocked {
801 correlation_id: None,
802 tool_class,
803 authority,
804 reason_kind: None,
805 reason: reason_text,
806 help: None,
807 }
808 }
809 ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
813 let (authority, reason_kind) =
814 provider_tool_block_authority(non_execution_kind.as_deref())?;
815 let (reason, help) = split_provider_denial_text(output);
816 let (reason, _) =
817 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
818 let help = help.map(|help| {
819 truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
820 });
821 AgentStreamChunkKindV1::Blocked {
822 correlation_id: Some(tool_correlation(event, source, id)),
823 tool_class: tool_class_from_denial_help(help.as_deref()),
824 authority,
825 reason_kind: Some(reason_kind.to_owned()),
826 reason,
827 help,
828 }
829 }
830 ProviderEvent::SessionEnded { stop_reason, .. } => {
834 let (authority, reason_kind, reason) =
835 stop_reason.as_ref().and_then(provider_session_end_block)?;
836 let (reason, _) =
837 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
838 AgentStreamChunkKindV1::Blocked {
839 correlation_id: None,
840 tool_class: "Session".to_owned(),
841 authority,
842 reason_kind: Some(reason_kind),
843 reason,
844 help: None,
845 }
846 }
847 ProviderEvent::SessionStarted { .. }
848 | ProviderEvent::SessionIdentityObserved { .. }
849 | ProviderEvent::TurnStarted { .. }
850 | ProviderEvent::WorkingObserved
851 | ProviderEvent::ToolStarted { .. }
852 | ProviderEvent::TurnCompleted { .. }
853 | ProviderEvent::ContextWindowUsage { .. }
854 | ProviderEvent::TurnInterrupted
855 | ProviderEvent::Error { .. }
856 | ProviderEvent::Ready
857 | ProviderEvent::InteractionResolved { .. }
858 | ProviderEvent::SubagentStarted { .. }
859 | ProviderEvent::SubagentStopped { .. }
860 | ProviderEvent::RateLimited { .. }
861 | ProviderEvent::UnrecognizedNotification { .. }
862 | ProviderEvent::UserMessage { .. }
863 | ProviderEvent::Plan { .. }
864 | ProviderEvent::AvailableCommandsUpdated { .. }
865 | ProviderEvent::SessionInfoUpdated { .. }
866 | ProviderEvent::UsageUpdated { .. } => return None,
867 };
868 Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
869}
870
871async fn run_native_session_catalog_operation<T, E, F>(
872 catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
873 operation: &'static str,
874 execute: F,
875) -> Result<T, NodeFailure>
876where
877 T: Send + 'static,
878 E: std::fmt::Display + Send + 'static,
879 F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
880{
881 let task = tokio::task::spawn_blocking(move || {
882 let mut catalog = match catalog.try_lock() {
883 Ok(catalog) => catalog,
884 Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
885 Err(std::sync::TryLockError::WouldBlock) => {
886 return Err(failure(
887 NodeFailureCode::BackendBusy,
888 "native session catalog operation is already running",
889 ));
890 }
891 };
892 execute(&mut catalog).map_err(|error| {
893 failure(
894 NodeFailureCode::BackendOperationFailed,
895 &format!("{operation} failed: {error}"),
896 )
897 })
898 });
899 timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
900 .await
901 .map_err(|_| {
902 failure(
903 NodeFailureCode::BackendBusy,
904 &format!("{operation} exceeded its bounded deadline"),
905 )
906 })?
907 .map_err(|_| {
908 failure(
909 NodeFailureCode::BackendOperationFailed,
910 &format!("{operation} task failed"),
911 )
912 })?
913}
914
915fn windows_path_text(path: &OpaqueHostPath) -> &str {
916 path.as_utf8()
917 .expect("Windows node only creates UTF-8 opaque host paths")
918}
919
920fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
921 path.as_utf8()
922 .map(str::to_owned)
923 .ok_or_else(|| failure(
924 NodeFailureCode::InvalidRequest,
925 "this node requires UTF-8 host paths",
926 ))
927}
928
929fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
930 GitWorktreeSnapshot {
931 path: opaque_windows_path(worktree.path),
932 head: worktree.head,
933 branch: worktree.branch,
934 is_bare: worktree.is_bare,
935 is_main: worktree.is_main,
936 locked: worktree.locked,
937 lock_reason: worktree.lock_reason,
938 prunable: worktree.prunable,
939 prunable_reason: worktree.prunable_reason,
940 workspace_id: worktree.workspace_id.take(),
941 }
942}
943
944#[derive(Clone, Debug, Eq, PartialEq)]
945pub struct WorkspaceConfig {
946 workspace_id: WorkspaceId,
947 canonical_root: String,
948 worktree_service_mode: WorktreeServiceMode,
949 managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
950}
951
952impl WorkspaceConfig {
953 pub fn new(
954 workspace_id: WorkspaceId,
955 root: impl AsRef<Path>,
956 ) -> Result<Self, NodeServerError> {
957 let root = root.as_ref();
958 if !root.is_absolute() {
959 return Err(NodeServerError::InvalidWorkspaceRoot {
960 workspace_id,
961 path: root.to_string_lossy().into_owned(),
962 message: "path is not absolute".to_owned(),
963 });
964 }
965 let canonical = std::fs::canonicalize(root).map_err(|error| {
966 NodeServerError::InvalidWorkspaceRoot {
967 workspace_id: workspace_id.clone(),
968 path: root.to_string_lossy().into_owned(),
969 message: error.to_string(),
970 }
971 })?;
972 if !canonical.is_dir() {
973 return Err(NodeServerError::InvalidWorkspaceRoot {
974 workspace_id,
975 path: root.to_string_lossy().into_owned(),
976 message: "path is not a directory".to_owned(),
977 });
978 }
979 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
980 NodeServerError::InvalidWorkspaceRoot {
981 workspace_id: workspace_id.clone(),
982 path: path.to_string_lossy().into_owned(),
983 message: "canonical path is not valid Unicode".to_owned(),
984 }
985 })?;
986 let canonical_root = platform::normalize_canonical_root(canonical_root);
987 validate_workspace_root(&workspace_id, &canonical_root)?;
988 Ok(Self {
989 workspace_id,
990 canonical_root,
991 worktree_service_mode: WorktreeServiceMode::Manual,
992 managed_worktree_profiles: BTreeMap::new(),
993 })
994 }
995
996 pub fn workspace_id(&self) -> &WorkspaceId {
997 &self.workspace_id
998 }
999
1000 pub fn canonical_root(&self) -> &str {
1001 &self.canonical_root
1002 }
1003
1004 pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1005 self.worktree_service_mode = mode;
1006 self
1007 }
1008
1009 pub fn with_managed_worktree_profile(
1010 mut self,
1011 profile: ManagedWorktreeProfile,
1012 ) -> Result<Self, NodeServerError> {
1013 profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1014 NodeServerError::InvalidManagedWorktreeProfile {
1015 workspace_id: self.workspace_id.clone(),
1016 profile_id: profile.profile_id().clone(),
1017 message,
1018 }
1019 })?;
1020 if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1021 return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1022 workspace_id: self.workspace_id,
1023 profile_id: profile.profile_id().clone(),
1024 });
1025 }
1026 if self.managed_worktree_profiles.len()
1027 == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1028 {
1029 return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1030 workspace_id: self.workspace_id,
1031 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1032 });
1033 }
1034 self.managed_worktree_profiles
1035 .insert(profile.profile_id().clone(), profile);
1036 Ok(self)
1037 }
1038}
1039
1040#[derive(Clone)]
1041pub struct NodeServerConfig {
1042 pub endpoint: String,
1043 api_listen: Option<std::net::SocketAddr>,
1044 bridge_listen: Option<std::net::SocketAddr>,
1047 bridge_token: Option<String>,
1050 bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1053 call_home: Option<std::net::SocketAddr>,
1056 call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1060 pub node_id: NodeId,
1061 pub workspaces: Vec<WorkspaceConfig>,
1062 access_token: String,
1063 pub runtime: NativeRuntimeConfig,
1064 session_record_retention: SessionRecordRetentionConfig,
1065 state_path: Option<PathBuf>,
1066 spawn_profiles: SpawnProfileRegistry,
1067 session_environment: Option<NodeSessionEnvironmentConfig>,
1068 history: Option<NativeHistoryConfig>,
1069 harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1070 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1074 #[cfg(feature = "fixture")]
1075 fixture_raw_pty_runtime: bool,
1076}
1077
1078#[derive(Clone)]
1079struct NodeSessionEnvironmentConfig {
1080 root: PathBuf,
1081 resolver: Arc<dyn NodeSecretResolver>,
1082}
1083
1084impl NodeServerConfig {
1085 pub fn new(
1086 endpoint: impl Into<String>,
1087 access_token: impl Into<String>,
1088 node_id: NodeId,
1089 workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1090 ) -> Result<Self, NodeServerError> {
1091 let endpoint = endpoint.into();
1092 let access_token = access_token.into();
1093 if !platform::validate_endpoint(&endpoint) {
1094 return Err(NodeServerError::InvalidEndpoint);
1095 }
1096 if access_token.is_empty() || access_token.len() > 4_096 {
1097 return Err(NodeServerError::InvalidAccessToken);
1098 }
1099 let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1100 if workspaces.is_empty() {
1101 return Err(NodeServerError::NoWorkspaces);
1102 }
1103 let mut workspace_ids = BTreeMap::new();
1104 let mut workspace_roots = BTreeMap::new();
1105 for workspace in &workspaces {
1106 if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1107 && workspace.managed_worktree_profiles.is_empty()
1108 {
1109 return Err(NodeServerError::ManagedWorktreeProfileRequired(
1110 workspace.workspace_id.clone(),
1111 ));
1112 }
1113 if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1114 && !workspace.managed_worktree_profiles.is_empty()
1115 {
1116 return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1117 workspace.workspace_id.clone(),
1118 ));
1119 }
1120 if workspace_ids
1121 .insert(workspace.workspace_id.clone(), ())
1122 .is_some()
1123 {
1124 return Err(NodeServerError::DuplicateWorkspaceId(
1125 workspace.workspace_id.clone(),
1126 ));
1127 }
1128 let root_key = platform::root_identity(&workspace.canonical_root);
1129 if let Some(existing) = workspace_roots.insert(
1130 root_key,
1131 workspace.workspace_id.clone(),
1132 ) {
1133 return Err(NodeServerError::DuplicateWorkspaceRoot {
1134 first: existing,
1135 second: workspace.workspace_id.clone(),
1136 root: workspace.canonical_root.clone(),
1137 });
1138 }
1139 }
1140 Ok(Self {
1141 endpoint,
1142 api_listen: None,
1143 bridge_listen: None,
1144 bridge_token: None,
1145 bridge_underlay: None,
1146 call_home: None,
1147 call_home_identity: None,
1148 node_id,
1149 workspaces,
1150 access_token,
1151 runtime: NativeRuntimeConfig::default(),
1152 session_record_retention: SessionRecordRetentionConfig::default(),
1153 state_path: None,
1154 spawn_profiles: SpawnProfileRegistry::default(),
1155 session_environment: None,
1156 history: None,
1157 harness_mcp_helper: None,
1158 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1159 #[cfg(feature = "fixture")]
1160 fixture_raw_pty_runtime: false,
1161 })
1162 }
1163
1164 pub fn with_api_listen(
1165 mut self,
1166 api_listen: std::net::SocketAddr,
1167 ) -> Result<Self, NodeServerError> {
1168 if !api_listen.ip().is_loopback() {
1169 return Err(NodeServerError::InvalidApiListen(api_listen));
1170 }
1171 self.api_listen = Some(api_listen);
1172 Ok(self)
1173 }
1174
1175 pub fn with_bridge_listen(
1179 mut self,
1180 bridge_listen: std::net::SocketAddr,
1181 ) -> Result<Self, NodeServerError> {
1182 if !bridge_listen.ip().is_loopback() {
1183 return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1184 }
1185 self.bridge_listen = Some(bridge_listen);
1186 Ok(self)
1187 }
1188
1189 pub fn with_bridge_token(
1192 mut self,
1193 bridge_token: impl Into<String>,
1194 ) -> Result<Self, NodeServerError> {
1195 let bridge_token = bridge_token.into();
1196 if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1197 return Err(NodeServerError::InvalidBridgeToken);
1198 }
1199 self.bridge_token = Some(bridge_token);
1200 Ok(self)
1201 }
1202
1203 pub fn with_bridge_underlay(
1208 mut self,
1209 bind: std::net::SocketAddr,
1210 transport_key: [u8; 32],
1211 underlay_token: impl Into<String>,
1212 ) -> Result<Self, NodeServerError> {
1213 if self.bridge_listen.is_none() {
1214 return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1215 }
1216 if self.bridge_token.is_none() {
1217 return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1218 }
1219 let underlay_token = underlay_token.into();
1220 if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1221 return Err(NodeServerError::InvalidBridgeUnderlayToken);
1222 }
1223 self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1225 bind,
1226 transport_key,
1227 underlay_token,
1228 });
1229 Ok(self)
1230 }
1231
1232 pub fn with_call_home(
1253 mut self,
1254 relay: std::net::SocketAddr,
1255 ) -> Result<Self, NodeServerError> {
1256 if relay.port() == 0 || relay.ip().is_unspecified() {
1259 return Err(NodeServerError::InvalidCallHome(relay));
1260 }
1261 self.call_home = Some(relay);
1262 Ok(self)
1263 }
1264
1265 pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1267 self.call_home_identity = Some(identity);
1268 self
1269 }
1270
1271 pub fn with_state_path(
1272 mut self,
1273 state_path: impl Into<PathBuf>,
1274 ) -> Result<Self, NodeServerError> {
1275 let state_path = state_path.into();
1276 if !state_path.is_absolute() || state_path.file_name().is_none() {
1277 return Err(NodeServerError::InvalidStatePath(
1278 state_path.to_string_lossy().into_owned(),
1279 ));
1280 }
1281 self.state_path = Some(state_path);
1282 Ok(self)
1283 }
1284
1285 pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1286 self.spawn_profiles = spawn_profiles;
1287 self
1288 }
1289
1290 pub fn with_session_record_retention(
1296 mut self,
1297 retention: SessionRecordRetentionConfig,
1298 ) -> Self {
1299 self.session_record_retention = retention;
1300 self
1301 }
1302
1303 pub fn with_session_environment_materialization(
1304 mut self,
1305 root: impl Into<PathBuf>,
1306 resolver: Arc<dyn NodeSecretResolver>,
1307 ) -> Result<Self, NodeServerError> {
1308 let root = root.into();
1309 if !root.is_absolute() {
1310 return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1311 }
1312 self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1313 Ok(self)
1314 }
1315
1316 pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1319 self.history = Some(history);
1320 self
1321 }
1322
1323 pub fn with_harness_mcp_helper(
1329 mut self,
1330 helper_program: impl Into<PathBuf>,
1331 ) -> Result<Self, NodeServerError> {
1332 self.harness_mcp_helper = Some(
1333 ReviewedHarnessMcpProgram::review(helper_program.into())
1334 .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1335 );
1336 Ok(self)
1337 }
1338
1339 pub fn with_network_allowlist_catalog(
1344 mut self,
1345 catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1346 ) -> Result<Self, NodeServerError> {
1347 if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1348 return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1349 max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1350 });
1351 }
1352 self.network_allowlist_catalog = catalog;
1353 Ok(self)
1354 }
1355
1356 pub fn with_network_allowlist_catalog_file(
1360 self,
1361 path: impl AsRef<std::path::Path>,
1362 ) -> Result<Self, NodeServerError> {
1363 let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1364 .map_err(NodeServerError::from)?;
1365 self.with_network_allowlist_catalog(catalog)
1366 }
1367}
1368
1369pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1370 platform::default_state_path(node_id.as_str())
1371 .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1372}
1373
1374pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1375 platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1376}
1377
1378fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1379 let parent = state_path.parent()?;
1380 let mut name = state_path.file_name()?.to_os_string();
1381 name.push(".delivery-store");
1382 Some(parent.join(name))
1383}
1384
1385fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1386 let parent = state_path.parent()?;
1387 let mut name = state_path.file_name()?.to_os_string();
1388 name.push(".context-pack-store");
1389 Some(parent.join(name))
1390}
1391
1392pub struct NodeServer {
1393 config: NodeServerConfig,
1394 runtime: NativeRuntime,
1395 shared: Arc<NodeShared>,
1396 events: EventSubscription,
1397 state_path_lock: Option<session_registry::StatePathLock>,
1398}
1399
1400#[cfg(feature = "fixture")]
1401#[derive(Clone, Default)]
1402pub struct SpawnManagedWorktreeV2FailureProbe {
1403 latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1404}
1405
1406#[cfg(feature = "fixture")]
1407impl SpawnManagedWorktreeV2FailureProbe {
1408 pub fn latest_code(&self) -> Option<NodeFailureCode> {
1409 *self
1410 .latest_code
1411 .lock()
1412 .unwrap_or_else(|poisoned| poisoned.into_inner())
1413 }
1414
1415 fn record(&self, code: Option<NodeFailureCode>) {
1416 *self
1417 .latest_code
1418 .lock()
1419 .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1420 }
1421}
1422
1423#[cfg(all(feature = "fixture", windows))]
1424struct IsolatedCodexHomeFixtureEnvironment;
1425
1426#[cfg(all(feature = "fixture", windows))]
1427impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1428 for IsolatedCodexHomeFixtureEnvironment
1429{
1430 fn resolve_child_environment(
1431 &self,
1432 ) -> Result<
1433 Vec<gate4agent_catalog::EnvMutation>,
1434 gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1435 > {
1436 Ok(vec![gate4agent_catalog::EnvMutation {
1437 key: OsString::from("USERPROFILE"),
1438 value: None,
1439 }])
1440 }
1441}
1442
1443impl NodeServer {
1444 pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1447 self.shared.m4a_accounts.as_ref()?;
1448 Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1449 }
1450
1451 pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1452 let catalog = active_registry()?;
1453 Self::new_with_registry(config, catalog)
1454 }
1455
1456 #[cfg(feature = "fixture")]
1457 pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1458 let mut spec = gate4agent_testkit::interactive_agent_spec();
1459 spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1460 spec.display_name = "Controlled Claude PTY fixture".to_owned();
1461 Self::new_fixture_with_spec(config, spec)
1462 }
1463
1464 #[cfg(feature = "fixture")]
1465 pub fn new_clean_exit_fixture(
1466 config: NodeServerConfig,
1467 fixture_root: PathBuf,
1468 started_marker: PathBuf,
1469 release_signal: PathBuf,
1470 ) -> Result<Self, NodeServerError> {
1471 let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1472 &fixture_root,
1473 &started_marker,
1474 &release_signal,
1475 )
1476 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1477 spec.id = AgentId::new("claude")
1478 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1479 spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1480 Self::new_fixture_with_spec(config, spec)
1481 }
1482
1483 #[cfg(all(feature = "fixture", windows))]
1493 pub fn new_durable_context_pack_clean_exit_fixture(
1494 config: NodeServerConfig,
1495 fixture_root: PathBuf,
1496 started_marker: PathBuf,
1497 release_signal: PathBuf,
1498 session_id: &str,
1499 ) -> Result<Self, NodeServerError> {
1500 let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1501 &fixture_root,
1502 &started_marker,
1503 &release_signal,
1504 session_id,
1505 )
1506 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1507 let source_id = AgentId::new("claude")
1508 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1509 let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1510 NodeServerError::Registry(
1511 "durable context pack clean-exit fixture source adapter is unavailable"
1512 .to_owned(),
1513 )
1514 })?;
1515 source_spec.capabilities.adapters.history =
1516 source_provider.capabilities.adapters.history.clone();
1517 source_spec.id = source_id;
1518 source_spec.display_name =
1519 "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1520 source_spec.detection.command =
1526 "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1527 source_spec.detection.aliases.clear();
1528
1529 let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1530 target_spec.id = AgentId::new("codex")
1531 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1532 target_spec.display_name =
1533 "Controlled Codex durable context-pack target fixture".to_owned();
1534 target_spec.detection.command =
1535 "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1536 target_spec.detection.aliases.clear();
1537
1538 let catalog = AgentRegistry::new([source_spec, target_spec])
1539 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1540 let mut server = Self::new_with_registry(config, catalog)?;
1541 Arc::get_mut(&mut server.shared)
1542 .expect("fixture Node shared state must remain uniquely owned before run")
1543 .fixture_semantic_hook_policy = true;
1544 Ok(server)
1545 }
1546
1547 #[cfg(feature = "fixture")]
1548 pub fn new_monitoring_hook_fixture(
1549 config: NodeServerConfig,
1550 ) -> Result<Self, NodeServerError> {
1551 let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1552 spec.id = AgentId::new("claude")
1553 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1554 let mut server = Self::new_fixture_with_spec(config, spec)?;
1555 Arc::get_mut(&mut server.shared)
1556 .expect("fixture Node shared state must remain uniquely owned before run")
1557 .fixture_semantic_hook_policy = true;
1558 Ok(server)
1559 }
1560
1561 #[cfg(all(feature = "fixture", windows))]
1562 pub fn new_provider_bundle_argv_fixture(
1563 config: NodeServerConfig,
1564 agent_id: AgentId,
1565 proof_path: PathBuf,
1566 ) -> Result<Self, NodeServerError> {
1567 let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1568 Self::new_fixture_with_spec(config, spec)
1569 }
1570
1571 #[cfg(all(feature = "fixture", windows))]
1572 pub fn new_provider_bundle_argv_hold_fixture(
1573 config: NodeServerConfig,
1574 agent_id: AgentId,
1575 proof_path: PathBuf,
1576 release_signal: PathBuf,
1577 ) -> Result<Self, NodeServerError> {
1578 let spec = Self::provider_bundle_argv_fixture_spec(
1579 agent_id,
1580 proof_path,
1581 Some(release_signal),
1582 )?;
1583 Self::new_fixture_with_spec(config, spec)
1584 }
1585
1586 #[cfg(all(feature = "fixture", windows))]
1587 fn provider_bundle_argv_fixture_spec(
1588 agent_id: AgentId,
1589 proof_path: PathBuf,
1590 release_signal: Option<PathBuf>,
1591 ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1592 if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1593 return Err(NodeServerError::Registry(
1594 "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1595 ));
1596 }
1597 let mut spec = gate4agent_testkit::interactive_agent_spec();
1598 spec.id = agent_id.clone();
1599 spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1600 let provider = builtin_registry()
1601 .get(&agent_id)
1602 .ok_or_else(|| {
1603 NodeServerError::Registry(
1604 "provider bundle argv fixture adapter is unavailable".to_owned(),
1605 )
1606 })?;
1607 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1608 if !proof_path.is_absolute()
1609 || !proof_path
1610 .parent()
1611 .is_some_and(|parent| parent.is_dir())
1612 {
1613 return Err(NodeServerError::Registry(
1614 "provider bundle argv proof path requires an existing absolute parent"
1615 .to_owned(),
1616 ));
1617 }
1618 if let Some(release_signal) = release_signal.as_ref() {
1619 if !release_signal.is_absolute()
1620 || !release_signal
1621 .parent()
1622 .is_some_and(|parent| parent.is_dir())
1623 {
1624 return Err(NodeServerError::Registry(
1625 "provider bundle argv release path requires an existing absolute parent"
1626 .to_owned(),
1627 ));
1628 }
1629 if release_signal.exists() {
1630 return Err(NodeServerError::Registry(
1631 "provider bundle argv release path must not exist".to_owned(),
1632 ));
1633 }
1634 let proof_parent = std::fs::canonicalize(
1635 proof_path
1636 .parent()
1637 .expect("validated provider bundle proof parent"),
1638 )
1639 .map_err(|_| {
1640 NodeServerError::Registry(
1641 "provider bundle argv proof parent is unavailable".to_owned(),
1642 )
1643 })?;
1644 let release_parent = std::fs::canonicalize(
1645 release_signal
1646 .parent()
1647 .expect("validated provider bundle release parent"),
1648 )
1649 .map_err(|_| {
1650 NodeServerError::Registry(
1651 "provider bundle argv release parent is unavailable".to_owned(),
1652 )
1653 })?;
1654 if proof_parent != release_parent {
1655 return Err(NodeServerError::Registry(
1656 "provider bundle argv release path must share the proof parent".to_owned(),
1657 ));
1658 }
1659 }
1660 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1661 NodeServerError::Registry(
1662 "provider bundle argv proof path is not valid Unicode".to_owned(),
1663 )
1664 })?;
1665 let release_signal = release_signal.map(|release_signal| {
1666 release_signal.into_os_string().into_string().map_err(|_| {
1667 NodeServerError::Registry(
1668 "provider bundle argv release path is not valid Unicode".to_owned(),
1669 )
1670 })
1671 }).transpose()?;
1672 let script = spec
1673 .launch
1674 .fixed_args
1675 .pop()
1676 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1677 let bundle_validation = if agent_id.as_str() == "codex" {
1678 "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') }"
1679 } else if agent_id.as_str() == "claude" {
1680 "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 }"
1681 } else {
1682 "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 }"
1683 };
1684 if let Some(release_signal) = release_signal {
1685 spec.launch.fixed_args.push(format!(
1686 "& {{ 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} }}",
1687 ));
1688 spec.launch.fixed_args.push(proof_path);
1689 spec.launch.fixed_args.push(release_signal);
1690 } else {
1691 spec.launch.fixed_args.push(format!(
1692 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1693 ));
1694 spec.launch.fixed_args.push(proof_path);
1695 }
1696 Ok(spec)
1697 }
1698
1699 #[cfg(all(feature = "fixture", windows))]
1700 pub fn new_context_pack_fixture(
1701 config: NodeServerConfig,
1702 proof_path: PathBuf,
1703 ) -> Result<Self, NodeServerError> {
1704 let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1705 Self::new_with_registry(config, catalog)
1706 }
1707
1708 #[cfg(all(feature = "fixture", windows))]
1709 pub fn new_monitoring_context_pack_fixture(
1710 config: NodeServerConfig,
1711 proof_path: PathBuf,
1712 ) -> Result<Self, NodeServerError> {
1713 let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1714 let mut server = Self::new_with_registry(config, catalog)?;
1715 Arc::get_mut(&mut server.shared)
1716 .expect("fixture Node shared state must remain uniquely owned before run")
1717 .fixture_semantic_hook_policy = true;
1718 Ok(server)
1719 }
1720
1721 #[cfg(all(feature = "fixture", windows))]
1722 pub fn install_isolated_codex_home_environment_fixture(
1723 &mut self,
1724 profile_id: SpawnEnvironmentProfileId,
1725 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1726 ) -> Result<(), NodeServerError> {
1727 let provider = AgentId::new("codex")
1728 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1729 let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1730 gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1731 "{}-pty",
1732 profile_id.as_str(),
1733 ))
1734 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1735 provider.clone(),
1736 TransportKind::Pty,
1737 vec![OsString::from("USERPROFILE")],
1738 Arc::new(IsolatedCodexHomeFixtureEnvironment),
1739 )
1740 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1741 let materialization = NodeSessionMaterializationProfile::new(
1742 Vec::new(),
1743 vec![crate::session_environment::NodeSessionPathBinding::new(
1744 "CODEX_HOME",
1745 crate::session_environment::NodeSessionPathClass::ProviderHome,
1746 )
1747 .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1748 Vec::new(),
1749 )
1750 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1751 self.install_environment_profile(
1752 NodeEnvironmentProfile::new_with_materialization(
1753 profile_id,
1754 profile_revision,
1755 provider,
1756 [native_profile],
1757 Some(materialization),
1758 )
1759 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1760 )
1761 }
1762
1763 #[cfg(all(feature = "fixture", windows))]
1764 pub fn new_context_only_proof_fixture(
1765 config: NodeServerConfig,
1766 target_provider: AgentId,
1767 proof_path: PathBuf,
1768 ) -> Result<Self, NodeServerError> {
1769 if target_provider.as_str() != "kimi" {
1770 return Err(NodeServerError::Registry(
1771 "context-only proof fixture requires Kimi".to_owned(),
1772 ));
1773 }
1774 let catalog = Self::context_pack_fixture_catalog(
1775 proof_path,
1776 Some(&target_provider),
1777 false,
1778 )?;
1779 Self::new_with_registry(config, catalog)
1780 }
1781
1782 #[cfg(all(feature = "fixture", windows))]
1783 fn context_pack_fixture_catalog(
1784 proof_path: PathBuf,
1785 context_only_target: Option<&AgentId>,
1786 monitoring_claude_source: bool,
1787 ) -> Result<AgentRegistry, NodeServerError> {
1788 if !proof_path.is_absolute()
1789 || !proof_path
1790 .parent()
1791 .is_some_and(|parent| parent.is_dir())
1792 {
1793 return Err(NodeServerError::Registry(
1794 "context pack proof path requires an existing absolute parent".to_owned(),
1795 ));
1796 }
1797 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1798 NodeServerError::Registry(
1799 "context pack proof path is not valid Unicode".to_owned(),
1800 )
1801 })?;
1802 let providers = ["claude", "codex", "grok", "kimi"];
1803 let builtins = builtin_registry();
1804 let mut specs = Vec::with_capacity(providers.len());
1805 for provider_id in providers {
1806 let agent_id = AgentId::new(provider_id)
1807 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1808 let provider = builtins.get(&agent_id).ok_or_else(|| {
1809 NodeServerError::Registry(
1810 "context pack fixture provider adapter is unavailable".to_owned(),
1811 )
1812 })?;
1813 let monitored_provider =
1814 monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1815 let mut spec = if monitored_provider {
1816 gate4agent_testkit::monitoring_hook_agent_spec()
1817 } else {
1818 gate4agent_testkit::interactive_agent_spec()
1819 };
1820 spec.id = agent_id.clone();
1821 spec.display_name = format!("Controlled {agent_id} context pack fixture");
1822 spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1823 spec.detection.aliases.clear();
1824 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1825 if provider_id == "codex" {
1826 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1827 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1828 })?;
1829 let validation = r#"
1830$bundleArgs = @($bundleArgs)
1831if ($bundleArgs.Count -ne 0) { exit 91 }
1832$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1833$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1834$cwd = (Get-Location).ProviderPath
1835if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1836$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1837if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1838$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1839if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1840$sha = [Security.Cryptography.SHA256]::Create()
1841try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1842if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1843if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1844$contextPath = Join-Path $contextRoot 'context-pack.json'
1845if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1846$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1847if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1848$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1849$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1850$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1851$separator = [IO.Path]::DirectorySeparatorChar
1852if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1853if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1854$rawContext = [IO.File]::ReadAllText($contextPath)
1855if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1856try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1857$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1858$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1859foreach ($property in @($document.PSObject.Properties.Name)) {
1860 if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1861}
1862foreach ($property in $requiredTopLevel) {
1863 if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1864}
1865if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1866if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1867$messages = @($document.retained_messages)
1868if ($messages.Count -eq 0) { exit 107 }
1869foreach ($message in $messages) {
1870 $messageProperties = @($message.PSObject.Properties.Name)
1871 foreach ($property in $messageProperties) {
1872 if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1873 }
1874 if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1875}
1876$sha = [Security.Cryptography.SHA256]::Create()
1877try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1878[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))
1879$rawProof = [IO.File]::ReadAllText($proofPath)
1880if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1881[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1882"#;
1883 spec.launch.fixed_args.push(format!(
1884 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1885 ));
1886 spec.launch.fixed_args.push(proof_path.clone());
1887 } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1888 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1889 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1890 })?;
1891 let validation = r#"
1892$bundleArgs = @($bundleArgs)
1893if ($bundleArgs.Count -ne 0) { exit 91 }
1894$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1895$cwd = (Get-Location).ProviderPath
1896if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1897$contextPath = Join-Path $contextRoot 'context-pack.json'
1898if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1899$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1900if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1901$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1902$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1903$separator = [IO.Path]::DirectorySeparatorChar
1904if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1905try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1906if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1907if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1908$messages = @($document.retained_messages)
1909if ($messages.Count -eq 0) { exit 99 }
1910$hasUser = $false
1911$hasAssistant = $false
1912foreach ($message in $messages) {
1913 if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1914 if ([string]$message.role -ceq 'user') { $hasUser = $true }
1915 elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1916 else { exit 101 }
1917}
1918if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1919$sha = [Security.Cryptography.SHA256]::Create()
1920try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1921[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1922[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1923"#;
1924 spec.launch.fixed_args.push(format!(
1925 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1926 ));
1927 spec.launch.fixed_args.push(proof_path.clone());
1928 }
1929 specs.push(spec);
1930 }
1931 let catalog = AgentRegistry::new(specs)
1932 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1933 Ok(catalog)
1934 }
1935
1936 #[cfg(feature = "fixture")]
1937 pub fn new_exact_launcher_fixture(
1938 config: NodeServerConfig,
1939 agent_id: AgentId,
1940 launcher: String,
1941 ) -> Result<Self, NodeServerError> {
1942 if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1943 return Err(NodeServerError::Registry(
1944 "exact launcher fixture requires a supported provider".to_owned(),
1945 ));
1946 }
1947 if launcher.contains('\0') {
1948 return Err(NodeServerError::Registry(
1949 "exact launcher fixture path contains NUL".to_owned(),
1950 ));
1951 }
1952 let launcher_path = Path::new(&launcher);
1953 if !launcher_path.is_absolute() {
1954 return Err(NodeServerError::Registry(
1955 "exact launcher fixture path must be absolute".to_owned(),
1956 ));
1957 }
1958 let metadata = std::fs::metadata(launcher_path).map_err(|_| {
1959 NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
1960 })?;
1961 if !metadata.is_file() {
1962 return Err(NodeServerError::Registry(
1963 "exact launcher fixture path must be a regular file".to_owned(),
1964 ));
1965 }
1966 let mut spec = builtin_registry()
1967 .get(&agent_id)
1968 .ok_or_else(|| {
1969 NodeServerError::Registry(
1970 "exact launcher fixture provider is unavailable".to_owned(),
1971 )
1972 })?
1973 .clone();
1974 spec.launch.program = launcher;
1975 Self::new_fixture_with_spec(config, spec)
1976 }
1977
1978 #[cfg(feature = "fixture")]
1987 fn h3b_provider_spec_with_program_and_args(
1988 agent_id: AgentId,
1989 provider_program: PathBuf,
1990 fixed_args: Vec<OsString>,
1991 ) -> Result<AgentSpec, NodeServerError> {
1992 if !provider_program.is_absolute() || fixed_args.len() > 32 {
1993 return Err(NodeServerError::Registry(
1994 "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
1995 ));
1996 }
1997 let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
1998 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
1999 })?;
2000 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2001 return Err(NodeServerError::Registry(
2002 "H3B fixture provider must be an exact regular file".to_owned(),
2003 ));
2004 }
2005 let program = provider_program.into_os_string().into_string().map_err(|_| {
2006 NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2007 })?;
2008 let fixed_args = fixed_args.into_iter().map(|argument| {
2009 argument.into_string().map_err(|_| NodeServerError::Registry(
2010 "H3B fixture argument is not Unicode".to_owned(),
2011 ))
2012 }).collect::<Result<Vec<_>, _>>()?;
2013 let argument_bytes = fixed_args.iter()
2014 .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2015 if program.contains('\0')
2016 || fixed_args.iter().any(|argument| argument.contains('\0'))
2017 || !matches!(argument_bytes, Some(total) if total <= 65_536)
2018 {
2019 return Err(NodeServerError::Registry(
2020 "H3B fixture provider or arguments are invalid".to_owned(),
2021 ));
2022 }
2023 let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2024 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2025 })?.clone();
2026 let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2027 .ok_or_else(|| NodeServerError::Registry(
2028 "H3B fixture provider basename is unavailable".to_owned(),
2029 ))?.to_owned();
2030 spec.detection.command = process_name.clone();
2031 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2032 name: process_name,
2033 }];
2034 spec.launch.program = program;
2035 spec.launch.fixed_args = fixed_args;
2036 Ok(spec)
2037 }
2038
2039 #[cfg(feature = "fixture")]
2040 pub fn new_harness_mcp_proxy_fixture(
2041 config: NodeServerConfig,
2042 provider_program: PathBuf,
2043 fixed_args: Vec<OsString>,
2044 harness_mcp_program: PathBuf,
2045 ) -> Result<Self, NodeServerError> {
2046 let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2047 let agent_id = AgentId::new("codex")
2048 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2049 let mut spec =
2050 Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2051 spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2052 config.fixture_raw_pty_runtime = true;
2053 Self::new_fixture_with_spec(config, spec)
2054 }
2055
2056 #[cfg(feature = "fixture")]
2080 pub fn new_harness_mcp_acp_launcher_fixture(
2081 config: NodeServerConfig,
2082 helper_program: PathBuf,
2083 provider: AgentId,
2084 program: PathBuf,
2085 args: Vec<OsString>,
2086 ) -> Result<Self, NodeServerError> {
2087 if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2088 return Err(NodeServerError::Registry(
2089 "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2090 ));
2091 }
2092 let config = config.with_harness_mcp_helper(helper_program)?;
2093 let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2094 let launch = spec.launch.clone();
2095 let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2096 NodeServerError::Registry(
2097 "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2098 .to_owned(),
2099 )
2100 })?;
2101 acp.launch_override = Some(launch);
2109 spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2110 Self::new_fixture_with_spec(config, spec)
2111 }
2112
2113 #[cfg(feature = "fixture")]
2114 pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2115 let mut spec = gate4agent_testkit::interactive_agent_spec();
2116 let claude_id = AgentId::new("claude")
2117 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2118 let claude = builtin_registry()
2119 .get(&claude_id)
2120 .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2121 spec.id = claude_id;
2122 spec.display_name = "Resumable Claude PTY fixture".to_owned();
2123 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2124 name: "claude".to_owned(),
2125 }];
2126 spec.capabilities.transports.pty_adapter = claude
2127 .capabilities
2128 .transports
2129 .pty_adapter
2130 .clone();
2131 spec.capabilities.adapters.resume = claude
2132 .capabilities
2133 .adapters
2134 .resume
2135 .clone();
2136 let script = spec
2137 .launch
2138 .fixed_args
2139 .pop()
2140 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2141 spec.launch.fixed_args.push(format!(
2142 "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2143 ));
2144 Self::new_fixture_with_spec(config, spec)
2145 }
2146
2147 #[cfg(feature = "fixture")]
2148 pub fn new_cmd_cwd_fixture(
2149 config: NodeServerConfig,
2150 command: String,
2151 ) -> Result<Self, NodeServerError> {
2152 let mut spec = gate4agent_testkit::interactive_agent_spec();
2153 spec.id = AgentId::new("claude")
2154 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2155 spec.display_name = "Controlled cmd cwd fixture".to_owned();
2156 spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2157 spec.launch.program = command;
2158 spec.launch.fixed_args.clear();
2159 Self::new_fixture_with_spec(config, spec)
2160 }
2161
2162 #[cfg(feature = "fixture")]
2163 fn new_fixture_with_spec(
2164 config: NodeServerConfig,
2165 spec: gate4agent_types::AgentSpec,
2166 ) -> Result<Self, NodeServerError> {
2167 let catalog = AgentRegistry::new([spec])
2168 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2169 Self::new_with_registry(config, catalog)
2170 }
2171
2172 fn new_with_registry(
2173 config: NodeServerConfig,
2174 catalog: AgentRegistry,
2175 ) -> Result<Self, NodeServerError> {
2176 for profile in config.spawn_profiles.iter() {
2177 if catalog.get(&profile.provider).is_none() {
2178 return Err(NodeServerError::Registry(
2179 "spawn profile references an unavailable provider".to_owned(),
2180 ));
2181 }
2182 }
2183 let input_settle_timeout_ms = catalog
2184 .iter()
2185 .map(|spec| spec.readiness.timeout_ms)
2186 .max()
2187 .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2188 .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2189 .max(MUTATION_SETTLE_TIMEOUT_MS);
2190 let (provider_contracts, provider_adapter_contracts) =
2191 provider_contract_manifest(&catalog)?;
2192 let compatibility_support = node_compatibility_support_for_manifest(
2193 &provider_contracts,
2194 &provider_adapter_contracts,
2195 )?;
2196 validate_node_negotiated_handshake_capacity(&compatibility_support)
2197 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2198 let enabled_providers = provider_contracts
2199 .iter()
2200 .map(|contract| contract.provider.clone())
2201 .collect();
2202 #[cfg(feature = "fixture")]
2203 let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2204 #[cfg(not(feature = "fixture"))]
2205 let fixture_raw_pty_runtime = false;
2206 let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2207 let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2208 crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2209 })).expect("fixture provider catalog remains bounded");
2210 (None, statuses)
2211 } else {
2212 let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2213 let statuses = monitor.collect();
2214 (Some(monitor), statuses)
2215 };
2216 let state_path_lock =
2217 session_registry::StatePathLock::acquire(config.state_path.as_deref())
2218 .map_err(|error| {
2219 durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2220 })?;
2221 let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2222 Some(state_path) => {
2223 let delivery_root = delivery_store_root_for_state_path(state_path)
2224 .ok_or_else(|| {
2225 NodeServerError::DeliveryStore(
2226 "delivery store root could not be derived from state path".to_owned(),
2227 )
2228 })?;
2229 let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2230 let detail = match &error {
2231 DeliveryStoreError::Storage(io_error) => {
2232 format!("{error}: {io_error}")
2233 }
2234 _ => error.to_string(),
2235 };
2236 NodeServerError::DeliveryStore(detail)
2237 })?;
2238 (Some(store), bundles)
2239 }
2240 None => (None, Vec::new()),
2241 };
2242 let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2243 NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2244 })?;
2245 let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2246 Some(state_path) => {
2247 let context_pack_root = context_pack_store_root_for_state_path(state_path)
2248 .ok_or(NodeServerError::ContextPackStore)?;
2249 let (store, packs) = ContextPackStore::open(context_pack_root)
2250 .map_err(|_| NodeServerError::ContextPackStore)?;
2251 (Some(store), packs)
2252 }
2253 None => (None, Vec::new()),
2254 };
2255 let mut durable_context_catalog = ContextPackCatalog::default();
2256 for pack in durable_context_packs {
2257 durable_context_catalog
2258 .insert(pack)
2259 .map_err(|_| NodeServerError::ContextPackStore)?;
2260 }
2261 let session_environment_materializer = config
2262 .session_environment
2263 .as_ref()
2264 .map(|environment| {
2265 SessionEnvironmentMaterializer::new(
2266 environment.root.clone(),
2267 Arc::clone(&environment.resolver),
2268 )
2269 .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2270 })
2271 .transpose()?;
2272 #[cfg(unix)]
2275 let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2276 let native_session_catalog = config.history.clone().map(|history| {
2277 Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2278 });
2279 let (handle, runtime) = match config.history.clone() {
2280 Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2281 None => NativeRuntime::new(catalog, config.runtime),
2282 };
2283 let native_launch_profile_control = runtime.native_launch_profile_control();
2284 let harness_mcp_registry = config
2285 .harness_mcp_helper
2286 .clone()
2287 .map(HarnessMcpProxyRegistry::new);
2288 let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2289 let incarnation_id = random_incarnation_id()
2290 .map_err(NodeServerError::IncarnationIdentity)?;
2291 let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2292 .map_err(durable_state_load_error)?;
2293 let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2294 let managed_worktree_tombstones =
2295 std::mem::take(&mut loaded.managed_worktree_tombstones);
2296 let materializations = std::mem::take(&mut loaded.materializations);
2297 let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2298 let (workspaces, records, persistence_warning) =
2299 merge_durable_state(&config.workspaces, loaded)?;
2300 let mut shared = NodeShared::new_with_incarnation(
2301 handle,
2302 config.access_token.clone(),
2303 config.node_id.clone(),
2304 incarnation_id,
2305 workspaces,
2306 enabled_providers,
2307 provider_runtime_statuses,
2308 provider_runtime_monitor,
2309 provider_contracts,
2310 provider_adapter_contracts,
2311 config.spawn_profiles.clone(),
2312 Some(native_launch_profile_control),
2313 native_session_catalog,
2314 session_environment_materializer,
2315 config.state_path.clone(),
2316 records,
2317 managed_worktrees,
2318 managed_worktree_tombstones,
2319 materializations,
2320 persistence_warning,
2321 input_settle_timeout_ms,
2322 );
2323 shared.harness_mcp_registry = harness_mcp_registry.clone();
2324 shared.session_record_retention = config.session_record_retention;
2325 shared.network_allowlist_catalog =
2326 RwLock::new(config.network_allowlist_catalog.clone());
2327 shared.bundle_catalog = RwLock::new(delivered_catalog);
2328 shared.delivery_store = Mutex::new(delivery_store);
2329 shared.context_catalog = RwLock::new(durable_context_catalog);
2330 shared.context_pack_store = Mutex::new(context_pack_store);
2331 shared.managed_spawn_replays = Mutex::new(
2332 managed_spawn_replays
2333 .into_iter()
2334 .map(|record| (record.idempotency_key.clone(), record))
2335 .collect(),
2336 );
2337 let shared = Arc::new(shared);
2338 if let Some(registry) = harness_mcp_registry {
2339 let weak = Arc::downgrade(&shared);
2340 registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2341 Some(shared) => shared.publish_transient(event),
2342 None => false,
2343 }));
2344 }
2345 if shared.persistence_error().is_none() {
2346 shared.persist_state().map_err(|error| {
2347 durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2348 })?;
2349 }
2350 Ok(Self {
2351 config,
2352 runtime,
2353 shared,
2354 events,
2355 state_path_lock,
2356 })
2357 }
2358
2359 pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2360 NodeShutdownHandle {
2361 shared: Arc::clone(&self.shared),
2362 }
2363 }
2364
2365 #[cfg(feature = "fixture")]
2366 pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2367 &self,
2368 ) -> SpawnManagedWorktreeV2FailureProbe {
2369 self.shared
2370 .fixture_spawn_managed_worktree_v2_failure_probe
2371 .clone()
2372 }
2373
2374 pub fn install_environment_profile(
2379 &mut self,
2380 profile: NodeEnvironmentProfile,
2381 ) -> Result<(), NodeServerError> {
2382 if !self.shared.enabled_providers.contains(profile.provider()) {
2383 return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2384 profile.provider().clone(),
2385 ));
2386 }
2387 let (binding, native_profiles, materialization) = profile.into_parts();
2388 if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2389 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2390 }
2391 let mut profiles = self
2392 .shared
2393 .environment_profiles
2394 .write()
2395 .unwrap_or_else(|poisoned| poisoned.into_inner());
2396 if profiles.contains_key(&binding.id) {
2397 return Err(NodeServerError::DuplicateEnvironmentProfile(
2398 binding.id.clone(),
2399 ));
2400 }
2401 if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2402 return Err(NodeServerError::EnvironmentProfileCapacity {
2403 max: MAX_NODE_ENVIRONMENT_PROFILES,
2404 });
2405 }
2406 for native_id in binding.native_profile_ids() {
2407 if profiles.values().any(|current| {
2408 current.native_profile_ids().any(|current_id| current_id == native_id)
2409 }) {
2410 return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2411 native_id.to_string(),
2412 ));
2413 }
2414 }
2415
2416 let mut installed = Vec::with_capacity(native_profiles.len());
2417 for native_profile in native_profiles {
2418 let native_id = native_profile.id().clone();
2419 if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2420 for installed_id in installed.into_iter().rev() {
2421 let _ = self.runtime.remove_native_launch_profile(&installed_id);
2422 }
2423 return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2424 }
2425 installed.push(native_id);
2426 }
2427 if let Some(materialization) = materialization {
2428 self.shared
2429 .environment_materialization_profiles
2430 .write()
2431 .unwrap_or_else(|poisoned| poisoned.into_inner())
2432 .insert(binding.id.clone(), materialization);
2433 }
2434 profiles.insert(binding.id.clone(), binding);
2435 Ok(())
2436 }
2437
2438 pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2440 if self.shared.session_environment_materializer.is_none() {
2441 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2442 }
2443 let mut catalog = self
2444 .shared
2445 .bundle_catalog
2446 .write()
2447 .unwrap_or_else(|poisoned| poisoned.into_inner());
2448 catalog
2449 .insert_idempotent(bundle)
2450 .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2451 Ok(())
2452 }
2453
2454 pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2455 let shutdown = self.shutdown_handle();
2456 let run = self.run();
2457 tokio::pin!(run);
2458 tokio::select! {
2459 result = &mut run => result,
2460 signal = wait_for_ctrl_signal() => {
2461 signal?;
2462 let shutdown_result = shutdown.request_shutdown().await;
2463 let run_result = run.await;
2464 shutdown_result.and(run_result)
2465 }
2466 }
2467 }
2468
2469 pub async fn run(self) -> Result<(), NodeServerError> {
2470 let NodeServer {
2471 config,
2472 mut runtime,
2473 shared,
2474 events,
2475 state_path_lock: _state_path_lock,
2476 } = self;
2477 shared.reconcile_materializations();
2478 shared.reconcile_managed_worktrees().await;
2479 shared.retire_unavailable_session_records().await;
2480 shared.reconcile_context_pack_exports().await;
2481 let endpoint = config.endpoint.clone();
2485 let call_home = config.call_home;
2486 let call_home_identity = config.call_home_identity.clone();
2487 let api_listen = config.api_listen;
2488 let bridge_listen = config.bridge_listen;
2489 let bridge_underlay = config.bridge_underlay;
2490 let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2491 .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2492 let accept_shared = Arc::clone(&shared);
2493 let api_shared = Arc::clone(&shared);
2494 let bridge_shared = Arc::clone(&shared);
2495 let shutdown_shared = Arc::clone(&shared);
2496 let shutdown_timeout = Duration::from_millis(
2497 config.runtime.provider_shutdown_timeout_ms.max(1),
2498 );
2499 let result = {
2500 let runtime_loop = drive_runtime_until_shutdown(
2501 &mut runtime,
2502 events,
2503 Arc::clone(&shared),
2504 shutdown_timeout,
2505 );
2506 let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2507 let api_loop = http_api::run(api_listen, api_shared);
2508 let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2509 tokio::pin!(runtime_loop);
2510 tokio::pin!(accept_loop);
2511 tokio::pin!(api_loop);
2512 tokio::pin!(bridge_loop);
2513 tokio::select! {
2514 runtime_result = &mut runtime_loop => {
2515 let shutdown_result = shutdown_shared
2516 .begin_shutdown()
2517 .await
2518 .map_err(NodeServerError::ShutdownDispatch);
2519 let (accept_result, api_result, bridge_result) =
2520 tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2521 match runtime_result {
2522 Err(error) => Err(error),
2523 Ok(()) => shutdown_result
2524 .and(accept_result)
2525 .and(api_result.map_err(NodeServerError::HttpApi))
2526 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2527 }
2528 }
2529 accept_result = &mut accept_loop => {
2530 let shutdown_result = shutdown_shared
2531 .begin_shutdown()
2532 .await
2533 .map_err(NodeServerError::ShutdownDispatch);
2534 let (runtime_result, api_result, bridge_result) =
2535 tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2536 match accept_result {
2537 Err(error) => Err(error),
2538 Ok(()) => shutdown_result
2539 .and(runtime_result)
2540 .and(api_result.map_err(NodeServerError::HttpApi))
2541 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2542 }
2543 }
2544 api_result = &mut api_loop => {
2545 let shutdown_result = shutdown_shared
2546 .begin_shutdown()
2547 .await
2548 .map_err(NodeServerError::ShutdownDispatch);
2549 let (runtime_result, accept_result, bridge_result) =
2550 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2551 match api_result {
2552 Err(error) => Err(NodeServerError::HttpApi(error)),
2553 Ok(()) => shutdown_result
2554 .and(runtime_result)
2555 .and(accept_result)
2556 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2557 }
2558 }
2559 bridge_result = &mut bridge_loop => {
2560 let shutdown_result = shutdown_shared
2561 .begin_shutdown()
2562 .await
2563 .map_err(NodeServerError::ShutdownDispatch);
2564 let (runtime_result, accept_result, api_result) =
2565 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2566 match bridge_result {
2567 Err(error) => Err(NodeServerError::BridgeApi(error)),
2568 Ok(()) => shutdown_result
2569 .and(runtime_result)
2570 .and(accept_result)
2571 .and(api_result.map_err(NodeServerError::HttpApi)),
2572 }
2573 }
2574 }
2575 };
2576 if let Ok(mut guard) = shared.delivery_store.lock() {
2583 *guard = None;
2584 }
2585 if let Ok(mut guard) = shared.context_pack_store.lock() {
2586 *guard = None;
2587 }
2588 result
2589 }
2590}
2591
2592async fn drive_runtime_until_shutdown(
2593 runtime: &mut NativeRuntime,
2594 events: EventSubscription,
2595 shared: Arc<NodeShared>,
2596 shutdown_timeout: Duration,
2597) -> Result<(), NodeServerError> {
2598 let _ = shared
2602 .shell_efficiency_profile
2603 .set(runtime.shell_efficiency_profile());
2604 let mut shutdown_started = None;
2605 let mut last_retention_sweep = Instant::now();
2606 loop {
2607 let runtime_tick_start = Instant::now();
2614 let tick_result = runtime.tick().await;
2615 let runtime_tick_elapsed = runtime_tick_start.elapsed();
2616
2617 let event_drain_start = Instant::now();
2618 for outcome in &tick_result.command_outcomes {
2626 if let Err(error) = &outcome.result {
2627 shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2628 tracing::warn!(
2629 command_id = outcome.command_id.0,
2630 cause = %error,
2631 "control command rejected"
2632 );
2633 shared.record_rejected_command(
2634 outcome.command_id,
2635 error.to_string(),
2636 error.is_unsupported_transport(),
2637 );
2638 }
2639 }
2640 let mut events_drained: u64 = 0;
2641 while let Ok(event) = events.try_recv() {
2642 events_drained += 1;
2643 let clean_exit = matches!(
2644 event.event,
2645 ControlEventKind::Exited { exit_code: Some(0), forced: false },
2646 );
2647 let instance_id = event.instance_id;
2648 let generation = event.generation;
2649 if clean_exit {
2661 if let Some((record_id, session)) =
2662 shared.managed_record_export_target_for_instance(instance_id, generation)
2663 {
2664 let export_shared = Arc::clone(&shared);
2665 tokio::spawn(async move {
2666 export_shared
2667 .reconcile_context_pack_export_for_record(&record_id, session)
2668 .await;
2669 });
2670 }
2671 }
2672 shared.publish_control(event);
2673 }
2674 let event_drain_elapsed = event_drain_start.elapsed();
2675
2676 let publish_terminal_frames_start = Instant::now();
2677 shared.publish_terminal_frames();
2678 let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2679
2680 shared
2686 .native_session_gauge
2687 .store(runtime.active_native_sessions(), Ordering::Relaxed);
2688 *shared
2689 .runtime_tick_profile
2690 .lock()
2691 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2692
2693 let remainder_start = Instant::now();
2694 if shared.shutdown.load(Ordering::Acquire) {
2695 let started = *shutdown_started.get_or_insert_with(Instant::now);
2696 let snapshot = shared.handle.snapshot();
2697 let sessions_settled = snapshot.sessions.iter().all(|session| {
2698 !matches!(
2699 session.status,
2700 gate4agent_types::SessionStatus::Starting
2701 | gate4agent_types::SessionStatus::Running
2702 | gate4agent_types::SessionStatus::Stopping
2703 )
2704 });
2705 if runtime.active_native_sessions() == 0 && sessions_settled {
2706 return Ok(());
2707 }
2708 if started.elapsed() >= shutdown_timeout {
2709 return Err(NodeServerError::ShutdownTimedOut {
2710 active_native_sessions: runtime.active_native_sessions(),
2711 });
2712 }
2713 }
2714 let remainder_elapsed = remainder_start.elapsed();
2715
2716 {
2717 let mut profile = shared
2718 .drive_loop_profile
2719 .lock()
2720 .unwrap_or_else(|poisoned| poisoned.into_inner());
2721 profile.record_runtime_tick(runtime_tick_elapsed);
2722 profile.record_event_drain(event_drain_elapsed);
2723 profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2724 profile.record_remainder(remainder_elapsed);
2725 profile.note_iteration(Instant::now());
2726 }
2727
2728 shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2740 if tick_result.observations_applied == 0 && events_drained == 0 {
2741 shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2742 }
2743
2744 if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2749 shared.retire_unavailable_session_records().await;
2750 last_retention_sweep = Instant::now();
2751 }
2752
2753 sleep(Duration::from_millis(10)).await;
2754 }
2755}
2756
2757#[derive(Clone)]
2758pub struct NodeShutdownHandle {
2759 shared: Arc<NodeShared>,
2760}
2761
2762impl NodeShutdownHandle {
2763 pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2764 self.shared
2765 .begin_shutdown()
2766 .await
2767 .map_err(NodeServerError::ShutdownDispatch)
2768 }
2769}
2770
2771async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2772 #[cfg(windows)]
2773 {
2774 let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2775 let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2776 return tokio::select! {
2777 signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2778 signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2779 };
2780 }
2781 #[cfg(unix)]
2782 {
2783 let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2784 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2785 tokio::select! {
2786 signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2787 signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2788 }
2789 }
2790}
2791
2792fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2805 let specs = builtin_registry()
2806 .iter()
2807 .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2808 .cloned()
2809 .collect::<Vec<_>>();
2810 AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2811}
2812
2813fn provider_contract_manifest(
2814 catalog: &AgentRegistry,
2815) -> Result<
2816 (
2817 Vec<ProviderContractSupport>,
2818 Vec<ProviderAdapterContractSupport>,
2819 ),
2820 NodeServerError,
2821> {
2822 let mut provider_contracts = Vec::new();
2823 let mut provider_adapter_contracts = Vec::new();
2824 for spec in catalog.iter() {
2825 let provider = spec.id.clone();
2826 let revision = ProviderContractRevision::new(spec.revision.clone())
2827 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2828 provider_contracts.push(ProviderContractSupport {
2829 provider: provider.clone(),
2830 revision,
2831 });
2832 for (family, binding) in declared_adapter_bindings(spec)? {
2833 let revision = AdapterContractRevision::new(binding.revision.clone())
2834 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2835 provider_adapter_contracts.push(ProviderAdapterContractSupport {
2836 provider: provider.clone(),
2837 family,
2838 adapter_id: binding.id.clone(),
2839 revision,
2840 });
2841 }
2842 }
2843 validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2844 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2845 Ok((provider_contracts, provider_adapter_contracts))
2846}
2847
2848fn declared_adapter_bindings(
2849 spec: &AgentSpec,
2850) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2851 let mut bindings = Vec::new();
2852 push_declared_binding(
2853 &mut bindings,
2854 AdapterFamily::PtySemantic,
2855 spec.capabilities.transports.pty_adapter.as_ref(),
2856 )?;
2857 push_declared_binding(
2858 &mut bindings,
2859 AdapterFamily::Pipe,
2860 spec.capabilities.adapters.pty_sidecar.as_ref(),
2861 )?;
2862 let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2863 let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2864 gate4agent_types::PipeProtocol::SemanticNdjson
2865 | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2866 gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2867 });
2868 push_declared_binding(
2869 &mut bindings,
2870 AdapterFamily::Pipe,
2871 pipe_transport
2872 .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2873 .map(|transport| &transport.adapter),
2874 )?;
2875 push_declared_binding(
2876 &mut bindings,
2877 AdapterFamily::Acp,
2878 spec.capabilities
2879 .transports
2880 .acp
2881 .as_ref()
2882 .map(|transport| &transport.adapter),
2883 )?;
2884 push_declared_binding(
2885 &mut bindings,
2886 AdapterFamily::Hook,
2887 spec.capabilities.adapters.hook.as_ref(),
2888 )?;
2889 push_declared_binding(
2890 &mut bindings,
2891 AdapterFamily::ManagedHook,
2892 spec.capabilities.adapters.managed_hook.as_ref(),
2893 )?;
2894 push_declared_binding(
2895 &mut bindings,
2896 AdapterFamily::OneShot,
2897 pipe_transport
2898 .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
2899 .map(|transport| &transport.adapter),
2900 )?;
2901 push_declared_binding(
2902 &mut bindings,
2903 AdapterFamily::OneShot,
2904 spec.capabilities.adapters.one_shot.as_ref(),
2905 )?;
2906 push_declared_binding(
2907 &mut bindings,
2908 AdapterFamily::History,
2909 spec.capabilities.adapters.history.as_ref(),
2910 )?;
2911 push_declared_binding(
2912 &mut bindings,
2913 AdapterFamily::Resume,
2914 spec.capabilities.adapters.resume.as_ref(),
2915 )?;
2916 push_declared_binding(
2917 &mut bindings,
2918 AdapterFamily::SessionOptions,
2919 spec.capabilities.adapters.session_options.as_ref(),
2920 )?;
2921 push_declared_binding(
2922 &mut bindings,
2923 AdapterFamily::CapabilityProbe,
2924 spec.capabilities.adapters.capability_probe.as_ref(),
2925 )?;
2926 Ok(bindings)
2927}
2928
2929fn push_declared_binding<'a>(
2930 bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
2931 family: AdapterFamily,
2932 binding: Option<&'a AdapterBinding>,
2933) -> Result<(), NodeServerError> {
2934 let Some(binding) = binding else {
2935 return Ok(());
2936 };
2937 if let Some((_, existing)) = bindings
2938 .iter()
2939 .find(|(existing_family, _)| *existing_family == family)
2940 {
2941 if *existing == binding {
2942 return Ok(());
2943 }
2944 return Err(NodeServerError::ProviderContractManifest(format!(
2945 "provider declares conflicting {family:?} adapter bindings"
2946 )));
2947 }
2948 bindings.push((family, binding));
2949 Ok(())
2950}
2951
2952fn merge_durable_state(
2953 configured: &[WorkspaceConfig],
2954 mut loaded: LoadedNodeState,
2955) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
2956 let mut workspaces = configured
2957 .iter()
2958 .cloned()
2959 .map(|workspace| (workspace.workspace_id.clone(), workspace))
2960 .collect::<BTreeMap<_, _>>();
2961 let mut roots = workspaces
2962 .values()
2963 .map(|workspace| {
2964 (
2965 platform::root_identity(&workspace.canonical_root),
2966 workspace.workspace_id.clone(),
2967 )
2968 })
2969 .collect::<BTreeMap<_, _>>();
2970 let mut warning = loaded
2971 .warning
2972 .take()
2973 .map(|message| session_registry::sanitized_persistence_summary(&message));
2974
2975 for (workspace_id, canonical_root) in loaded.workspaces {
2976 if let Some(existing) = workspaces.get(&workspace_id) {
2977 if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
2978 return Err(NodeServerError::DurableState(io::Error::new(
2979 io::ErrorKind::InvalidData,
2980 DURABLE_STATE_CONFLICT_ERROR,
2981 )));
2982 }
2983 continue;
2984 }
2985 if roots.get(&platform::root_identity(&canonical_root)).is_some() {
2986 return Err(NodeServerError::DurableState(io::Error::new(
2987 io::ErrorKind::InvalidData,
2988 DURABLE_STATE_CONFLICT_ERROR,
2989 )));
2990 }
2991 if !Path::new(&canonical_root).is_dir() {
2992 warning.get_or_insert_with(|| {
2993 session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
2994 });
2995 continue;
2996 }
2997 let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
2998 NodeServerError::DurableState(io::Error::new(
2999 io::ErrorKind::InvalidData,
3000 DURABLE_STATE_CONFLICT_ERROR,
3001 ))
3002 })?;
3003 roots.insert(
3004 platform::root_identity(&workspace.canonical_root),
3005 workspace.workspace_id.clone(),
3006 );
3007 workspaces.insert(workspace_id, workspace);
3008 }
3009
3010 for record in &mut loaded.records {
3011 match workspaces.get(&record.workspace_id) {
3012 Some(workspace)
3013 if platform::roots_equal(
3014 &workspace.canonical_root,
3015 windows_path_text(&record.canonical_root),
3016 ) => {}
3017 Some(_) => {
3018 return Err(NodeServerError::DurableState(io::Error::new(
3019 io::ErrorKind::InvalidData,
3020 DURABLE_STATE_CONFLICT_ERROR,
3021 )));
3022 }
3023 None => {
3024 record.state = ManagedSessionState::Unavailable;
3025 record.active_session = None;
3026 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3027 }
3028 }
3029 }
3030 Ok((workspaces.into_values().collect(), loaded.records, warning))
3031}
3032
3033struct RejectedCommandReason {
3036 message: String,
3037 unsupported_transport: bool,
3044 expires_at: Instant,
3045}
3046
3047struct NodeShared {
3048 handle: Gate4AgentHandle,
3049 access_token: String,
3050 node_id: NodeId,
3051 incarnation_id: NodeIncarnationId,
3052 started_at_unix_ms: u64,
3053 workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3054 worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3055 managed_worktree_profiles:
3056 BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3057 managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3058 enabled_providers: Vec<AgentId>,
3059 provider_runtime_statuses: ProviderRuntimeStatuses,
3060 provider_runtime_status_updates:
3061 RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3062 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3063 provider_contracts: Vec<ProviderContractSupport>,
3064 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3065 spawn_profiles: SpawnProfileRegistry,
3066 environment_profiles:
3067 RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3068 environment_materialization_profiles:
3069 RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3070 network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3078 #[cfg(feature = "dig2-station-probe")]
3082 browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3083 bundle_catalog: RwLock<BundleCatalog>,
3084 delivery_store: Mutex<Option<DeliveryStore>>,
3085 context_pack_store: Mutex<Option<ContextPackStore>>,
3086 context_catalog: RwLock<ContextPackCatalog>,
3087 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3088 harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3089 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3090 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3091 m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3093 m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3095 materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3096 spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3097 managed_spawn_replays:
3098 Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3099 controller: Mutex<Option<ControllerLease>>,
3100 history: Mutex<NodeEventHistory>,
3101 event_tx: broadcast::Sender<NodeEventEnvelope>,
3102 terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3103 agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3111 harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3120 terminal_frame_watermarks:
3121 Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3122 next_connection_id: AtomicU64,
3123 next_instance_id: AtomicU64,
3124 next_command_id: AtomicU64,
3125 shutdown: AtomicBool,
3126 shutdown_notify: Notify,
3127 preauth_slots: Arc<Semaphore>,
3128 authenticated_slots: Arc<Semaphore>,
3129 inspection_slots: Arc<Semaphore>,
3130 mutation_gate: AsyncMutex<()>,
3131 session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3132 session_records: Mutex<SessionRecords>,
3133 state_path: Option<PathBuf>,
3134 persistence_error: RwLock<Option<String>>,
3135 state_transaction: Mutex<()>,
3136 input_settle_timeout_ms: u64,
3137 drive_loop_profile: Mutex<DriveLoopProfiler>,
3141 runtime_tick_profile: Mutex<TickProfileSnapshot>,
3147 shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3159 native_session_gauge: AtomicUsize,
3166 connection_loop_iterations: AtomicU64,
3177 connection_events_sent: AtomicU64,
3178 rejected_commands_total: AtomicU64,
3187 rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3207 drive_loop_iterations_total: AtomicU64,
3208 drive_loop_iterations_idle: AtomicU64,
3209 session_record_retention: SessionRecordRetentionConfig,
3213 retired_records_total: AtomicU64,
3219 #[cfg(feature = "fixture")]
3220 fixture_semantic_hook_policy: bool,
3221 #[cfg(feature = "fixture")]
3222 fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3223}
3224
3225impl Drop for NodeShared {
3226 fn drop(&mut self) {
3227 SessionLifecycleHook::on_shutdown(self);
3228 }
3229}
3230
3231#[derive(Clone, Debug, Eq, PartialEq)]
3232struct SessionBinding {
3233 workspace_id: WorkspaceId,
3234 generation: SessionGeneration,
3235 runtime_policy: ProviderRuntimePolicy,
3236 pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3237 record_id: Option<SessionRecordId>,
3238 managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3239 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3240 bundle: Option<ResolvedBundleReceipt>,
3241 context: Option<ResolvedContextPackReceipt>,
3242 materialization_id: Option<MaterializationId>,
3243}
3244
3245#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3246enum SpawnRecordPolicy {
3247 ProviderIdentityOnly,
3248 Always,
3249}
3250
3251struct NativeEnvironmentSelectionGuard {
3252 control: Option<NativeLaunchProfileControl>,
3253 instance_id: AgentInstanceId,
3254}
3255
3256struct NativeInstanceOverlayGuard {
3257 control: Option<NativeLaunchProfileControl>,
3258 instance_id: AgentInstanceId,
3259}
3260
3261struct NativeHarnessMcpOverlayGuard {
3262 control: NativeLaunchProfileControl,
3263 instance_id: AgentInstanceId,
3264}
3265
3266impl NativeHarnessMcpOverlayGuard {
3267 fn retain(self) { std::mem::forget(self); }
3268}
3269
3270impl Drop for NativeHarnessMcpOverlayGuard {
3271 fn drop(&mut self) {
3272 self.control
3273 .clear_native_mcp_server_launch_overlay(self.instance_id);
3274 }
3275}
3276
3277impl NativeInstanceOverlayGuard {
3278 fn retain(mut self) {
3279 self.control = None;
3280 }
3281}
3282
3283impl Drop for NativeInstanceOverlayGuard {
3284 fn drop(&mut self) {
3285 if let Some(control) = self.control.as_ref() {
3286 control.clear_native_instance_launch_overlay(self.instance_id);
3287 }
3288 }
3289}
3290
3291enum PreparedNativeLaunchOverlay {
3292 Environment(NativeLaunchEnvironmentOverlay),
3293 Instance(NativeInstanceLaunchOverlay),
3294}
3295
3296impl NativeEnvironmentSelectionGuard {
3297 fn retain(mut self) {
3298 self.control = None;
3299 }
3300}
3301
3302impl Drop for NativeEnvironmentSelectionGuard {
3303 fn drop(&mut self) {
3304 if let Some(control) = self.control.as_ref() {
3305 control.clear_native_launch_profile_selection(self.instance_id);
3306 }
3307 }
3308}
3309
3310struct SessionMaterializationGuard<'a> {
3311 shared: &'a NodeShared,
3312 id: Option<MaterializationId>,
3313 provider_home: Option<PathBuf>,
3316}
3317
3318impl SessionMaterializationGuard<'_> {
3319 fn id(&self) -> Option<&MaterializationId> {
3320 self.id.as_ref()
3321 }
3322
3323 fn provider_home(&self) -> Option<&Path> {
3324 self.provider_home.as_deref()
3325 }
3326
3327 fn retain(mut self) {
3328 self.id = None;
3329 }
3330}
3331
3332impl Drop for SessionMaterializationGuard<'_> {
3333 fn drop(&mut self) {
3334 if let Some(id) = self.id.take() {
3335 let _ = self.shared.cleanup_materialization(&id);
3336 }
3337 }
3338}
3339
3340
3341trait SessionLifecycleHook {
3344 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3346 fn on_session_ended(&self, address: &SessionAddress);
3348 fn on_shutdown(&self);
3350 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3352 fn cleanup_session_materialization(
3354 &self,
3355 address: &SessionAddress,
3356 binding: &SessionBinding,
3357 ) -> Result<(), NodeFailure>;
3358}
3359
3360impl SessionLifecycleHook for NodeShared {
3361 fn on_session_ended(&self, address: &SessionAddress) {
3362 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3363 registry.revoke_session(address);
3364 }
3365 if let Some(control) = self.native_launch_profile_control.as_ref() {
3366 control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3367 }
3368 }
3369
3370 fn on_shutdown(&self) {
3371 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3372 let instances = registry.shutdown();
3373 if let Some(control) = self.native_launch_profile_control.as_ref() {
3374 for instance_id in instances {
3375 control.clear_native_mcp_server_launch_overlay(instance_id);
3376 }
3377 }
3378 }
3379 }
3380
3381 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3382 if let Some(control) = self.native_launch_profile_control.as_ref() {
3383 control.clear_native_mcp_server_launch_overlay(instance_id);
3384 }
3385 }
3386
3387 fn cleanup_session_materialization(
3388 &self,
3389 address: &SessionAddress,
3390 binding: &SessionBinding,
3391 ) -> Result<(), NodeFailure> {
3392 self.cleanup_session_owned_materialization(address, binding)
3393 }
3394
3395 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3396 #[cfg(feature = "dig2-station-probe")]
3399 {
3400 crate::dig2_station_lease::release_lease_for_holder(
3401 &self.browser_station_leases,
3402 address.session.instance_id,
3403 );
3404 }
3405 if let Some(control) = self.native_launch_profile_control.as_ref() {
3406 control.clear_native_mcp_server_launch_overlay(
3407 address.session.instance_id,
3408 );
3409 }
3410 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3411 registry.revoke_session(address);
3412 }
3413 if removed
3414 .is_some_and(|binding| {
3415 binding.environment_profile.is_some()
3416 || binding.bundle.is_some()
3417 || binding.context.is_some()
3418 })
3419 {
3420 if let Some(control) = self.native_launch_profile_control.as_ref() {
3421 control.clear_native_launch_profile_selection(
3422 address.session.instance_id,
3423 );
3424 control.clear_native_instance_launch_overlay(
3425 address.session.instance_id,
3426 );
3427 }
3428 }
3429 }
3430}
3431
3432struct SpawnPrepareRequest<'r> {
3433 instance_id: AgentInstanceId,
3434 address: &'r SessionAddress,
3435 provider: &'r AgentId,
3436 mode: SessionMode,
3437 environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3438 bundle: Option<&'r ResolvedBundleReceipt>,
3439 context: Option<&'r ResolvedContextPackReceipt>,
3440 managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3441 harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3442 approval_level: ApprovalLevel,
3443 network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3444 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3445}
3446
3447struct PreparedSpawn<'a> {
3451 instance_overlay: Option<NativeInstanceOverlayGuard>,
3452 environment_selection: Option<NativeEnvironmentSelectionGuard>,
3453 materialization_guard: Option<SessionMaterializationGuard<'a>>,
3454 harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3455 #[cfg(feature = "dig2-station-probe")]
3456 browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3457 materialization_id: Option<MaterializationId>,
3458}
3459
3460impl PreparedSpawn<'_> {
3461 fn retain_session_guards(&mut self) {
3463 if let Some(selection) = self.environment_selection.take() {
3464 selection.retain();
3465 }
3466 if let Some(overlay) = self.instance_overlay.take() {
3467 overlay.retain();
3468 }
3469 if let Some(guard) = self.materialization_guard.take() {
3470 guard.retain();
3471 }
3472 }
3473
3474 fn retain_launch_guards(&mut self) {
3476 if let Some(overlay) = self.harness_mcp_overlay.take() {
3477 overlay.retain();
3478 }
3479 #[cfg(feature = "dig2-station-probe")]
3480 if let Some(guard) = self.browser_station_lease_guard.take() {
3481 guard.retain();
3482 }
3483 }
3484}
3485
3486trait SpawnPreparer {
3490 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3491 fn select_environment(
3493 &self,
3494 instance_id: AgentInstanceId,
3495 provider: &AgentId,
3496 mode: SessionMode,
3497 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3498 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3499 fn install_launch_overlay(
3501 &self,
3502 instance_id: AgentInstanceId,
3503 overlay: PreparedNativeLaunchOverlay,
3504 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3505}
3506
3507impl SpawnPreparer for NodeShared {
3508 fn select_environment(
3509 &self,
3510 instance_id: AgentInstanceId,
3511 provider: &AgentId,
3512 mode: SessionMode,
3513 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3514 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3515 self.select_environment_profile(instance_id, provider, mode, environment_profile)
3516 }
3517
3518 fn install_launch_overlay(
3519 &self,
3520 instance_id: AgentInstanceId,
3521 overlay: PreparedNativeLaunchOverlay,
3522 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3523 self.install_prepared_launch_overlay(instance_id, overlay)
3524 }
3525
3526 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3527 let SpawnPrepareRequest {
3528 instance_id,
3529 address,
3530 provider,
3531 mode,
3532 environment_profile,
3533 bundle,
3534 context,
3535 managed_authority,
3536 harness_mcp,
3537 approval_level,
3538 network_allowlist,
3539 browser_profile_id,
3540 } = request;
3541 #[cfg(feature = "dig2-station-probe")]
3546 let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3547 use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3548 match acquire_lease(
3549 &self.browser_station_leases,
3550 profile_id,
3551 instance_id,
3552 unix_time_ms(),
3553 ) {
3554 Ok(guard) => Some(guard),
3555 Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3556 return Err(failure(
3557 NodeFailureCode::BrowserStationProfileBusy,
3558 "dig2browser station profile is already leased by another session",
3559 ));
3560 }
3561 Err(BrowserStationLeaseError::CapacityExceeded) => {
3562 return Err(failure(
3563 NodeFailureCode::BrowserStationProfileBusy,
3564 "dig2browser station profile lease capacity exhausted",
3565 ));
3566 }
3567 Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3568 return Err(failure(
3569 NodeFailureCode::BrowserStationProfileBusy,
3570 "session already holds a dig2browser station profile lease",
3571 ));
3572 }
3573 }
3574 } else {
3575 None
3576 };
3577 #[cfg(not(feature = "dig2-station-probe"))]
3578 let _ = browser_profile_id;
3579 let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3580 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3589 {
3590 return Err(failure(
3591 NodeFailureCode::BindingMismatch,
3592 "harness MCP reservation does not match the exact PTY/ACP provider",
3593 ));
3594 }
3595 prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3596 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3597 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3598 })?;
3599 let launch = prepared.launch();
3604 let mut env_entries = vec![
3605 (
3606 OsString::from(launch.endpoint_env.as_str()),
3607 prepared.endpoint().as_os_str().to_os_string(),
3608 ),
3609 (
3610 OsString::from(launch.token_env.as_str()),
3611 prepared.token().expose().into(),
3612 ),
3613 (
3614 OsString::from(launch.program_env.as_str()),
3615 prepared.helper_program().as_os_str().to_os_string(),
3616 ),
3617 ];
3618 if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3619 env_entries.push(trace_entry);
3620 }
3621 let overlay = NativeMcpServerLaunchOverlay::new(
3622 provider.clone(),
3623 launch.server_name.as_str(),
3624 prepared.helper_program().as_os_str().to_os_string(),
3625 launch.args.iter().map(OsString::from).collect(),
3626 env_entries,
3627 launch.scrub_env.iter().map(OsString::from).collect(),
3628 ).map_err(|_| failure(
3629 NodeFailureCode::HarnessMcpUnavailable,
3630 "harness MCP launch overlay is unavailable",
3631 ))?;
3632 control
3633 .install_native_mcp_server_launch_overlay(instance_id, overlay)
3634 .map_err(|_| failure(
3635 NodeFailureCode::HarnessMcpUnavailable,
3636 "harness MCP launch overlay could not be installed",
3637 ))?;
3638 tracing::info!(
3644 node_id = %self.node_id,
3645 instance_id = ?instance_id,
3646 provider = %provider,
3647 mode = ?mode,
3648 "harness MCP overlay installed for spawn",
3649 );
3650 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3651 } else {
3652 None
3653 };
3654 let prepared_materialization = self.prepare_session_materialization(
3655 &address,
3656 &provider,
3657 mode,
3658 environment_profile,
3659 bundle,
3660 context,
3661 managed_authority.cloned(),
3662 )?;
3663 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3664 Some((overlay, guard)) => (Some(overlay), Some(guard)),
3665 None => (None, None),
3666 };
3667 let materialization_id = materialization_guard
3668 .as_ref()
3669 .and_then(SessionMaterializationGuard::id)
3670 .cloned();
3671 let mut environment_selection = SpawnPreparer::select_environment(self,
3672 instance_id,
3673 &provider,
3674 mode,
3675 environment_profile,
3676 )?;
3677 let provider_home = materialization_guard
3680 .as_ref()
3681 .and_then(SessionMaterializationGuard::provider_home);
3682 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3683 &provider,
3684 mode,
3685 approval_level,
3686 network_allowlist,
3687 provider_home,
3688 )?;
3689 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3690 (prepared, args) if args.is_empty() => prepared,
3691 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3692 overlay.append_extra_args(args).map_err(|_| {
3693 failure(
3694 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3695 "network allowlist overlay could not merge with instance launch args",
3696 )
3697 })?;
3698 Some(PreparedNativeLaunchOverlay::Instance(overlay))
3699 }
3700 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3701 Some(PreparedNativeLaunchOverlay::Instance(
3702 overlay
3703 .into_instance_with_extra_args(args)
3704 .map_err(|_| {
3705 failure(
3706 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3707 "network allowlist overlay could not merge with environment overlay",
3708 )
3709 })?,
3710 ))
3711 }
3712 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3713 NativeInstanceLaunchOverlay::new(
3714 provider.clone(),
3715 match mode {
3716 SessionMode::Pty => TransportKind::Pty,
3717 SessionMode::Acp => TransportKind::Acp,
3718 SessionMode::Inline => TransportKind::Pipe,
3719 },
3720 Vec::new(),
3721 args,
3722 )
3723 .map_err(|_| {
3724 failure(
3725 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3726 "network allowlist overlay is invalid for this transport",
3727 )
3728 })?,
3729 )),
3730 };
3731 let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str())) {
3734 (SessionMode::Pty, Some(accounts), Some(m4a_provider)) => {
3735 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3736 match accounts.provision(m4a_provider, &session_key) {
3737 Ok(account) => {
3738 let identity = account.env_mutations();
3739 let merged = match environment_overlay {
3740 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3741 .map(PreparedNativeLaunchOverlay::Instance),
3742 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3743 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3744 }
3745 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3746 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3747 }
3748 }
3749 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3750 tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3751 self.m4a_live
3752 .lock()
3753 .unwrap_or_else(|p| p.into_inner())
3754 .insert(account.nick.clone(), (instance_id, provider.clone()));
3755 Some(merged)
3756 }
3757 Err(error) => {
3758 tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3759 environment_overlay
3760 }
3761 }
3762 }
3763 (SessionMode::Pty, Some(_), None) => {
3764 tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
3765 environment_overlay
3766 }
3767 _ => environment_overlay,
3768 };
3769 let mut instance_overlay = environment_overlay
3770 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
3771 .transpose()?
3772 .flatten();
3773 Ok(PreparedSpawn {
3774 instance_overlay,
3775 environment_selection,
3776 materialization_guard,
3777 harness_mcp_overlay,
3778 #[cfg(feature = "dig2-station-probe")]
3779 browser_station_lease_guard,
3780 materialization_id,
3781 })
3782 }
3783}
3784
3785struct SessionRecords {
3786 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3787}
3788
3789struct SpawnIdempotencyCache {
3790 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3791}
3792
3793struct SpawnIdempotencyEntry {
3794 value: SpawnIdempotencyValue,
3795 expires_at: Instant,
3796}
3797
3798enum SpawnIdempotencyValue {
3799 Standard {
3800 spec: SpawnSpec,
3801 result: Result<ResolvedSpawnReceipt, NodeFailure>,
3802 },
3803 Managed {
3804 request: ManagedWorktreeSpawnRequest,
3805 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3806 },
3807 HarnessMcp,
3808}
3809
3810enum DurableManagedSpawnReplayDecision {
3811 Reserved,
3812 PendingLinked,
3813 Committed(ManagedWorktreeSpawnReceipt),
3814}
3815
3816impl SpawnIdempotencyValue {
3817 fn references_context(&self, context_id: &SpawnContextId) -> bool {
3818 match self {
3819 Self::Standard { result, .. } => result
3820 .as_ref()
3821 .ok()
3822 .and_then(|receipt| receipt.context.as_ref()),
3823 Self::Managed { result, .. } => result
3824 .as_ref()
3825 .ok()
3826 .and_then(|receipt| receipt.spawn.context.as_ref()),
3827 Self::HarnessMcp => None,
3828 }
3829 .is_some_and(|receipt| &receipt.id == context_id)
3830 }
3831}
3832
3833#[derive(Clone, Copy)]
3834struct ControllerLease {
3835 connection_id: u64,
3836 expires_at: Instant,
3837}
3838
3839struct NodeEventHistory {
3840 last_sequence: u64,
3841 replay_floor_sequence: u64,
3842 events: VecDeque<NodeEventEnvelope>,
3843 record_providers: BTreeMap<SessionRecordId, AgentId>,
3844 removed_record_providers: BTreeMap<u64, AgentId>,
3845}
3846
3847impl NodeEventHistory {
3848 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
3849 Self {
3850 last_sequence: 0,
3851 replay_floor_sequence: 1,
3852 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
3853 record_providers,
3854 removed_record_providers: BTreeMap::new(),
3855 }
3856 }
3857}
3858
3859impl NodeShared {
3860 fn new_with_incarnation(
3861 handle: Gate4AgentHandle,
3862 access_token: String,
3863 node_id: NodeId,
3864 incarnation_id: NodeIncarnationId,
3865 workspaces: Vec<WorkspaceConfig>,
3866 enabled_providers: Vec<AgentId>,
3867 provider_runtime_statuses: ProviderRuntimeStatuses,
3868 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3869 provider_contracts: Vec<ProviderContractSupport>,
3870 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3871 spawn_profiles: SpawnProfileRegistry,
3872 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3873 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3874 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3875 state_path: Option<PathBuf>,
3876 records: Vec<ManagedSessionRecord>,
3877 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
3878 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
3879 materializations: Vec<MaterializationOwnershipRecord>,
3880 persistence_warning: Option<String>,
3881 input_settle_timeout_ms: u64,
3882 ) -> Self {
3883 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
3884 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
3885 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
3886 let (harness_mcp_proxy_event_tx, _) =
3887 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
3888 let record_providers = records
3889 .iter()
3890 .map(|record| (record.record_id.clone(), record.provider.clone()))
3891 .collect();
3892 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
3893 .iter()
3894 .map(|workspace| {
3895 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
3896 })
3897 .collect();
3898 for lease in &managed_worktrees {
3899 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
3900 }
3901 let managed_worktree_profiles = workspaces.iter()
3902 .map(|workspace| (
3903 workspace.workspace_id.clone(),
3904 workspace.managed_worktree_profiles.clone(),
3905 ))
3906 .collect();
3907 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
3908 managed_worktrees,
3909 managed_worktree_tombstones,
3910 ).expect("durable managed worktree registry was validated while loading");
3911 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
3912 managed_worktrees.reattach_record_holders(
3913 &managed_worktree_record_holders(&records),
3914 unix_time_ms(),
3915 );
3916 Self {
3917 handle,
3918 access_token,
3919 node_id,
3920 incarnation_id,
3921 started_at_unix_ms: std::time::SystemTime::now()
3922 .duration_since(std::time::UNIX_EPOCH)
3923 .unwrap_or_default()
3924 .as_millis()
3925 .min(u64::MAX as u128) as u64,
3926 workspaces: RwLock::new(
3927 workspaces
3928 .into_iter()
3929 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
3930 .collect(),
3931 ),
3932 worktree_service_modes,
3933 managed_worktree_profiles,
3934 managed_worktrees: Mutex::new(managed_worktrees),
3935 enabled_providers,
3936 provider_runtime_statuses,
3937 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
3938 provider_runtime_monitor,
3939 provider_contracts,
3940 provider_adapter_contracts,
3941 spawn_profiles,
3942 environment_profiles: RwLock::new(BTreeMap::new()),
3943 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
3944 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
3945 #[cfg(feature = "dig2-station-probe")]
3946 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
3947 bundle_catalog: RwLock::new(BundleCatalog::default()),
3948 delivery_store: Mutex::new(None),
3949 context_pack_store: Mutex::new(None),
3950 context_catalog: RwLock::new(ContextPackCatalog::default()),
3951 native_launch_profile_control,
3952 harness_mcp_registry: None,
3953 native_session_catalog,
3954 session_environment_materializer,
3955 m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
3956 m4a_live: Mutex::new(BTreeMap::new()),
3957 materializations: Mutex::new(
3958 materializations
3959 .into_iter()
3960 .map(|record| (record.id().clone(), record))
3961 .collect(),
3962 ),
3963 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
3964 entries: BTreeMap::new(),
3965 }),
3966 managed_spawn_replays: Mutex::new(BTreeMap::new()),
3967 controller: Mutex::new(None),
3968 history: Mutex::new(NodeEventHistory::new(record_providers)),
3969 event_tx,
3970 terminal_event_tx,
3971 agent_stream_event_tx,
3972 harness_mcp_proxy_event_tx,
3973 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
3974 next_connection_id: AtomicU64::new(1),
3975 next_instance_id: AtomicU64::new(1),
3976 next_command_id: AtomicU64::new(1),
3977 shutdown: AtomicBool::new(false),
3978 shutdown_notify: Notify::new(),
3979 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
3980 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
3981 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
3982 mutation_gate: AsyncMutex::new(()),
3983 session_bindings: Mutex::new(BTreeMap::new()),
3984 session_records: Mutex::new(SessionRecords {
3985 records: records
3986 .into_iter()
3987 .map(|record| (record.record_id.clone(), record))
3988 .collect(),
3989 }),
3990 state_path,
3991 persistence_error: RwLock::new(
3992 persistence_warning
3993 .map(|message| session_registry::sanitized_persistence_summary(&message)),
3994 ),
3995 state_transaction: Mutex::new(()),
3996 input_settle_timeout_ms,
3997 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
3998 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
3999 shell_efficiency_profile: OnceLock::new(),
4000 native_session_gauge: AtomicUsize::new(0),
4001 connection_loop_iterations: AtomicU64::new(0),
4002 connection_events_sent: AtomicU64::new(0),
4003 rejected_commands_total: AtomicU64::new(0),
4004 rejected_command_reasons: Mutex::new(BTreeMap::new()),
4005 drive_loop_iterations_total: AtomicU64::new(0),
4006 drive_loop_iterations_idle: AtomicU64::new(0),
4007 session_record_retention: SessionRecordRetentionConfig::default(),
4008 retired_records_total: AtomicU64::new(0),
4009 #[cfg(feature = "fixture")]
4010 fixture_semantic_hook_policy: false,
4011 #[cfg(feature = "fixture")]
4012 fixture_spawn_managed_worktree_v2_failure_probe:
4013 SpawnManagedWorktreeV2FailureProbe::default(),
4014 }
4015 }
4016
4017 #[cfg(test)]
4018 fn new(
4019 handle: Gate4AgentHandle,
4020 access_token: String,
4021 node_id: NodeId,
4022 workspaces: Vec<WorkspaceConfig>,
4023 enabled_providers: Vec<AgentId>,
4024 ) -> Self {
4025 Self::new_with_incarnation(
4026 handle,
4027 access_token,
4028 node_id,
4029 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4030 workspaces,
4031 enabled_providers,
4032 ProviderRuntimeStatuses::default(),
4033 None,
4034 Vec::new(),
4035 Vec::new(),
4036 SpawnProfileRegistry::default(),
4037 None,
4038 None,
4039 None,
4040 None,
4041 Vec::new(),
4042 Vec::new(),
4043 Vec::new(),
4044 Vec::new(),
4045 None,
4046 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4047 )
4048 }
4049
4050 fn persistence_error(&self) -> Option<String> {
4051 self.persistence_error
4052 .read()
4053 .unwrap_or_else(|poisoned| poisoned.into_inner())
4054 .clone()
4055 }
4056
4057 async fn admit_provider_runtime(
4058 &self,
4059 provider: &AgentId,
4060 requirement: ProviderRuntimeRequirement,
4061 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4062 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4063 let pipe_transport = monitor.supports_pipe_transport(provider);
4064 let acp_transport = monitor.supports_acp_transport(provider);
4065 let provider = provider.clone();
4066 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4067 let (status, admission) = timeout(
4068 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4069 refresh,
4070 )
4071 .await
4072 .map_err(|_| failure(
4073 NodeFailureCode::BackendBusy,
4074 "provider runtime probe exceeded its bounded deadline",
4075 ))?
4076 .map_err(|_| failure(
4077 NodeFailureCode::BackendOperationFailed,
4078 "provider runtime probe task failed",
4079 ))?;
4080 if let Some(status) = status {
4081 self
4082 .provider_runtime_status_updates
4083 .write()
4084 .unwrap_or_else(|poisoned| poisoned.into_inner())
4085 .insert(status.provider().clone(), status);
4086 }
4087 admission.and_then(|policy| {
4088 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4089 })
4090 } else {
4091 crate::provider_runtime::admit_status(
4097 &self.provider_runtime_statuses,
4098 provider,
4099 false,
4100 false,
4101 requirement,
4102 )
4103 };
4104 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4105 admission.map_err(|error| match error {
4106 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4107 NodeFailureCode::BackendOperationFailed,
4108 "provider launcher is unavailable",
4109 ),
4110 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4111 NodeFailureCode::UnsupportedCapability,
4112 "provider semantic capability is not verified",
4113 ),
4114 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4115 NodeFailureCode::BackendBusy,
4116 "provider runtime probe is already in progress",
4117 ),
4118 })
4119 }
4120
4121 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4127 match self.provider_runtime_monitor.as_deref() {
4128 Some(monitor) => monitor.supports_pipe_transport(provider),
4129 None => false,
4130 }
4131 }
4132
4133 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4139 match self.provider_runtime_monitor.as_deref() {
4140 Some(monitor) => monitor.supports_acp_transport(provider),
4141 None => false,
4142 }
4143 }
4144
4145 fn admit_declared_hook_semantics(
4161 &self,
4162 provider: &AgentId,
4163 policy: ProviderRuntimePolicy,
4164 ) -> ProviderRuntimePolicy {
4165 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4166 return policy;
4167 }
4168 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4169 contract.provider == *provider && contract.family == AdapterFamily::Hook
4170 });
4171 if !declares_hook {
4172 return policy;
4173 }
4174 ProviderRuntimePolicy::new(
4175 policy.raw_pty_lifecycle,
4176 policy.semantic_readiness,
4177 policy.structured_prompt,
4178 policy.provider_session_identity,
4179 policy.semantic_resume,
4180 true,
4181 )
4182 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4183 }
4184
4185 fn resolve_network_allowlist_overlay_args(
4200 &self,
4201 provider: &AgentId,
4202 mode: SessionMode,
4203 approval_level: ApprovalLevel,
4204 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4205 provider_home: Option<&Path>,
4206 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4207 let Some(allowlist_id) = network_allowlist else {
4208 return Ok(Vec::new());
4209 };
4210 let catalog = self
4211 .network_allowlist_catalog
4212 .read()
4213 .unwrap_or_else(|poisoned| poisoned.into_inner());
4214 let Some(entry) = catalog.get(allowlist_id) else {
4215 return Err(failure(
4216 NodeFailureCode::UnknownNetworkAllowlist,
4217 "spawn network allowlist is unavailable on this node",
4218 ));
4219 };
4220
4221 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4223 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4224 |message| {
4225 failure(
4226 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4227 &message,
4228 )
4229 },
4230 )?;
4231 if !matches!(mode, SessionMode::Pty) {
4232 return Err(failure(
4233 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4234 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4235 ));
4236 }
4237 let Some(home) = provider_home else {
4238 return Err(failure(
4239 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4240 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4241 ));
4242 };
4243 let domains =
4244 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4245 if domains.is_empty() {
4246 return Err(failure(
4247 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4248 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4249 ));
4250 }
4251 let settings_path = home.join(
4252 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4253 );
4254 let json =
4255 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4256 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4257 |_| {
4258 failure(
4259 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4260 "Claude station network settings sidecar could not be written under ProviderHome",
4261 )
4262 },
4263 )?;
4264 let overlay =
4265 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4266 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4267 }
4268
4269 let Some(native) = entry.provider_native.as_ref() else {
4270 return Ok(Vec::new());
4271 };
4272 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4273 provider.as_str(),
4274 approval_level,
4275 native,
4276 )
4277 .map_err(|message| {
4278 failure(
4279 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4280 &message,
4281 )
4282 })?;
4283 if overlay.is_empty() {
4284 return Ok(Vec::new());
4285 }
4286 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4287 return Err(failure(
4288 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4289 "Codex network_access -c overlay requires PTY or ACP transport",
4290 ));
4291 }
4292 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4293 }
4294
4295 fn resolve_codex_network_access_overlay_args(
4297 &self,
4298 provider: &AgentId,
4299 mode: SessionMode,
4300 approval_level: ApprovalLevel,
4301 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4302 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4303 self.resolve_network_allowlist_overlay_args(
4304 provider,
4305 mode,
4306 approval_level,
4307 network_allowlist,
4308 None,
4309 )
4310 }
4311
4312 fn resolve_environment_profile(
4313 &self,
4314 resolved: &ResolvedSpawnSpec,
4315 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4316 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4322 let catalog = self
4323 .network_allowlist_catalog
4324 .read()
4325 .unwrap_or_else(|poisoned| poisoned.into_inner());
4326 let Some(entry) = catalog.get(allowlist_id) else {
4327 return Err(failure(
4328 NodeFailureCode::UnknownNetworkAllowlist,
4329 "spawn network allowlist is unavailable on this node",
4330 ));
4331 };
4332 if let Some(native) = entry.provider_native.as_ref() {
4335 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4336 resolved.provider.as_str(),
4337 resolved.approval_level,
4338 native,
4339 ) {
4340 return Err(failure(
4341 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4342 &message,
4343 ));
4344 }
4345 }
4346 }
4347 #[cfg(feature = "dig2-station-probe")]
4356 if resolved.browser_profile_id.is_some() {
4357 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4358 match probe_station_reachable() {
4359 Ok(()) => {}
4360 Err(StationProbeError::UnsupportedPlatform)
4361 | Err(StationProbeError::InvalidPipeSuffix) => {
4362 return Err(failure(
4363 NodeFailureCode::BrowserStationProbeUnavailable,
4364 "dig2browser station probe unavailable on this platform",
4365 ));
4366 }
4367 Err(StationProbeError::Unreachable) => {
4368 return Err(failure(
4369 NodeFailureCode::BrowserStationUnreachable,
4370 "dig2browser station named-pipe is unreachable",
4371 ));
4372 }
4373 }
4374 }
4375 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4376 return Ok(None);
4377 };
4378 let profiles = self
4379 .environment_profiles
4380 .read()
4381 .unwrap_or_else(|poisoned| poisoned.into_inner());
4382 let binding = profiles.get(profile_id).ok_or_else(|| {
4383 failure(
4384 NodeFailureCode::UnknownEnvironmentProfile,
4385 "spawn environment profile is unavailable on this node",
4386 )
4387 })?;
4388 if binding.provider != resolved.provider
4389 || binding.native_profile_id(resolved.mode).is_none()
4390 {
4391 return Err(failure(
4392 NodeFailureCode::EnvironmentProfileBindingMismatch,
4393 "spawn environment profile does not match the provider and mode",
4394 ));
4395 }
4396 Ok(Some(ResolvedEnvironmentProfileReceipt {
4400 profile_id: binding.id.clone(),
4401 profile_revision: binding.revision.clone(),
4402 network_allowlist: resolved.network_allowlist.clone(),
4403 browser_profile_id: resolved.browser_profile_id.clone(),
4404 }))
4405 }
4406
4407 fn resolve_bundle(
4408 &self,
4409 resolved: &ResolvedSpawnSpec,
4410 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4411 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4412 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4413 return Ok(None);
4414 };
4415 let catalog = self.bundle_catalog
4416 .read()
4417 .unwrap_or_else(|poisoned| poisoned.into_inner());
4418 let bundle = catalog.get(bundle_id).ok_or_else(|| {
4419 failure(
4420 NodeFailureCode::UnknownBundle,
4421 "spawn bundle is unavailable on this node",
4422 )
4423 })?;
4424 self.bundle_layout(
4425 &resolved.provider,
4426 resolved.mode,
4427 environment_profile,
4428 bundle,
4429 )?;
4430 Ok(Some(bundle.receipt()))
4431 }
4432
4433 fn begin_delivery_stage(
4434 &self,
4435 manifest: DeliveryBundleManifestV2,
4436 ) -> Result<NodeResponse, NodeFailure> {
4437 let mut store = self
4438 .delivery_store
4439 .lock()
4440 .unwrap_or_else(|poisoned| poisoned.into_inner());
4441 let store = store.as_mut().ok_or_else(|| {
4442 failure(
4443 NodeFailureCode::DeliveryStageStorageFailed,
4444 "node delivery store is unavailable",
4445 )
4446 })?;
4447 let begun = store.begin(manifest).map_err(delivery_failure)?;
4448 Ok(NodeResponse::DeliveryStageBegun {
4449 stage_id: begun.stage_id,
4450 manifest_digest: begun.manifest_digest,
4451 missing_blobs: begun.missing_blobs,
4452 })
4453 }
4454
4455 fn put_delivery_blob_chunk(
4456 &self,
4457 stage_id: DeliveryStageId,
4458 blob_digest: DeliveryBlobDigestV1,
4459 offset: u64,
4460 chunk_hex: DeliveryBlobChunkHexV1,
4461 ) -> Result<NodeResponse, NodeFailure> {
4462 let mut store = self
4463 .delivery_store
4464 .lock()
4465 .unwrap_or_else(|poisoned| poisoned.into_inner());
4466 let store = store.as_mut().ok_or_else(|| {
4467 failure(
4468 NodeFailureCode::DeliveryStageStorageFailed,
4469 "node delivery store is unavailable",
4470 )
4471 })?;
4472 let accepted = store
4473 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4474 .map_err(delivery_failure)?;
4475 Ok(NodeResponse::DeliveryBlobChunkAccepted {
4476 stage_id: accepted.stage_id,
4477 blob_digest: accepted.blob_digest,
4478 next_offset: accepted.next_offset,
4479 })
4480 }
4481
4482 fn commit_delivery_stage(
4483 &self,
4484 stage_id: DeliveryStageId,
4485 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4486 let mut store = self
4487 .delivery_store
4488 .lock()
4489 .unwrap_or_else(|poisoned| poisoned.into_inner());
4490 let store = store.as_mut().ok_or_else(|| {
4491 failure(
4492 NodeFailureCode::DeliveryStageStorageFailed,
4493 "node delivery store is unavailable",
4494 )
4495 })?;
4496 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4497 let mut catalog = self
4498 .bundle_catalog
4499 .write()
4500 .unwrap_or_else(|poisoned| poisoned.into_inner());
4501 let mut next_catalog = catalog.clone();
4502 next_catalog
4503 .insert_idempotent(prepared.bundle().clone())
4504 .map_err(|_| {
4505 failure(
4506 NodeFailureCode::DeliveryStageConflict,
4507 "delivery bundle identity conflicts with installed content",
4508 )
4509 })?;
4510 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4511 *catalog = next_catalog;
4512 Ok(receipt)
4513 }
4514
4515 fn abort_delivery_stage(
4516 &self,
4517 stage_id: &DeliveryStageId,
4518 ) -> Result<(), NodeFailure> {
4519 let mut store = self
4520 .delivery_store
4521 .lock()
4522 .unwrap_or_else(|poisoned| poisoned.into_inner());
4523 let store = store.as_mut().ok_or_else(|| {
4524 failure(
4525 NodeFailureCode::DeliveryStageStorageFailed,
4526 "node delivery store is unavailable",
4527 )
4528 })?;
4529 store.abort(stage_id).map_err(delivery_failure)
4530 }
4531
4532 fn resolve_context(
4533 &self,
4534 resolved: &ResolvedSpawnSpec,
4535 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4536 let Some(context_id) = resolved.context_id.as_ref() else {
4537 return Ok(None);
4538 };
4539 self.context_catalog
4540 .read()
4541 .unwrap_or_else(|poisoned| poisoned.into_inner())
4542 .get(context_id)
4543 .map(|context| context.receipt().clone())
4544 .ok_or_else(|| {
4545 failure(
4546 NodeFailureCode::UnknownContextPack,
4547 "spawn context pack is unavailable on this node",
4548 )
4549 })
4550 .map(Some)
4551 }
4552
4553 fn bundle_layout(
4554 &self,
4555 provider: &AgentId,
4556 mode: SessionMode,
4557 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4558 bundle: &NodeBundle,
4559 ) -> Result<BundleProviderLayout, NodeFailure> {
4560 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4561 failure(
4562 NodeFailureCode::BundleBindingMismatch,
4563 "spawn bundle does not match the provider and mode",
4564 )
4565 })?;
4566 if layout != BundleProviderLayout::Codex {
4567 return Ok(layout);
4568 }
4569 let profile = environment_profile.and_then(|receipt| {
4570 let profiles = self
4571 .environment_profiles
4572 .read()
4573 .unwrap_or_else(|poisoned| poisoned.into_inner());
4574 let binding = profiles.get(&receipt.profile_id)?;
4575 if binding.revision != receipt.profile_revision
4576 || &binding.provider != provider
4577 || binding.native_profile_id(mode).is_none()
4578 {
4579 return None;
4580 }
4581 self.environment_materialization_profiles
4582 .read()
4583 .unwrap_or_else(|poisoned| poisoned.into_inner())
4584 .get(&receipt.profile_id)
4585 .cloned()
4586 });
4587 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4588 return Err(failure(
4589 NodeFailureCode::BundleBindingMismatch,
4590 "Codex bundle requires an exact isolated CODEX_HOME profile",
4591 ));
4592 }
4593 Ok(layout)
4594 }
4595
4596 fn select_environment_profile(
4597 &self,
4598 instance_id: AgentInstanceId,
4599 provider: &AgentId,
4600 mode: SessionMode,
4601 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4602 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4603 let Some(environment_profile) = environment_profile else {
4604 return Ok(None);
4605 };
4606 let native_profile_id = {
4607 let profiles = self
4608 .environment_profiles
4609 .read()
4610 .unwrap_or_else(|poisoned| poisoned.into_inner());
4611 let binding = profiles
4612 .get(&environment_profile.profile_id)
4613 .ok_or_else(|| {
4614 failure(
4615 NodeFailureCode::UnknownEnvironmentProfile,
4616 "session environment profile is unavailable on this node",
4617 )
4618 })?;
4619 if &binding.revision != &environment_profile.profile_revision
4620 || &binding.provider != provider
4621 {
4622 return Err(failure(
4623 NodeFailureCode::EnvironmentProfileBindingMismatch,
4624 "session environment profile revision or provider changed",
4625 ));
4626 }
4627 binding.native_profile_id(mode).cloned().ok_or_else(|| {
4628 failure(
4629 NodeFailureCode::EnvironmentProfileBindingMismatch,
4630 "session environment profile does not support this mode",
4631 )
4632 })?
4633 };
4634 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4635 failure(
4636 NodeFailureCode::BackendOperationFailed,
4637 "native environment profile controller is unavailable",
4638 )
4639 })?;
4640 control
4641 .select_native_launch_profile(instance_id, native_profile_id)
4642 .map_err(|_| {
4643 failure(
4644 NodeFailureCode::BackendOperationFailed,
4645 "native environment profile selection failed",
4646 )
4647 })?;
4648 Ok(Some(NativeEnvironmentSelectionGuard {
4649 control: Some(control),
4650 instance_id,
4651 }))
4652 }
4653
4654 fn install_prepared_launch_overlay(
4655 &self,
4656 instance_id: AgentInstanceId,
4657 overlay: PreparedNativeLaunchOverlay,
4658 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4659 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4660 failure(
4661 NodeFailureCode::BackendOperationFailed,
4662 "native launch profile controller is unavailable",
4663 )
4664 })?;
4665 match overlay {
4666 PreparedNativeLaunchOverlay::Environment(overlay) => {
4667 control
4668 .install_native_launch_environment_overlay(instance_id, overlay)
4669 .map_err(|_| failure(
4670 NodeFailureCode::BackendOperationFailed,
4671 "session environment overlay installation failed",
4672 ))?;
4673 Ok(None)
4674 }
4675 PreparedNativeLaunchOverlay::Instance(overlay) => {
4676 control
4677 .install_native_instance_launch_overlay(instance_id, overlay)
4678 .map_err(|_| failure(
4679 NodeFailureCode::BundleBindingMismatch,
4680 "session bundle launch overlay installation failed",
4681 ))?;
4682 Ok(Some(NativeInstanceOverlayGuard {
4683 control: Some(control),
4684 instance_id,
4685 }))
4686 }
4687 }
4688 }
4689
4690 fn environment_materialization_profile(
4691 &self,
4692 environment_profile: &ResolvedEnvironmentProfileReceipt,
4693 ) -> Option<NodeSessionMaterializationProfile> {
4694 self.environment_materialization_profiles
4695 .read()
4696 .unwrap_or_else(|poisoned| poisoned.into_inner())
4697 .get(&environment_profile.profile_id)
4698 .cloned()
4699 }
4700
4701 fn materialization_profile(
4702 &self,
4703 provider: &AgentId,
4704 mode: SessionMode,
4705 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4706 bundle: Option<&ResolvedBundleReceipt>,
4707 context: Option<&ResolvedContextPackReceipt>,
4708 ) -> Result<Option<(
4709 NodeSessionMaterializationProfile,
4710 bool,
4711 Option<BundleProviderLayout>,
4712 )>, NodeFailure> {
4713 let environment = environment_profile
4714 .and_then(|receipt| self.environment_materialization_profile(receipt));
4715 let (bundle, bundle_layout) = match bundle {
4716 Some(receipt) => {
4717 let catalog = self.bundle_catalog.read()
4718 .unwrap_or_else(|poisoned| poisoned.into_inner());
4719 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4720 NodeFailureCode::UnknownBundle,
4721 "session bundle is unavailable on this node",
4722 ))?;
4723 if installed.receipt() != *receipt {
4724 return Err(failure(
4725 NodeFailureCode::BundleBindingMismatch,
4726 "session bundle revision or digest changed",
4727 ));
4728 }
4729 let layout = self.bundle_layout(
4730 provider,
4731 mode,
4732 environment_profile,
4733 installed,
4734 )?;
4735 (Some(installed.clone()), Some(layout))
4736 }
4737 None => (None, None),
4738 };
4739 let has_environment_overlay = environment.is_some() || context.is_some();
4740 let profile = match (environment, bundle.as_ref(), bundle_layout) {
4741 (None, None, None) => None,
4742 (Some(profile), None, None) => Some(Ok(profile)),
4743 (None, Some(bundle), Some(layout)) => {
4744 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4745 }
4746 (Some(profile), Some(bundle), Some(layout)) => {
4747 Some(profile.with_bundle(bundle, layout))
4748 }
4749 _ => {
4750 return Err(failure(
4751 NodeFailureCode::BundleBindingMismatch,
4752 "session bundle provider layout is unavailable",
4753 ));
4754 }
4755 };
4756 let profile = profile
4757 .transpose()
4758 .map_err(|_| failure(
4759 NodeFailureCode::BundleMaterializationFailed,
4760 "session bundle materialization profile is invalid",
4761 ))?;
4762 let profile = match (profile, context) {
4763 (Some(profile), Some(receipt)) => {
4764 let catalog = self.context_catalog
4765 .read()
4766 .unwrap_or_else(|poisoned| poisoned.into_inner());
4767 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4768 NodeFailureCode::UnknownContextPack,
4769 "session context pack is unavailable on this node",
4770 ))?;
4771 if pack.receipt() != receipt {
4772 return Err(failure(
4773 NodeFailureCode::ContextPackMaterializationFailed,
4774 "session context pack identity changed",
4775 ));
4776 }
4777 profile.with_context(pack)
4778 }
4779 (None, Some(receipt)) => {
4780 let catalog = self.context_catalog
4781 .read()
4782 .unwrap_or_else(|poisoned| poisoned.into_inner());
4783 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4784 NodeFailureCode::UnknownContextPack,
4785 "session context pack is unavailable on this node",
4786 ))?;
4787 if pack.receipt() != receipt {
4788 return Err(failure(
4789 NodeFailureCode::ContextPackMaterializationFailed,
4790 "session context pack identity changed",
4791 ));
4792 }
4793 NodeSessionMaterializationProfile::from_context(pack)
4794 }
4795 (Some(profile), None) => Ok(profile),
4796 (None, None) => return Ok(None),
4797 }
4798 .map_err(|_| failure(
4799 NodeFailureCode::ContextPackMaterializationFailed,
4800 "session context pack materialization profile is invalid",
4801 ))?;
4802 Ok(Some((profile, has_environment_overlay, bundle_layout)))
4803 }
4804
4805 fn prepare_session_materialization<'a>(
4806 &'a self,
4807 address: &SessionAddress,
4808 provider: &AgentId,
4809 mode: SessionMode,
4810 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4811 bundle: Option<&ResolvedBundleReceipt>,
4812 context: Option<&ResolvedContextPackReceipt>,
4813 managed_lease_id: Option<ManagedWorktreeLeaseId>,
4814 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
4815 let Some((profile, has_environment_overlay, bundle_layout)) =
4816 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
4817 else {
4818 return Ok(None);
4819 };
4820 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4821 failure(
4822 NodeFailureCode::BackendOperationFailed,
4823 "session environment materializer is unavailable",
4824 )
4825 })?;
4826 let materialization_failure_code = if context.is_some() {
4827 NodeFailureCode::ContextPackMaterializationFailed
4828 } else if bundle.is_some() {
4829 NodeFailureCode::BundleMaterializationFailed
4830 } else {
4831 NodeFailureCode::BackendOperationFailed
4832 };
4833 let id = MaterializationId::new(format!(
4834 "mat-{}-{}-{}",
4835 self.incarnation_id,
4836 address.session.instance_id.0,
4837 address.session.generation.0,
4838 ))
4839 .map_err(|_| {
4840 failure(
4841 NodeFailureCode::BackendOperationFailed,
4842 "session environment materialization identity failed",
4843 )
4844 })?;
4845 let owner = MaterializationOwner::Session {
4846 incarnation_id: self.incarnation_id,
4847 instance_id: address.session.instance_id,
4848 generation: address.session.generation,
4849 };
4850 let mut ownership = materializer
4851 .begin(
4852 id.clone(),
4853 environment_profile.cloned(),
4854 bundle.cloned(),
4855 context.cloned(),
4856 owner,
4857 managed_lease_id,
4858 &profile,
4859 unix_time_ms(),
4860 )
4861 .map_err(|_| {
4862 failure(
4863 materialization_failure_code,
4864 "session materialization preparation failed",
4865 )
4866 })?;
4867 {
4868 let _transaction = self
4869 .state_transaction
4870 .lock()
4871 .unwrap_or_else(|poisoned| poisoned.into_inner());
4872 let duplicate = self
4873 .materializations
4874 .lock()
4875 .unwrap_or_else(|poisoned| poisoned.into_inner())
4876 .insert(id.clone(), ownership.clone());
4877 if duplicate.is_some() {
4878 return Err(failure(
4879 NodeFailureCode::BackendOperationFailed,
4880 "session environment materialization identity conflict",
4881 ));
4882 }
4883 if let Err(error) = self.persist_state_locked() {
4884 self.materializations
4885 .lock()
4886 .unwrap_or_else(|poisoned| poisoned.into_inner())
4887 .remove(&id);
4888 return Err(persistence_failure(error));
4889 }
4890 }
4891 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
4892 Ok(environment) => environment,
4893 Err(_) => {
4894 let transaction = self
4895 .state_transaction
4896 .lock()
4897 .unwrap_or_else(|poisoned| poisoned.into_inner());
4898 self.materializations
4899 .lock()
4900 .unwrap_or_else(|poisoned| poisoned.into_inner())
4901 .insert(id.clone(), ownership.clone());
4902 if self.persist_state_locked().is_err() {
4903 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4904 }
4905 drop(transaction);
4906 if ownership.state() == MaterializationState::CleanupRequired {
4907 let _ = self.cleanup_materialization(&id);
4908 }
4909 return Err(failure(
4910 materialization_failure_code,
4911 "session materialization failed",
4912 ));
4913 }
4914 };
4915 {
4916 let _transaction = self
4917 .state_transaction
4918 .lock()
4919 .unwrap_or_else(|poisoned| poisoned.into_inner());
4920 self.materializations
4921 .lock()
4922 .unwrap_or_else(|poisoned| poisoned.into_inner())
4923 .insert(id.clone(), ownership.clone());
4924 if let Err(error) = self.persist_state_locked() {
4925 drop(_transaction);
4926 let _ = self.cleanup_materialization(&id);
4927 return Err(persistence_failure(error));
4928 }
4929 }
4930 let guard = SessionMaterializationGuard {
4931 shared: self,
4932 id: Some(id),
4933 provider_home: Some(ownership.provider_home().to_path_buf()),
4934 };
4935 let bundle_arguments = match bundle {
4936 Some(receipt) => {
4937 let catalog = self.bundle_catalog.read()
4938 .unwrap_or_else(|poisoned| poisoned.into_inner());
4939 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4940 NodeFailureCode::UnknownBundle,
4941 "session bundle is unavailable on this node",
4942 ))?;
4943 if installed.receipt() != *receipt {
4944 return Err(failure(
4945 NodeFailureCode::BundleBindingMismatch,
4946 "session bundle revision or digest changed",
4947 ));
4948 }
4949 let layout = bundle_layout.ok_or_else(|| failure(
4950 NodeFailureCode::BundleBindingMismatch,
4951 "materialized bundle provider layout is unavailable",
4952 ))?;
4953 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
4954 failure(
4955 NodeFailureCode::BundleMaterializationFailed,
4956 "session bundle byte revalidation failed",
4957 )
4958 })?;
4959 Some(bundle_launch_arguments(
4960 layout,
4961 ownership.bundle_root(),
4962 ownership.provider_home(),
4963 ).map_err(|_| failure(
4964 NodeFailureCode::BundleBindingMismatch,
4965 "session bundle launch binding is invalid",
4966 ))?)
4967 }
4968 None => None,
4969 };
4970 if let Some(receipt) = context {
4971 materializer
4972 .revalidate_context(&ownership, receipt)
4973 .map_err(|_| failure(
4974 NodeFailureCode::ContextPackMaterializationFailed,
4975 "session context pack byte revalidation failed",
4976 ))?;
4977 }
4978 let transport = match mode {
4979 SessionMode::Pty => TransportKind::Pty,
4980 SessionMode::Inline => TransportKind::Pipe,
4981 SessionMode::Acp => TransportKind::Acp,
4982 };
4983 let overlay = if let Some(extra_args) = bundle_arguments {
4984 Some(PreparedNativeLaunchOverlay::Instance(
4985 NativeInstanceLaunchOverlay::new(
4986 provider.clone(),
4987 transport,
4988 if has_environment_overlay {
4989 environment
4990 } else {
4991 Vec::new()
4992 },
4993 extra_args,
4994 )
4995 .map_err(|_| {
4996 failure(
4997 NodeFailureCode::BundleBindingMismatch,
4998 "session bundle launch overlay is invalid",
4999 )
5000 })?,
5001 ))
5002 } else if has_environment_overlay {
5003 let context_only = environment_profile.is_none()
5004 && bundle.is_none()
5005 && context.is_some();
5006 let overlay = if context_only {
5007 NativeLaunchEnvironmentOverlay::new_context_root(
5008 provider.clone(),
5009 transport,
5010 environment,
5011 )
5012 .map_err(|_| {
5013 failure(
5014 NodeFailureCode::ContextPackMaterializationFailed,
5015 "context-only launch overlay is invalid",
5016 )
5017 })?
5018 } else {
5019 NativeLaunchEnvironmentOverlay::new(
5020 provider.clone(),
5021 transport,
5022 environment,
5023 )
5024 .map_err(|_| {
5025 failure(
5026 NodeFailureCode::BackendOperationFailed,
5027 "session environment overlay is invalid",
5028 )
5029 })?
5030 };
5031 Some(PreparedNativeLaunchOverlay::Environment(overlay))
5032 } else {
5033 None
5034 };
5035 Ok(Some((overlay, guard)))
5036 }
5037
5038 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5039 let Some(mut ownership) = self
5040 .materializations
5041 .lock()
5042 .unwrap_or_else(|poisoned| poisoned.into_inner())
5043 .get(id)
5044 .cloned()
5045 else {
5046 return Ok(());
5047 };
5048 if ownership.state() != MaterializationState::CleanupRequired {
5049 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5050 failure(
5051 NodeFailureCode::BackendOperationFailed,
5052 "session environment cleanup state is invalid",
5053 )
5054 })?;
5055 }
5056 {
5057 let _transaction = self
5058 .state_transaction
5059 .lock()
5060 .unwrap_or_else(|poisoned| poisoned.into_inner());
5061 let previous = self
5062 .materializations
5063 .lock()
5064 .unwrap_or_else(|poisoned| poisoned.into_inner())
5065 .insert(id.clone(), ownership.clone());
5066 if let Err(error) = self.persist_state_locked() {
5067 let mut materializations = self
5068 .materializations
5069 .lock()
5070 .unwrap_or_else(|poisoned| poisoned.into_inner());
5071 if let Some(previous) = previous {
5072 materializations.insert(id.clone(), previous);
5073 }
5074 return Err(persistence_failure(error));
5075 }
5076 }
5077 let cleanup = self
5078 .session_environment_materializer
5079 .as_ref()
5080 .ok_or(())
5081 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5082 if cleanup.is_err() {
5083 let _ = ownership.mark_recovery_required(unix_time_ms());
5084 self.materializations
5085 .lock()
5086 .unwrap_or_else(|poisoned| poisoned.into_inner())
5087 .insert(id.clone(), ownership);
5088 if self.persist_state().is_err() {
5089 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5090 }
5091 return Err(failure(
5092 NodeFailureCode::BackendOperationFailed,
5093 "session environment cleanup requires recovery",
5094 ));
5095 }
5096 let _transaction = self
5097 .state_transaction
5098 .lock()
5099 .unwrap_or_else(|poisoned| poisoned.into_inner());
5100 self.materializations
5101 .lock()
5102 .unwrap_or_else(|poisoned| poisoned.into_inner())
5103 .remove(id);
5104 if let Err(error) = self.persist_state_locked() {
5105 self.materializations
5106 .lock()
5107 .unwrap_or_else(|poisoned| poisoned.into_inner())
5108 .insert(id.clone(), ownership);
5109 return Err(persistence_failure(error));
5110 }
5111 Ok(())
5112 }
5113
5114 fn resolve_record_materialization(
5115 &self,
5116 record_id: &SessionRecordId,
5117 provider: &AgentId,
5118 mode: SessionMode,
5119 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5120 bundle: Option<&ResolvedBundleReceipt>,
5121 context: Option<&ResolvedContextPackReceipt>,
5122 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5123 let ownership = {
5124 let materializations = self
5125 .materializations
5126 .lock()
5127 .unwrap_or_else(|poisoned| poisoned.into_inner());
5128 let mut matches = materializations.values().filter(|record| {
5129 record.owner()
5130 == &MaterializationOwner::Record {
5131 record_id: record_id.clone(),
5132 }
5133 });
5134 let ownership = matches.next().cloned();
5135 if matches.next().is_some() {
5136 return Err(failure(
5137 NodeFailureCode::BackendOperationFailed,
5138 "managed session has conflicting environment ownership",
5139 ));
5140 }
5141 ownership
5142 };
5143 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5144 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5145 failure(
5146 NodeFailureCode::ContextPackMaterializationFailed,
5147 "session context materializer is unavailable",
5148 )
5149 })?;
5150 let pack = materializer
5151 .revalidate_context(ownership, receipt)
5152 .map_err(|_| {
5153 self.mark_materialization_recovery_required(ownership.id());
5154 failure(
5155 NodeFailureCode::ContextPackMaterializationFailed,
5156 "managed session context pack revalidation failed",
5157 )
5158 })?;
5159 self.context_catalog
5160 .write()
5161 .unwrap_or_else(|poisoned| poisoned.into_inner())
5162 .insert(pack)
5163 .map_err(|_| failure(
5164 NodeFailureCode::ContextPackMaterializationFailed,
5165 "managed session context pack catalog restore failed",
5166 ))?;
5167 }
5168 let materialization = self.materialization_profile(
5169 provider,
5170 mode,
5171 environment_profile,
5172 bundle,
5173 context,
5174 )?;
5175 match (ownership, materialization) {
5176 (None, None) => Ok(None),
5177 (None, Some(_)) | (Some(_), None) => Err(failure(
5178 NodeFailureCode::BackendOperationFailed,
5179 "managed session materialization is unavailable",
5180 )),
5181 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5182 if ownership.environment_profile() != environment_profile
5183 || ownership.bundle() != bundle
5184 || ownership.context() != context
5185 || ownership.state() != MaterializationState::Ready
5186 {
5187 self.mark_materialization_recovery_required(ownership.id());
5188 return Err(failure(
5189 NodeFailureCode::BackendOperationFailed,
5190 "managed session environment ownership requires recovery",
5191 ));
5192 }
5193 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5194 failure(
5195 NodeFailureCode::BackendOperationFailed,
5196 "session environment materializer is unavailable",
5197 )
5198 })?;
5199 if materializer.revalidate(&ownership).is_err() {
5200 self.mark_materialization_recovery_required(ownership.id());
5201 return Err(failure(
5202 NodeFailureCode::BackendOperationFailed,
5203 "managed session environment revalidation failed",
5204 ));
5205 }
5206 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5207 let catalog = self.bundle_catalog.read()
5208 .unwrap_or_else(|poisoned| poisoned.into_inner());
5209 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5210 return Err(failure(
5211 NodeFailureCode::UnknownBundle,
5212 "managed session bundle is unavailable",
5213 ));
5214 };
5215 let layout = bundle_layout.ok_or_else(|| failure(
5216 NodeFailureCode::BundleBindingMismatch,
5217 "record bundle provider layout is unavailable",
5218 ))?;
5219 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5220 drop(catalog);
5221 self.mark_materialization_recovery_required(ownership.id());
5222 return Err(failure(
5223 NodeFailureCode::BundleMaterializationFailed,
5224 "managed session bundle revalidation failed",
5225 ));
5226 }
5227 Some(bundle_launch_arguments(
5228 layout,
5229 ownership.bundle_root(),
5230 ownership.provider_home(),
5231 ).map_err(|_| failure(
5232 NodeFailureCode::BundleBindingMismatch,
5233 "managed session bundle launch binding is invalid",
5234 ))?)
5235 } else {
5236 None
5237 };
5238 if !has_environment_materialization {
5239 let overlay = bundle_arguments.map(|extra_args| {
5240 NativeInstanceLaunchOverlay::new(
5241 provider.clone(),
5242 TransportKind::Pty,
5243 Vec::new(),
5244 extra_args,
5245 )
5246 .map(PreparedNativeLaunchOverlay::Instance)
5247 .map_err(|_| failure(
5248 NodeFailureCode::BundleBindingMismatch,
5249 "managed session bundle launch overlay is invalid",
5250 ))
5251 }).transpose()?;
5252 return Ok(Some((ownership.id().clone(), overlay)));
5253 }
5254 let environment = match materializer.resolve_environment(&ownership, &profile) {
5255 Ok(environment) => environment,
5256 Err(
5257 SessionEnvironmentMaterializeError::SecretUnavailable
5258 | SessionEnvironmentMaterializeError::SecretDenied
5259 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5260 ) => {
5261 return Err(failure(
5262 NodeFailureCode::BackendOperationFailed,
5263 "managed session environment resolution failed",
5264 ));
5265 }
5266 Err(
5267 SessionEnvironmentMaterializeError::InvalidRoot
5268 | SessionEnvironmentMaterializeError::OwnershipMismatch
5269 | SessionEnvironmentMaterializeError::Filesystem(_)
5270 | SessionEnvironmentMaterializeError::Record(_),
5271 ) => {
5272 self.mark_materialization_recovery_required(ownership.id());
5273 return Err(failure(
5274 NodeFailureCode::BackendOperationFailed,
5275 "managed session environment resolution failed",
5276 ));
5277 }
5278 };
5279 let transport = match mode {
5280 SessionMode::Pty => TransportKind::Pty,
5281 SessionMode::Inline => TransportKind::Pipe,
5282 SessionMode::Acp => TransportKind::Acp,
5283 };
5284 let overlay = match bundle_arguments {
5285 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5286 NativeInstanceLaunchOverlay::new(
5287 provider.clone(),
5288 transport,
5289 environment,
5290 extra_args,
5291 )
5292 .map_err(|_| failure(
5293 NodeFailureCode::BundleBindingMismatch,
5294 "managed session bundle launch overlay is invalid",
5295 ))?,
5296 ),
5297 None => PreparedNativeLaunchOverlay::Environment(
5298 NativeLaunchEnvironmentOverlay::new(
5299 provider.clone(),
5300 transport,
5301 environment,
5302 )
5303 .map_err(|_| {
5304 failure(
5305 NodeFailureCode::BackendOperationFailed,
5306 "managed session environment overlay is invalid",
5307 )
5308 })?,
5309 ),
5310 };
5311 Ok(Some((ownership.id().clone(), Some(overlay))))
5312 }
5313 }
5314 }
5315
5316 fn destroy_record_materialization(
5317 &self,
5318 record_id: &SessionRecordId,
5319 ) -> Result<bool, NodeFailure> {
5320 let ids = self
5321 .materializations
5322 .lock()
5323 .unwrap_or_else(|poisoned| poisoned.into_inner())
5324 .values()
5325 .filter_map(|record| {
5326 (record.owner()
5327 == &MaterializationOwner::Record {
5328 record_id: record_id.clone(),
5329 })
5330 .then(|| record.id().clone())
5331 })
5332 .collect::<Vec<_>>();
5333 if ids.len() > 1 {
5334 return Err(failure(
5335 NodeFailureCode::BackendOperationFailed,
5336 "managed session has conflicting environment ownership",
5337 ));
5338 }
5339 let Some(id) = ids.first().cloned() else {
5340 return Ok(false);
5341 };
5342 let (terminal_record, mut cleanup_ownership) = {
5343 let _transaction = self
5344 .state_transaction
5345 .lock()
5346 .unwrap_or_else(|poisoned| poisoned.into_inner());
5347 let original_record = self.record(record_id)?;
5348 let mut terminal_record = original_record.clone();
5349 terminal_record.provider_session = None;
5350 terminal_record.active_session = None;
5351 terminal_record.state = ManagedSessionState::Unavailable;
5352 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5353 terminal_record.updated_at_unix_ms = unix_time_ms();
5354 let original_ownership = self
5355 .materializations
5356 .lock()
5357 .unwrap_or_else(|poisoned| poisoned.into_inner())
5358 .get(&id)
5359 .cloned()
5360 .ok_or_else(|| {
5361 failure(
5362 NodeFailureCode::BackendOperationFailed,
5363 "managed session environment ownership disappeared during cleanup",
5364 )
5365 })?;
5366 let mut cleanup_ownership = original_ownership.clone();
5367 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5368 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5369 failure(
5370 NodeFailureCode::BackendOperationFailed,
5371 "managed session environment cleanup state is invalid",
5372 )
5373 })?;
5374 }
5375 self.session_records
5376 .lock()
5377 .unwrap_or_else(|poisoned| poisoned.into_inner())
5378 .records
5379 .insert(record_id.clone(), terminal_record.clone());
5380 self.materializations
5381 .lock()
5382 .unwrap_or_else(|poisoned| poisoned.into_inner())
5383 .insert(id.clone(), cleanup_ownership.clone());
5384 if let Err(error) = self.persist_state_locked() {
5385 self.session_records
5386 .lock()
5387 .unwrap_or_else(|poisoned| poisoned.into_inner())
5388 .records
5389 .insert(record_id.clone(), original_record);
5390 self.materializations
5391 .lock()
5392 .unwrap_or_else(|poisoned| poisoned.into_inner())
5393 .insert(id.clone(), original_ownership);
5394 return Err(persistence_failure(error));
5395 }
5396 (terminal_record, cleanup_ownership)
5397 };
5398 let cleanup = self
5399 .session_environment_materializer
5400 .as_ref()
5401 .ok_or(())
5402 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5403 if cleanup.is_err() {
5404 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5405 self.materializations
5406 .lock()
5407 .unwrap_or_else(|poisoned| poisoned.into_inner())
5408 .insert(id.clone(), cleanup_ownership);
5409 if self.persist_state().is_err() {
5410 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5411 }
5412 return Err(failure(
5413 NodeFailureCode::BackendOperationFailed,
5414 "managed session environment cleanup requires recovery",
5415 ));
5416 }
5417 let _transaction = self
5418 .state_transaction
5419 .lock()
5420 .unwrap_or_else(|poisoned| poisoned.into_inner());
5421 self.remove_record_memory(record_id);
5422 self.materializations
5423 .lock()
5424 .unwrap_or_else(|poisoned| poisoned.into_inner())
5425 .remove(&id);
5426 if let Err(error) = self.persist_state_locked() {
5427 self.session_records
5428 .lock()
5429 .unwrap_or_else(|poisoned| poisoned.into_inner())
5430 .records
5431 .insert(record_id.clone(), terminal_record);
5432 self.materializations
5433 .lock()
5434 .unwrap_or_else(|poisoned| poisoned.into_inner())
5435 .insert(id, cleanup_ownership);
5436 return Err(persistence_failure(error));
5437 }
5438 Ok(true)
5439 }
5440
5441 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5442 let changed = {
5443 let mut materializations = self
5444 .materializations
5445 .lock()
5446 .unwrap_or_else(|poisoned| poisoned.into_inner());
5447 let Some(ownership) = materializations.get_mut(id) else {
5448 return;
5449 };
5450 ownership.mark_recovery_required(unix_time_ms()).is_ok()
5451 };
5452 if changed && self.persist_state().is_err() {
5453 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5454 }
5455 }
5456
5457 fn reconcile_materializations(&self) {
5458 let records = self
5459 .materializations
5460 .lock()
5461 .unwrap_or_else(|poisoned| poisoned.into_inner())
5462 .values()
5463 .cloned()
5464 .collect::<Vec<_>>();
5465 for mut ownership in records {
5466 let result = match (ownership.state(), ownership.owner()) {
5467 (MaterializationState::Preparing, _)
5468 | (MaterializationState::CleanupRequired, _) => {
5469 self.cleanup_materialization(ownership.id())
5470 }
5471 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5472 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5473 self.materializations
5474 .lock()
5475 .unwrap_or_else(|poisoned| poisoned.into_inner())
5476 .insert(ownership.id().clone(), ownership.clone());
5477 self.persist_state().map_err(persistence_failure)
5478 } else {
5479 Err(failure(
5480 NodeFailureCode::BackendOperationFailed,
5481 "session environment reconciliation failed",
5482 ))
5483 }
5484 }
5485 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5486 .session_environment_materializer
5487 .as_ref()
5488 .ok_or_else(|| {
5489 failure(
5490 NodeFailureCode::BackendOperationFailed,
5491 "session environment materializer is unavailable",
5492 )
5493 })
5494 .and_then(|materializer| {
5495 materializer.revalidate(&ownership).map_err(|_| failure(
5496 NodeFailureCode::BackendOperationFailed,
5497 "session materialization revalidation failed",
5498 ))?;
5499 let record = self.record(record_id)?;
5500 if record.environment_profile.as_ref() != ownership.environment_profile()
5501 || record.bundle.as_ref() != ownership.bundle()
5502 || record.context.as_ref() != ownership.context()
5503 {
5504 return Err(failure(
5505 NodeFailureCode::BackendOperationFailed,
5506 "session materialization ownership changed after restart",
5507 ));
5508 }
5509 if let Some(receipt) = ownership.bundle() {
5510 let catalog = self.bundle_catalog.read()
5511 .unwrap_or_else(|poisoned| poisoned.into_inner());
5512 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5513 NodeFailureCode::UnknownBundle,
5514 "session bundle is unavailable after restart",
5515 ))?;
5516 if bundle.receipt() != *receipt {
5517 return Err(failure(
5518 NodeFailureCode::BundleBindingMismatch,
5519 "session bundle changed after restart",
5520 ));
5521 }
5522 let layout = self.bundle_layout(
5523 &record.provider,
5524 record.mode,
5525 record.environment_profile.as_ref(),
5526 bundle,
5527 )?;
5528 materializer
5529 .revalidate_bundle(&ownership, bundle, layout)
5530 .map_err(|_| failure(
5531 NodeFailureCode::BundleMaterializationFailed,
5532 "session bundle revalidation failed",
5533 ))?;
5534 }
5535 if let Some(receipt) = ownership.context() {
5536 let pack = materializer
5537 .revalidate_context(&ownership, receipt)
5538 .map_err(|_| {
5539 failure(
5540 NodeFailureCode::ContextPackMaterializationFailed,
5541 "session context revalidation failed",
5542 )
5543 })?;
5544 self.context_catalog
5545 .write()
5546 .unwrap_or_else(|poisoned| poisoned.into_inner())
5547 .insert(pack)
5548 .map_err(|_| {
5549 failure(
5550 NodeFailureCode::ContextPackMaterializationFailed,
5551 "session context catalog restore failed",
5552 )
5553 })?;
5554 }
5555 Ok(())
5556 }),
5557 (MaterializationState::RecoveryRequired, _) => Ok(()),
5558 };
5559 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5560 let _ = ownership.mark_recovery_required(unix_time_ms());
5561 self.materializations
5562 .lock()
5563 .unwrap_or_else(|poisoned| poisoned.into_inner())
5564 .insert(ownership.id().clone(), ownership);
5565 if self.persist_state().is_err() {
5566 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5567 }
5568 }
5569 }
5570 let materialized_profiles = self
5571 .environment_materialization_profiles
5572 .read()
5573 .unwrap_or_else(|poisoned| poisoned.into_inner())
5574 .keys()
5575 .cloned()
5576 .collect::<Vec<_>>();
5577 let ownership = self
5578 .materializations
5579 .lock()
5580 .unwrap_or_else(|poisoned| poisoned.into_inner())
5581 .values()
5582 .cloned()
5583 .collect::<Vec<_>>();
5584 let changed = {
5585 let mut session_records = self
5586 .session_records
5587 .lock()
5588 .unwrap_or_else(|poisoned| poisoned.into_inner());
5589 let mut changed = false;
5590 for record in session_records.records.values_mut() {
5591 let environment_profile = record.environment_profile.as_ref();
5592 if environment_profile.is_none()
5593 && record.bundle.is_none()
5594 && record.context.is_none()
5595 {
5596 continue;
5597 }
5598 if record.bundle.is_none()
5599 && environment_profile.is_some_and(|profile| {
5600 !materialized_profiles.contains(&profile.profile_id)
5601 })
5602 {
5603 continue;
5604 }
5605 let has_exact_ownership = ownership.iter().any(|candidate| {
5606 candidate.state() == MaterializationState::Ready
5607 && candidate.environment_profile() == environment_profile
5608 && candidate.bundle() == record.bundle.as_ref()
5609 && candidate.context() == record.context.as_ref()
5610 && candidate.owner()
5611 == &MaterializationOwner::Record {
5612 record_id: record.record_id.clone(),
5613 }
5614 });
5615 if !has_exact_ownership {
5616 record.provider_session = None;
5617 record.active_session = None;
5618 record.state = ManagedSessionState::Unavailable;
5619 record.last_error = Some(if record.bundle.is_some() {
5620 "bundle-unavailable".to_owned()
5621 } else if record.context.is_some() {
5622 "context-unavailable".to_owned()
5623 } else {
5624 "environment-profile-unavailable".to_owned()
5625 });
5626 record.updated_at_unix_ms = unix_time_ms();
5627 changed = true;
5628 }
5629 }
5630 changed
5631 };
5632 if changed && self.persist_state().is_err() {
5633 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5634 }
5635 }
5636
5637 fn replay_spawn_spec(
5638 &self,
5639 spec: &SpawnSpec,
5640 now: Instant,
5641 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5642 let mut cache = self
5643 .spawn_idempotency
5644 .lock()
5645 .unwrap_or_else(|poisoned| poisoned.into_inner());
5646 cache.entries.retain(|_, entry| entry.expires_at > now);
5647 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5648 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5649 return Err(failure(
5650 NodeFailureCode::SpawnIdempotencyConflict,
5651 "spawn idempotency key was reused across spawn kinds",
5652 ));
5653 };
5654 if existing != spec {
5655 return Err(failure(
5656 NodeFailureCode::SpawnIdempotencyConflict,
5657 "spawn idempotency key was reused with a different specification",
5658 ));
5659 }
5660 return Ok(Some(result.clone()));
5661 }
5662 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5663 return Err(failure(
5664 NodeFailureCode::SpawnIdempotencyCapacity,
5665 "spawn idempotency cache reached its bounded capacity",
5666 ));
5667 }
5668 Ok(None)
5669 }
5670
5671 fn remember_spawn_spec(
5672 &self,
5673 spec: SpawnSpec,
5674 result: Result<ResolvedSpawnReceipt, NodeFailure>,
5675 accepted_at: Instant,
5676 ) {
5677 self
5678 .spawn_idempotency
5679 .lock()
5680 .unwrap_or_else(|poisoned| poisoned.into_inner())
5681 .entries
5682 .insert(
5683 spec.idempotency_key.clone(),
5684 SpawnIdempotencyEntry {
5685 value: SpawnIdempotencyValue::Standard { spec, result },
5686 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5687 },
5688 );
5689 }
5690
5691 fn claim_harness_mcp_spawn(
5692 &self,
5693 _reservation_id: &HarnessMcpReservationId,
5694 _activation_digest: &HarnessMcpActivationDigest,
5695 spec: &SpawnSpec,
5696 now: Instant,
5697 ) -> Result<(), NodeFailure> {
5698 let mut cache = self.spawn_idempotency.lock()
5699 .unwrap_or_else(|poisoned| poisoned.into_inner());
5700 cache.entries.retain(|_, entry| entry.expires_at > now);
5701 if cache.entries.contains_key(&spec.idempotency_key) {
5702 return Err(failure(
5703 NodeFailureCode::SpawnIdempotencyConflict,
5704 "spawn idempotency key was reused across spawn kinds",
5705 ));
5706 }
5707 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5708 return Err(failure(
5709 NodeFailureCode::SpawnIdempotencyCapacity,
5710 "spawn idempotency cache reached its bounded capacity",
5711 ));
5712 }
5713 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5714 value: SpawnIdempotencyValue::HarnessMcp,
5715 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5716 });
5717 Ok(())
5718 }
5719
5720 fn replay_managed_spawn(
5721 &self,
5722 request: &ManagedWorktreeSpawnRequest,
5723 now: Instant,
5724 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5725 let mut cache = self.spawn_idempotency.lock()
5726 .unwrap_or_else(|poisoned| poisoned.into_inner());
5727 cache.entries.retain(|_, entry| entry.expires_at > now);
5728 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5729 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5730 return Err(failure(
5731 NodeFailureCode::SpawnIdempotencyConflict,
5732 "spawn idempotency key was reused across spawn kinds",
5733 ));
5734 };
5735 if existing != request {
5736 return Err(failure(
5737 NodeFailureCode::SpawnIdempotencyConflict,
5738 "spawn idempotency key was reused with a different managed specification",
5739 ));
5740 }
5741 return Ok(Some(result.clone()));
5742 }
5743 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5744 return Err(failure(
5745 NodeFailureCode::SpawnIdempotencyCapacity,
5746 "spawn idempotency cache reached its bounded capacity",
5747 ));
5748 }
5749 Ok(None)
5750 }
5751
5752 fn remember_managed_spawn(
5753 &self,
5754 request: ManagedWorktreeSpawnRequest,
5755 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5756 accepted_at: Instant,
5757 ) {
5758 self.spawn_idempotency.lock()
5759 .unwrap_or_else(|poisoned| poisoned.into_inner())
5760 .entries
5761 .insert(
5762 request.spawn_spec.idempotency_key.clone(),
5763 SpawnIdempotencyEntry {
5764 value: SpawnIdempotencyValue::Managed { request, result },
5765 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5766 },
5767 );
5768 }
5769
5770 fn remember_managed_spawn_attempt(
5771 &self,
5772 durable_v2_key: Option<&SpawnIdempotencyKey>,
5773 request: ManagedWorktreeSpawnRequest,
5774 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5775 accepted_at: Instant,
5776 ) {
5777 if durable_v2_key.is_none() {
5778 self.remember_managed_spawn(request, result, accepted_at);
5779 }
5780 }
5781
5782 fn acquire_durable_managed_spawn_v2(
5783 &self,
5784 request: &ManagedWorktreeSpawnRequestV2,
5785 request_digest: String,
5786 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5787 if self.state_path.is_none() {
5788 return Err(failure(
5789 NodeFailureCode::BackendOperationFailed,
5790 "managed worktree V2 requires durable node state",
5791 ));
5792 }
5793 let _transaction = self.state_transaction
5794 .lock()
5795 .unwrap_or_else(|poisoned| poisoned.into_inner());
5796 let key = request.spawn_spec.idempotency_key.clone();
5797 let mut previous = None;
5798 {
5799 let mut replays = self.managed_spawn_replays
5800 .lock()
5801 .unwrap_or_else(|poisoned| poisoned.into_inner());
5802 if let Some(record) = replays.get_mut(&key) {
5803 if record.request_digest != request_digest {
5804 return Err(failure(
5805 NodeFailureCode::SpawnIdempotencyConflict,
5806 "managed worktree V2 idempotency key was reused with a different request",
5807 ));
5808 }
5809 match &record.state {
5810 ManagedWorktreeSpawnReplayStateV10::Pending {
5811 owner_incarnation_id,
5812 lease_id: None,
5813 } if owner_incarnation_id != &self.incarnation_id => {
5814 previous = Some(record.clone());
5815 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
5816 owner_incarnation_id: self.incarnation_id,
5817 lease_id: None,
5818 };
5819 record.updated_at_unix_ms = unix_time_ms();
5820 }
5821 ManagedWorktreeSpawnReplayStateV10::Pending {
5822 lease_id: None,
5823 ..
5824 } => {
5825 return Err(failure(
5826 NodeFailureCode::BackendBusy,
5827 "managed worktree V2 reservation is already active",
5828 ));
5829 }
5830 ManagedWorktreeSpawnReplayStateV10::Pending {
5831 lease_id: Some(_),
5832 ..
5833 } => {
5834 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
5835 }
5836 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
5837 return Ok(DurableManagedSpawnReplayDecision::Committed(
5838 receipt.clone(),
5839 ));
5840 }
5841 }
5842 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
5843 return Err(failure(
5844 NodeFailureCode::SpawnIdempotencyCapacity,
5845 "durable managed worktree replay ledger reached its bounded capacity",
5846 ));
5847 } else {
5848 let now = unix_time_ms();
5849 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
5850 idempotency_key: key.clone(),
5851 request_digest,
5852 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
5853 profile_id: request.worktree_profile_id.clone(),
5854 expected_profile_revision: request.expected_profile_revision.clone(),
5855 state: ManagedWorktreeSpawnReplayStateV10::Pending {
5856 owner_incarnation_id: self.incarnation_id,
5857 lease_id: None,
5858 },
5859 created_at_unix_ms: now,
5860 updated_at_unix_ms: now,
5861 });
5862 }
5863 }
5864 if let Err(error) = self.persist_state_locked() {
5865 let mut replays = self.managed_spawn_replays
5866 .lock()
5867 .unwrap_or_else(|poisoned| poisoned.into_inner());
5868 match previous {
5869 Some(previous) => {
5870 replays.insert(key, previous);
5871 }
5872 None => {
5873 replays.remove(&key);
5874 }
5875 }
5876 return Err(persistence_failure(error));
5877 }
5878 Ok(DurableManagedSpawnReplayDecision::Reserved)
5879 }
5880
5881 fn link_durable_managed_spawn_v2_lease_locked(
5882 &self,
5883 key: &SpawnIdempotencyKey,
5884 lease_id: &ManagedWorktreeLeaseId,
5885 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
5886 let mut replays = self.managed_spawn_replays
5887 .lock()
5888 .unwrap_or_else(|poisoned| poisoned.into_inner());
5889 let record = replays.get_mut(key).ok_or_else(|| {
5890 failure(
5891 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5892 "managed worktree V2 reservation disappeared before allocation",
5893 )
5894 })?;
5895 let previous = record.clone();
5896 match &mut record.state {
5897 ManagedWorktreeSpawnReplayStateV10::Pending {
5898 lease_id: current,
5899 ..
5900 }
5901 if current.is_none() =>
5902 {
5903 *current = Some(lease_id.clone());
5904 record.updated_at_unix_ms = unix_time_ms();
5905 Ok(previous)
5906 }
5907 _ => Err(failure(
5908 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5909 "managed worktree V2 reservation is not allocation-ready",
5910 )),
5911 }
5912 }
5913
5914 fn restore_durable_managed_spawn_v2_locked(
5915 &self,
5916 key: &SpawnIdempotencyKey,
5917 previous: ManagedWorktreeSpawnReplayRecordV10,
5918 ) {
5919 self.managed_spawn_replays
5920 .lock()
5921 .unwrap_or_else(|poisoned| poisoned.into_inner())
5922 .insert(key.clone(), previous);
5923 }
5924
5925 fn commit_durable_managed_spawn_v2(
5926 &self,
5927 request: &ManagedWorktreeSpawnRequestV2,
5928 receipt: ManagedWorktreeSpawnReceipt,
5929 ) -> Result<(), NodeFailure> {
5930 let _transaction = self.state_transaction
5931 .lock()
5932 .unwrap_or_else(|poisoned| poisoned.into_inner());
5933 let key = &request.spawn_spec.idempotency_key;
5934 let previous = {
5935 let mut replays = self.managed_spawn_replays
5936 .lock()
5937 .unwrap_or_else(|poisoned| poisoned.into_inner());
5938 let record = replays.get_mut(key).ok_or_else(|| {
5939 failure(
5940 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5941 "managed worktree V2 reservation disappeared before commit",
5942 )
5943 })?;
5944 let previous = record.clone();
5945 match &record.state {
5946 ManagedWorktreeSpawnReplayStateV10::Pending {
5947 lease_id: Some(lease_id),
5948 ..
5949 } if lease_id == &receipt.lease.lease_id => {}
5950 _ => {
5951 return Err(failure(
5952 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5953 "managed worktree V2 reservation is not linked to the spawned lease",
5954 ));
5955 }
5956 }
5957 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
5958 record.updated_at_unix_ms = unix_time_ms();
5959 previous
5960 };
5961 if let Err(error) = self.persist_state_locked() {
5962 self.restore_durable_managed_spawn_v2_locked(key, previous);
5963 return Err(persistence_failure(error));
5964 }
5965 Ok(())
5966 }
5967
5968 fn remove_unallocated_durable_managed_spawn_v2(
5969 &self,
5970 request: &ManagedWorktreeSpawnRequestV2,
5971 ) -> Result<(), NodeFailure> {
5972 let _transaction = self.state_transaction
5973 .lock()
5974 .unwrap_or_else(|poisoned| poisoned.into_inner());
5975 let key = &request.spawn_spec.idempotency_key;
5976 let removed = {
5977 let mut replays = self.managed_spawn_replays
5978 .lock()
5979 .unwrap_or_else(|poisoned| poisoned.into_inner());
5980 match replays.get(key).map(|record| &record.state) {
5981 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
5982 lease_id: None,
5983 ..
5984 }) => {
5985 replays.remove(key)
5986 }
5987 _ => None,
5988 }
5989 };
5990 let Some(removed) = removed else {
5991 return Ok(());
5992 };
5993 if let Err(error) = self.persist_state_locked() {
5994 self.managed_spawn_replays
5995 .lock()
5996 .unwrap_or_else(|poisoned| poisoned.into_inner())
5997 .insert(key.clone(), removed);
5998 return Err(persistence_failure(error));
5999 }
6000 Ok(())
6001 }
6002
6003 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6004 if spec.target.node_id != self.node_id {
6005 return Err(failure(
6006 NodeFailureCode::SpawnTargetMismatch,
6007 "spawn target does not match this node",
6008 ));
6009 }
6010 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6011 failure(
6012 NodeFailureCode::UnknownSpawnProfile,
6013 "spawn profile is unavailable on this node",
6014 )
6015 })?;
6016 let resolved = spec.resolve(defaults).map_err(|error| match error {
6017 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6018 NodeFailureCode::SpawnProfileRevisionMismatch,
6019 "spawn profile revision does not match the loaded profile",
6020 ),
6021 _ => failure(
6022 NodeFailureCode::InvalidRequest,
6023 "spawn specification could not be resolved",
6024 ),
6025 })?;
6026 if resolved.target.worktree_id.is_some() {
6027 self.require_worktree_service(&resolved.target.workspace_id)?;
6028 }
6029 let environment_profile = self.resolve_environment_profile(&resolved)?;
6030 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6031 self.resolve_context(&resolved)?;
6032 Ok(resolved)
6033 }
6034
6035 async fn resolve_spawn_workspace(
6036 &self,
6037 resolved: &ResolvedSpawnSpec,
6038 deadline: Instant,
6039 ) -> Result<WorkspaceId, NodeFailure> {
6040 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6041 return Ok(resolved.target.workspace_id.clone());
6042 };
6043 if self.managed_worktrees.lock()
6044 .unwrap_or_else(|poisoned| poisoned.into_inner())
6045 .lease_for_workspace(worktree_id)
6046 .is_some()
6047 {
6048 return Err(failure(
6049 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6050 "managed worktree targets may only be spawned through their lease request",
6051 ));
6052 }
6053 self.require_worktree_service(&resolved.target.workspace_id)?;
6054 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6055 let selected_root = self.workspace_root(worktree_id)?;
6056 let remaining = spawn_deadline_remaining(deadline)?;
6057 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6058 .await
6059 .map_err(|_| {
6060 failure(
6061 NodeFailureCode::SpawnDeadlineExceeded,
6062 "Git worktree selection exceeded the spawn deadline",
6063 )
6064 })?
6065 .map_err(git_worktree_failure)?;
6066 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6067 Ok(worktree_id.clone())
6068 }
6069
6070 async fn spawn_from_spec(
6071 &self,
6072 spec: SpawnSpec,
6073 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6074 self.spawn_from_spec_with_proxy(spec, None).await
6075 }
6076
6077 async fn spawn_from_spec_with_proxy(
6078 &self,
6079 spec: SpawnSpec,
6080 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6081 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6082 let accepted_at = Instant::now();
6083 if harness_mcp.is_none() {
6084 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6085 return replayed;
6086 }
6087 }
6088 let resolved = self.resolve_spawn_spec(&spec)?;
6089 let environment_profile = self.resolve_environment_profile(&resolved)?;
6090 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6091 let context = self.resolve_context(&resolved)?;
6092 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6093 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6094 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6095 let spawn_workspace_id = match spawn_workspace_id {
6096 Ok(workspace_id) => workspace_id,
6097 Err(error) => {
6098 let result = Err(error);
6099 if harness_mcp.is_none() {
6100 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6101 }
6102 return result;
6103 }
6104 };
6105 if self.managed_worktrees.lock()
6106 .unwrap_or_else(|poisoned| poisoned.into_inner())
6107 .lease_for_workspace(&spawn_workspace_id)
6108 .is_some()
6109 {
6110 let result = Err(failure(
6111 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6112 "managed worktree targets may only be spawned through their lease request",
6113 ));
6114 if harness_mcp.is_none() {
6115 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6116 }
6117 return result;
6118 }
6119 let result = self
6120 .spawn_session_with_deadline(
6121 spawn_workspace_id,
6122 resolved.provider.clone(),
6123 resolved.mode,
6124 resolved.terminal_size,
6125 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6126 environment_profile.clone(),
6127 bundle.clone(),
6128 context.clone(),
6129 None,
6130 None,
6131 SpawnRecordPolicy::Always,
6132 Some(deadline),
6133 &required_capabilities,
6134 harness_mcp,
6135 resolved.approval_level,
6136 resolved.network_allowlist.clone(),
6137 resolved.browser_profile_id.clone(),
6138 )
6139 .await
6140 .map(|(session, _runtime_policy)| {
6141 resolved.receipt_with_materialization(
6142 self.incarnation_id,
6143 session,
6144 environment_profile,
6145 bundle,
6146 context,
6147 )
6148 });
6149 if harness_mcp.is_none() {
6150 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6151 }
6152 result
6153 }
6154
6155 async fn spawn_from_spec_with_harness_mcp(
6156 &self,
6157 reservation_id: HarnessMcpReservationId,
6158 activation_digest: HarnessMcpActivationDigest,
6159 spec: SpawnSpec,
6160 deadline_unix_ms: u64,
6161 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6162 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6163 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6164 })?;
6165 if let Some(receipt) = registry
6166 .replay_spawn(&reservation_id, &activation_digest, &spec)
6167 .map_err(harness_mcp_failure)?
6168 {
6169 return Ok(receipt);
6170 }
6171 let resolved = self.resolve_spawn_spec(&spec)?;
6172 let prepared = registry
6173 .prepare_spawn(
6174 &reservation_id,
6175 &activation_digest,
6176 &spec,
6177 deadline_unix_ms,
6178 resolved.provider.clone(),
6179 )
6180 .map_err(harness_mcp_failure)?;
6181 self.claim_harness_mcp_spawn(
6182 &reservation_id,
6183 &activation_digest,
6184 &spec,
6185 Instant::now(),
6186 )?;
6187 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6188 let mut receipt = match spawn {
6189 Ok(receipt) => receipt,
6190 Err(error) => {
6191 let _ = registry.abort(&reservation_id, &activation_digest);
6192 return Err(error);
6193 }
6194 };
6195 let record_id = self
6196 .session_bindings
6197 .lock()
6198 .unwrap_or_else(|poisoned| poisoned.into_inner())
6199 .get(&receipt.session.session.instance_id)
6200 .and_then(|binding| binding.record_id.clone());
6201 let record_id = match record_id {
6202 Some(record_id) => record_id,
6203 None => {
6204 let rollback = self.remove_session(&receipt.session).await;
6205 let _ = registry.abort(&reservation_id, &activation_digest);
6206 if let Err(rollback_error) = rollback {
6207 return Err(rollback_error);
6208 }
6209 return Err(failure(
6210 NodeFailureCode::BindingMismatch,
6211 "harness MCP spawn did not retain its exact managed record",
6212 ));
6213 }
6214 };
6215 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6216 reservation_id: reservation_id.clone(),
6217 activation_digest: activation_digest.clone(),
6218 });
6219 if let Err(error) = registry.mark_spawned(
6220 &prepared,
6221 receipt.session.clone(),
6222 record_id,
6223 receipt.clone(),
6224 ) {
6225 let rollback = self.remove_session(&receipt.session).await;
6226 let _ = registry.abort(&reservation_id, &activation_digest);
6227 if let Err(rollback_error) = rollback {
6228 return Err(rollback_error);
6229 }
6230 return Err(harness_mcp_failure(error));
6231 }
6232 Ok(receipt)
6233 }
6234
6235 async fn spawn_managed_worktree(
6236 &self,
6237 request: ManagedWorktreeSpawnRequest,
6238 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6239 self.spawn_managed_worktree_inner(request, None).await
6240 }
6241
6242 async fn spawn_managed_worktree_inner(
6243 &self,
6244 request: ManagedWorktreeSpawnRequest,
6245 durable_v2_key: Option<&SpawnIdempotencyKey>,
6246 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6247 let accepted_at = Instant::now();
6248 if durable_v2_key.is_none() {
6249 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6250 return replayed;
6251 }
6252 }
6253 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6254 if request.spawn_spec.target.worktree_id.is_some() {
6255 let result = Err(failure(
6256 NodeFailureCode::InvalidRequest,
6257 "managed worktree spawn must not select a caller-provided worktree",
6258 ));
6259 self.remember_managed_spawn_attempt(
6260 durable_v2_key,
6261 request,
6262 result.clone(),
6263 accepted_at,
6264 );
6265 return result;
6266 }
6267 let environment_profile = self.resolve_environment_profile(&resolved)?;
6268 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6269 let context = self.resolve_context(&resolved)?;
6270 let source_workspace_id = resolved.target.workspace_id.clone();
6271 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6272 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6273 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6274 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6275 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6276 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6277 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6278 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6279 };
6280 let runtime_policy = timeout(
6281 spawn_deadline_remaining(deadline)?,
6282 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6283 ).await.map_err(|_| failure(
6284 NodeFailureCode::SpawnDeadlineExceeded,
6285 "provider admission exceeded the managed spawn deadline",
6286 ))??;
6287 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6288 return Err(failure(
6289 NodeFailureCode::UnsupportedSpawnCapability,
6290 "provider runtime does not admit a required spawn capability",
6291 ));
6292 }
6293 self.ensure_binding_capacity()?;
6294 let source_root = self.workspace_root(&source_workspace_id)?;
6295 profile.validate_for_workspace(&source_root)
6296 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6297 let base_timeout_ms = u64::try_from(
6298 spawn_deadline_remaining(deadline)?.as_millis(),
6299 ).unwrap_or(u64::MAX).max(1);
6300 let base_commit = resolve_base_commit_with_timeout(
6301 &source_root,
6302 profile.base(),
6303 base_timeout_ms,
6304 ).await.map_err(|error| {
6305 if Instant::now() >= deadline || error.message.contains("timed out") {
6306 failure(
6307 NodeFailureCode::SpawnDeadlineExceeded,
6308 "managed worktree base resolution exceeded the spawn deadline",
6309 )
6310 } else {
6311 managed_git_worktree_failure(error)
6312 }
6313 })?;
6314
6315 let lease = {
6316 let _transaction = self.state_transaction.lock()
6317 .unwrap_or_else(|poisoned| poisoned.into_inner());
6318 let previous = self.managed_worktrees.lock()
6319 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6320 let lease = self.managed_worktrees.lock()
6321 .unwrap_or_else(|poisoned| poisoned.into_inner())
6322 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6323 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6324 let previous_replay = match durable_v2_key
6325 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6326 .transpose()
6327 {
6328 Ok(previous) => previous,
6329 Err(error) => {
6330 *self.managed_worktrees.lock()
6331 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6332 return Err(error);
6333 }
6334 };
6335 if let Err(error) = self.persist_state_locked() {
6336 *self.managed_worktrees.lock()
6337 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6338 if let (Some(key), Some(previous_replay)) =
6339 (durable_v2_key, previous_replay)
6340 {
6341 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6342 }
6343 return Err(persistence_failure(error));
6344 }
6345 lease
6346 };
6347 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6348 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6349 self.managed_worktrees.lock()
6350 .unwrap_or_else(|poisoned| poisoned.into_inner())
6351 .remove_unmutated(&lease.lease_id);
6352 if self.persist_state().is_err() {
6353 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6354 }
6355 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6356 self.remember_managed_spawn_attempt(
6357 durable_v2_key,
6358 request,
6359 result.clone(),
6360 accepted_at,
6361 );
6362 return result;
6363 }
6364 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6365 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6366 Err(error) => {
6367 self.reconcile_failed_allocation(&lease, &source_root).await;
6368 let result = Err(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 };
6378 let created = match create_git_worktree_with_timeout(
6379 &source_root,
6380 &lease.target_root,
6381 &lease.branch,
6382 Some(&base_commit),
6383 mutation_timeout_ms,
6384 ).await {
6385 Ok(created) => created,
6386 Err(error) => {
6387 self.reconcile_failed_allocation(&lease, &source_root).await;
6388 let result = Err(if Instant::now() >= deadline
6389 || error.message.contains("timed out")
6390 || error.message.contains("deadline")
6391 {
6392 failure(
6393 NodeFailureCode::SpawnDeadlineExceeded,
6394 "managed worktree creation exceeded the spawn deadline",
6395 )
6396 } else {
6397 managed_git_worktree_failure(error)
6398 });
6399 self.remember_managed_spawn_attempt(
6400 durable_v2_key,
6401 request,
6402 result.clone(),
6403 accepted_at,
6404 );
6405 return result;
6406 }
6407 };
6408 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6409 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6410 let result = Err(failure(
6411 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6412 "created worktree identity did not match its durable lease",
6413 ));
6414 self.remember_managed_spawn_attempt(
6415 durable_v2_key,
6416 request,
6417 result.clone(),
6418 accepted_at,
6419 );
6420 return result;
6421 }
6422 {
6423 let mut registry = self.managed_worktrees.lock()
6424 .unwrap_or_else(|poisoned| poisoned.into_inner());
6425 let current = registry.get_mut(&lease.lease_id)
6426 .expect("allocated managed worktree lease remains present");
6427 current.expected_head = Some(created.head.clone());
6428 current.state = ManagedWorktreeLeaseState::Ready;
6429 current.updated_at_unix_ms = unix_time_ms();
6430 }
6431 if let Err(error) = self.persist_state() {
6432 self.mark_managed_recovery_required(
6433 &lease.lease_id,
6434 ManagedWorktreeCleanupFailure::Backend,
6435 );
6436 let result = Err(persistence_failure(error));
6437 self.remember_managed_spawn_attempt(
6438 durable_v2_key,
6439 request,
6440 result.clone(),
6441 accepted_at,
6442 );
6443 return result;
6444 }
6445 let ready = self.managed_worktrees.lock()
6446 .unwrap_or_else(|poisoned| poisoned.into_inner())
6447 .get(&lease.lease_id)
6448 .expect("ready managed worktree lease remains present")
6449 .snapshot();
6450 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6451 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6452 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6453 let result = Err(error);
6454 self.remember_managed_spawn_attempt(
6455 durable_v2_key,
6456 request,
6457 result.clone(),
6458 accepted_at,
6459 );
6460 return result;
6461 }
6462 let spawn = self.spawn_session_with_deadline(
6463 lease.workspace_id.clone(),
6464 resolved.provider.clone(),
6465 resolved.mode,
6466 resolved.terminal_size,
6467 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6468 environment_profile.clone(),
6469 bundle.clone(),
6470 context.clone(),
6471 Some(lease.lease_id.clone()),
6472 Some(runtime_policy),
6473 SpawnRecordPolicy::Always,
6474 Some(deadline),
6475 &required_capabilities,
6476 None,
6477 resolved.approval_level,
6478 resolved.network_allowlist.clone(),
6479 resolved.browser_profile_id.clone(),
6480 ).await;
6481 let (session, effective_runtime_policy) = match spawn {
6482 Ok(spawned) => spawned,
6483 Err(error) => {
6484 if self
6485 .cleanup_managed_worktree(&lease.lease_id, true)
6486 .await
6487 .is_err()
6488 {
6489 self.mark_managed_recovery_required(
6490 &lease.lease_id,
6491 ManagedWorktreeCleanupFailure::Backend,
6492 );
6493 }
6494 let result = Err(error);
6495 self.remember_managed_spawn_attempt(
6496 durable_v2_key,
6497 request,
6498 result.clone(),
6499 accepted_at,
6500 );
6501 return result;
6502 }
6503 };
6504 let (record_id, raw_inventory_record_id) = {
6505 let mut bindings = self.session_bindings.lock()
6506 .unwrap_or_else(|poisoned| poisoned.into_inner());
6507 let binding = bindings.get_mut(&session.session.instance_id)
6508 .expect("successful spawn retains its session binding");
6509 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6510 managed_spawn_record_ownership(binding, effective_runtime_policy)
6511 };
6512 let bound = self.managed_worktrees.lock()
6513 .unwrap_or_else(|poisoned| poisoned.into_inner())
6514 .bind_session(
6515 &lease.lease_id,
6516 ManagedWorktreeSessionHolder {
6517 incarnation_id: self.incarnation_id,
6518 instance_id: session.session.instance_id,
6519 generation: session.session.generation,
6520 },
6521 record_id,
6522 unix_time_ms(),
6523 )
6524 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6525 let snapshot = match bound {
6526 Ok(snapshot) => snapshot,
6527 Err(error) => {
6528 if raw_inventory_record_id
6529 .as_ref()
6530 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6531 {
6532 self.mark_managed_recovery_required(
6533 &lease.lease_id,
6534 ManagedWorktreeCleanupFailure::Backend,
6535 );
6536 }
6537 let _ = self.remove_session(&session).await;
6538 self.mark_managed_recovery_required(
6539 &lease.lease_id,
6540 ManagedWorktreeCleanupFailure::Backend,
6541 );
6542 let result = Err(error);
6543 self.remember_managed_spawn_attempt(
6544 durable_v2_key,
6545 request,
6546 result.clone(),
6547 accepted_at,
6548 );
6549 return result;
6550 }
6551 };
6552 if let Err(error) = self.persist_state() {
6553 if raw_inventory_record_id
6554 .as_ref()
6555 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6556 {
6557 self.mark_managed_recovery_required(
6558 &lease.lease_id,
6559 ManagedWorktreeCleanupFailure::Backend,
6560 );
6561 }
6562 let _ = self.remove_session(&session).await;
6563 if self
6564 .cleanup_managed_worktree(&lease.lease_id, true)
6565 .await
6566 .is_err()
6567 {
6568 self.mark_managed_recovery_required(
6569 &lease.lease_id,
6570 ManagedWorktreeCleanupFailure::Backend,
6571 );
6572 }
6573 let result = Err(persistence_failure(error));
6574 self.remember_managed_spawn_attempt(
6575 durable_v2_key,
6576 request,
6577 result.clone(),
6578 accepted_at,
6579 );
6580 return result;
6581 }
6582 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6583 let receipt = managed_spawn_receipt(
6584 &resolved,
6585 self.incarnation_id,
6586 session,
6587 snapshot,
6588 environment_profile,
6589 bundle,
6590 context,
6591 );
6592 let result = Ok(receipt);
6593 self.remember_managed_spawn_attempt(
6594 durable_v2_key,
6595 request,
6596 result.clone(),
6597 accepted_at,
6598 );
6599 result
6600 }
6601
6602 async fn spawn_managed_worktree_v2(
6603 &self,
6604 request: ManagedWorktreeSpawnRequestV2,
6605 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6606 if request.spawn_spec.target.node_id != self.node_id {
6607 return Err(failure(
6608 NodeFailureCode::SpawnTargetMismatch,
6609 "spawn target does not match this node",
6610 ));
6611 }
6612 if request.spawn_spec.target.worktree_id.is_some() {
6613 return Err(failure(
6614 NodeFailureCode::InvalidRequest,
6615 "managed worktree spawn must not select a caller-provided worktree",
6616 ));
6617 }
6618 let request_digest = managed_spawn_request_digest_v2(&request)?;
6619 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6620 match replay {
6621 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6622 DurableManagedSpawnReplayDecision::PendingLinked => {
6623 return Err(failure(
6624 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6625 "managed worktree V2 reservation has an incomplete durable allocation",
6626 ));
6627 }
6628 DurableManagedSpawnReplayDecision::Reserved => {}
6629 }
6630 let validated = (|| {
6631 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6632 let profile = self.managed_profile(
6633 &resolved.target.workspace_id,
6634 &request.worktree_profile_id,
6635 )?;
6636 if profile.revision() != &request.expected_profile_revision {
6637 return Err(failure(
6638 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6639 "managed worktree profile revision does not match the loaded profile",
6640 ));
6641 }
6642 Ok(())
6643 })();
6644 match validated {
6645 Ok(()) => {}
6646 Err(error) => {
6647 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6648 return Err(error);
6649 }
6650 }
6651 let legacy = ManagedWorktreeSpawnRequest {
6652 spawn_spec: request.spawn_spec.clone(),
6653 worktree_profile_id: request.worktree_profile_id.clone(),
6654 };
6655 match self
6656 .spawn_managed_worktree_inner(
6657 legacy,
6658 Some(&request.spawn_spec.idempotency_key),
6659 )
6660 .await
6661 {
6662 Ok(receipt) => {
6663 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6664 Ok(receipt)
6665 }
6666 Err(error) => {
6667 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6668 Err(error)
6669 }
6670 }
6671 }
6672
6673 fn managed_profile(
6674 &self,
6675 workspace_id: &WorkspaceId,
6676 profile_id: &WorktreeProfileId,
6677 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6678 if self.worktree_service_modes.get(workspace_id).copied()
6679 != Some(WorktreeServiceMode::Managed)
6680 {
6681 return Err(failure(
6682 NodeFailureCode::UnsupportedSpawnCapability,
6683 "workspace does not enable managed worktrees",
6684 ));
6685 }
6686 self.managed_worktree_profiles.get(workspace_id)
6687 .and_then(|profiles| profiles.get(profile_id))
6688 .cloned()
6689 .ok_or_else(|| failure(
6690 NodeFailureCode::InvalidRequest,
6691 "managed worktree profile is unavailable for this workspace",
6692 ))
6693 }
6694
6695 async fn reconcile_failed_allocation(
6696 &self,
6697 lease: &ManagedWorktreeLeaseRecord,
6698 source_root: &str,
6699 ) {
6700 match list_git_worktrees(source_root).await {
6701 Ok(worktrees) => {
6702 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6703 None => {
6704 match std::fs::symlink_metadata(&lease.target_root) {
6705 Err(error) if error.kind() == io::ErrorKind::NotFound => {
6706 self.managed_worktrees.lock()
6707 .unwrap_or_else(|poisoned| poisoned.into_inner())
6708 .remove_unmutated(&lease.lease_id);
6709 let _ = self.persist_state();
6710 }
6711 _ => self.mark_managed_recovery_required(
6712 &lease.lease_id,
6713 ManagedWorktreeCleanupFailure::OwnershipConflict,
6714 ),
6715 }
6716 }
6717 Some(item) if exact_owned_worktree(
6718 lease,
6719 &item.path,
6720 item.branch.as_deref(),
6721 &item.head,
6722 ) => {
6723 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6724 }
6725 Some(_) => self.mark_managed_recovery_required(
6726 &lease.lease_id,
6727 ManagedWorktreeCleanupFailure::OwnershipConflict,
6728 ),
6729 }
6730 }
6731 Err(_) => self.mark_managed_recovery_required(
6732 &lease.lease_id,
6733 ManagedWorktreeCleanupFailure::Backend,
6734 ),
6735 }
6736 }
6737
6738 async fn reconcile_managed_worktrees(&self) {
6739 let leases = self.managed_worktrees.lock()
6740 .unwrap_or_else(|poisoned| poisoned.into_inner())
6741 .active_records()
6742 .cloned()
6743 .collect::<Vec<_>>();
6744 for lease in leases {
6745 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6746 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6747 _ => {
6748 self.mark_managed_recovery_required(
6749 &lease.lease_id,
6750 ManagedWorktreeCleanupFailure::OwnershipConflict,
6751 );
6752 continue;
6753 }
6754 };
6755 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6756 self.mark_managed_recovery_required(
6757 &lease.lease_id,
6758 ManagedWorktreeCleanupFailure::OwnershipConflict,
6759 );
6760 continue;
6761 }
6762 let source_root = match self.workspace_root(&lease.source_workspace_id) {
6763 Ok(root) => root,
6764 Err(_) => {
6765 self.mark_managed_recovery_required(
6766 &lease.lease_id,
6767 ManagedWorktreeCleanupFailure::OwnershipConflict,
6768 );
6769 continue;
6770 }
6771 };
6772 let worktrees = match list_git_worktrees(&source_root).await {
6773 Ok(worktrees) => worktrees,
6774 Err(_) => {
6775 self.mark_managed_recovery_required(
6776 &lease.lease_id,
6777 ManagedWorktreeCleanupFailure::Backend,
6778 );
6779 continue;
6780 }
6781 };
6782 let listed = worktrees.iter()
6783 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6784 let Some(listed) = listed else {
6785 match std::fs::symlink_metadata(&lease.target_root) {
6786 Err(error) if error.kind() == io::ErrorKind::NotFound
6787 && lease.state == ManagedWorktreeLeaseState::Allocating
6788 && !lease.has_holders() => {
6789 let removed = self.managed_worktrees.lock()
6790 .unwrap_or_else(|poisoned| poisoned.into_inner())
6791 .tombstone(&lease.lease_id, unix_time_ms());
6792 if self.persist_state().is_ok() && removed.is_some() {
6793 self.publish(NodeEvent::ManagedWorktreeRemoved {
6794 lease_id: lease.lease_id.clone(),
6795 });
6796 }
6797 }
6798 _ => self.mark_managed_recovery_required(
6799 &lease.lease_id,
6800 ManagedWorktreeCleanupFailure::OwnershipConflict,
6801 ),
6802 }
6803 continue;
6804 };
6805 if listed.is_main || listed.is_bare || !exact_owned_worktree(
6806 &lease,
6807 &listed.path,
6808 listed.branch.as_deref(),
6809 &listed.head,
6810 ) {
6811 self.mark_managed_recovery_required(
6812 &lease.lease_id,
6813 ManagedWorktreeCleanupFailure::OwnershipConflict,
6814 );
6815 continue;
6816 }
6817 if !self.workspaces.read()
6818 .unwrap_or_else(|poisoned| poisoned.into_inner())
6819 .contains_key(&lease.workspace_id)
6820 {
6821 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
6822 self.mark_managed_recovery_required(
6823 &lease.lease_id,
6824 ManagedWorktreeCleanupFailure::Backend,
6825 );
6826 continue;
6827 }
6828 }
6829 let snapshot = {
6830 let mut registry = self.managed_worktrees.lock()
6831 .unwrap_or_else(|poisoned| poisoned.into_inner());
6832 let current = registry.get_mut(&lease.lease_id)
6833 .expect("reconciled managed lease remains present");
6834 current.expected_head = Some(listed.head.clone());
6835 current.cleanup_failure = None;
6836 current.state = if current.has_holders() {
6837 ManagedWorktreeLeaseState::InUse
6838 } else if current.retention == ManagedWorktreeRetention::Retain {
6839 ManagedWorktreeLeaseState::Retained
6840 } else {
6841 ManagedWorktreeLeaseState::Ready
6842 };
6843 current.updated_at_unix_ms = unix_time_ms();
6844 current.snapshot()
6845 };
6846 if self.persist_state().is_err() {
6847 self.mark_managed_recovery_required(
6848 &lease.lease_id,
6849 ManagedWorktreeCleanupFailure::Backend,
6850 );
6851 continue;
6852 }
6853 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6854 if snapshot.state == ManagedWorktreeLeaseState::Ready {
6855 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
6856 }
6857 }
6858 }
6859
6860 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
6880 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
6881 .session_records
6882 .lock()
6883 .unwrap_or_else(|poisoned| poisoned.into_inner())
6884 .records
6885 .values()
6886 .filter(|record| {
6887 record.state == ManagedSessionState::Live
6888 && record.exported_context.is_none()
6889 && record.provider_session.is_some()
6890 })
6891 .filter_map(|record| {
6892 record
6893 .active_session
6894 .clone()
6895 .map(|session| (record.record_id.clone(), session))
6896 })
6897 .collect();
6898 for (record_id, session) in candidates {
6899 let shared = Arc::clone(self);
6900 tokio::spawn(async move {
6901 shared
6902 .reconcile_context_pack_export_for_record(&record_id, session)
6903 .await;
6904 });
6905 }
6906 }
6907
6908 async fn reconcile_context_pack_export_for_record(
6927 &self,
6928 record_id: &SessionRecordId,
6929 session: SessionAddress,
6930 ) {
6931 let Ok(record) = self.record(record_id) else {
6932 return;
6933 };
6934 if !matches!(
6935 record.state,
6936 ManagedSessionState::Live | ManagedSessionState::Dormant
6937 ) || record.exported_context.is_some()
6938 || record.provider_session.is_none()
6939 {
6940 return;
6941 }
6942 let Ok(receipt) = self
6949 .export_context_pack_for_session_record(&record.record_id, &session, true)
6950 .await
6951 else {
6952 return;
6953 };
6954 self.set_exported_context(&record.record_id, receipt);
6955 }
6956
6957 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
6962 let transaction = self
6963 .state_transaction
6964 .lock()
6965 .unwrap_or_else(|poisoned| poisoned.into_inner());
6966 let (previous, updated) = {
6967 let mut records = self
6968 .session_records
6969 .lock()
6970 .unwrap_or_else(|poisoned| poisoned.into_inner());
6971 let Some(record) = records.records.get_mut(record_id) else {
6972 return;
6973 };
6974 if record.exported_context.is_some() {
6975 return;
6976 }
6977 let previous = record.clone();
6978 record.exported_context = Some(receipt);
6979 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
6980 (previous, record.clone())
6981 };
6982 if self.persist_state_locked().is_err() {
6983 self.session_records
6984 .lock()
6985 .unwrap_or_else(|poisoned| poisoned.into_inner())
6986 .records
6987 .insert(record_id.clone(), previous);
6988 return;
6989 }
6990 drop(transaction);
6991 self.publish_record(updated);
6992 }
6993
6994 fn managed_record_export_target_for_instance(
7004 &self,
7005 instance_id: AgentInstanceId,
7006 generation: SessionGeneration,
7007 ) -> Option<(SessionRecordId, SessionAddress)> {
7008 let bindings = self
7009 .session_bindings
7010 .lock()
7011 .unwrap_or_else(|poisoned| poisoned.into_inner());
7012 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7013 let record_id = binding.record_id.clone()?;
7014 Some((
7015 record_id,
7016 SessionAddress {
7017 workspace_id: binding.workspace_id.clone(),
7018 session: SessionKey { instance_id, generation },
7019 },
7020 ))
7021 }
7022
7023 fn mark_managed_recovery_required(
7024 &self,
7025 lease_id: &ManagedWorktreeLeaseId,
7026 failure_kind: ManagedWorktreeCleanupFailure,
7027 ) {
7028 let snapshot = {
7029 let mut registry = self.managed_worktrees.lock()
7030 .unwrap_or_else(|poisoned| poisoned.into_inner());
7031 let Some(lease) = registry.get_mut(lease_id) else { return };
7032 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7033 lease.cleanup_failure = Some(failure_kind);
7034 lease.updated_at_unix_ms = unix_time_ms();
7035 lease.snapshot()
7036 };
7037 let _ = self.persist_state();
7038 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7039 }
7040
7041 fn set_persistence_error(&self, error: Option<String>) {
7042 *self
7043 .persistence_error
7044 .write()
7045 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7046 .map(|message| session_registry::sanitized_persistence_summary(&message));
7047 }
7048
7049 fn persist_state(&self) -> io::Result<()> {
7050 let _transaction = self
7051 .state_transaction
7052 .lock()
7053 .unwrap_or_else(|poisoned| poisoned.into_inner());
7054 self.persist_state_locked()
7055 }
7056
7057 fn persist_state_locked(&self) -> io::Result<()> {
7058 let workspaces = self
7059 .workspaces
7060 .read()
7061 .unwrap_or_else(|poisoned| poisoned.into_inner())
7062 .clone();
7063 let records = self
7064 .session_records
7065 .lock()
7066 .unwrap_or_else(|poisoned| poisoned.into_inner())
7067 .records
7068 .values()
7069 .cloned()
7070 .collect::<Vec<_>>();
7071 let mut managed = self.managed_worktrees
7072 .lock()
7073 .unwrap_or_else(|poisoned| poisoned.into_inner());
7074 managed.reattach_record_holders(
7075 &managed_worktree_record_holders(&records),
7076 unix_time_ms(),
7077 );
7078 let materializations = self
7079 .materializations
7080 .lock()
7081 .unwrap_or_else(|poisoned| poisoned.into_inner())
7082 .values()
7083 .cloned()
7084 .collect::<Vec<_>>();
7085 let managed_spawn_replays = self.managed_spawn_replays
7086 .lock()
7087 .unwrap_or_else(|poisoned| poisoned.into_inner())
7088 .values()
7089 .cloned()
7090 .collect::<Vec<_>>();
7091 let result = session_registry::save_v10(
7092 self.state_path.as_deref(),
7093 &self.node_id,
7094 &workspaces,
7095 &records,
7096 &managed.records(),
7097 &managed.tombstones(),
7098 &materializations,
7099 &managed_spawn_replays,
7100 );
7101 match &result {
7102 Ok(warning) => self.set_persistence_error(warning.clone()),
7103 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7104 }
7105 result.map(|_| ())
7106 }
7107
7108 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7109 let _mutation_guard = self.mutation_gate.lock().await;
7110 self.begin_shutdown_locked().await
7111 }
7112
7113 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7114 if self.shutdown.swap(true, Ordering::AcqRel) {
7115 return Ok(());
7116 }
7117 SessionLifecycleHook::on_shutdown(self);
7118 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7119 let mut first_error = None;
7120 for session in &self.handle.snapshot().sessions {
7121 let remaining = deadline.saturating_duration_since(Instant::now());
7122 if remaining.is_zero() {
7123 first_error.get_or_insert_with(|| {
7124 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7125 });
7126 break;
7127 }
7128 if let Err(error) = self
7129 .dispatch_bounded(
7130 ControlCommand::Stop {
7131 instance_id: session.instance_id,
7132 force: true,
7133 },
7134 remaining,
7135 )
7136 .await
7137 {
7138 first_error.get_or_insert(error);
7139 }
7140 }
7141 self.shutdown_notify.notify_waiters();
7142 match first_error {
7143 Some(error) => Err(error),
7144 None => Ok(()),
7145 }
7146 }
7147
7148 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7149 let bindings = self
7150 .session_bindings
7151 .lock()
7152 .unwrap_or_else(|poisoned| poisoned.into_inner());
7153 if bindings.len() >= CONTROL_SESSIONS_MAX {
7154 Err(failure(
7155 NodeFailureCode::BackendBusy,
7156 "node session-binding capacity is full; remove an existing session first",
7157 ))
7158 } else {
7159 Ok(())
7160 }
7161 }
7162
7163 fn ensure_workspace_environment_profile_compatible(
7170 &self,
7171 workspace_id: &WorkspaceId,
7172 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7173 ) -> Result<(), NodeFailure> {
7174 let bindings = self
7175 .session_bindings
7176 .lock()
7177 .unwrap_or_else(|poisoned| poisoned.into_inner());
7178 for binding in bindings.values() {
7179 if &binding.workspace_id != workspace_id {
7180 continue;
7181 }
7182 if binding.environment_profile.as_ref() != environment_profile {
7183 return Err(failure(
7184 NodeFailureCode::WorkspaceBusy,
7185 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7186 ));
7187 }
7188 }
7189 Ok(())
7190 }
7191
7192 #[cfg(test)]
7193 fn bind_session(
7194 &self,
7195 instance_id: AgentInstanceId,
7196 workspace_id: WorkspaceId,
7197 generation: SessionGeneration,
7198 ) {
7199 self.bind_session_with_policy(
7200 &SessionAddress {
7201 workspace_id,
7202 session: SessionKey { instance_id, generation },
7203 },
7204 ProviderRuntimePolicy::raw_pty(),
7205 None,
7206 );
7207 }
7208
7209 #[cfg(test)]
7210 fn bind_session_with_policy(
7211 &self,
7212 address: &SessionAddress,
7213 runtime_policy: ProviderRuntimePolicy,
7214 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7215 ) {
7216 self.bind_session_with_materialization(
7217 address,
7218 runtime_policy,
7219 environment_profile,
7220 None,
7221 None,
7222 None,
7223 );
7224 }
7225
7226 fn bind_session_with_materialization(
7227 &self,
7228 address: &SessionAddress,
7229 runtime_policy: ProviderRuntimePolicy,
7230 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7231 bundle: Option<ResolvedBundleReceipt>,
7232 context: Option<ResolvedContextPackReceipt>,
7233 materialization_id: Option<MaterializationId>,
7234 ) {
7235 let mut bindings = self
7236 .session_bindings
7237 .lock()
7238 .unwrap_or_else(|poisoned| poisoned.into_inner());
7239 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7240 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7241 bindings.insert(address.session.instance_id, SessionBinding {
7242 workspace_id: address.workspace_id.clone(),
7243 generation: address.session.generation,
7244 runtime_policy,
7245 pending_resume: None,
7246 record_id: None,
7247 managed_worktree_lease_id: None,
7248 environment_profile,
7249 bundle,
7250 context,
7251 materialization_id,
7252 });
7253 }
7254
7255 #[cfg(test)]
7256 fn bind_managed_session(
7257 &self,
7258 address: &SessionAddress,
7259 record_id: SessionRecordId,
7260 runtime_policy: ProviderRuntimePolicy,
7261 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7262 ) {
7263 self.bind_managed_session_with_materialization(
7264 address,
7265 record_id,
7266 runtime_policy,
7267 environment_profile,
7268 None,
7269 None,
7270 None,
7271 );
7272 }
7273
7274 fn bind_managed_session_with_materialization(
7275 &self,
7276 address: &SessionAddress,
7277 record_id: SessionRecordId,
7278 runtime_policy: ProviderRuntimePolicy,
7279 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7280 bundle: Option<ResolvedBundleReceipt>,
7281 context: Option<ResolvedContextPackReceipt>,
7282 materialization_id: Option<MaterializationId>,
7283 ) {
7284 let mut bindings = self
7285 .session_bindings
7286 .lock()
7287 .unwrap_or_else(|poisoned| poisoned.into_inner());
7288 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7289 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7290 bindings.insert(
7291 address.session.instance_id,
7292 SessionBinding {
7293 workspace_id: address.workspace_id.clone(),
7294 generation: address.session.generation,
7295 runtime_policy,
7296 pending_resume: None,
7297 record_id: Some(record_id),
7298 managed_worktree_lease_id: None,
7299 environment_profile,
7300 bundle,
7301 context,
7302 materialization_id,
7303 },
7304 );
7305 }
7306
7307 #[cfg(test)]
7308 fn bind_spawn_session(
7309 &self,
7310 address: &SessionAddress,
7311 provider: AgentId,
7312 mode: SessionMode,
7313 runtime_policy: ProviderRuntimePolicy,
7314 record_policy: SpawnRecordPolicy,
7315 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7316 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7317 self.bind_spawn_session_with_materialization(
7318 address,
7319 provider,
7320 mode,
7321 runtime_policy,
7322 record_policy,
7323 environment_profile,
7324 None,
7325 None,
7326 None,
7327 )
7328 }
7329
7330 fn bind_spawn_session_with_materialization(
7331 &self,
7332 address: &SessionAddress,
7333 provider: AgentId,
7334 mode: SessionMode,
7335 runtime_policy: ProviderRuntimePolicy,
7336 record_policy: SpawnRecordPolicy,
7337 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7338 bundle: Option<ResolvedBundleReceipt>,
7339 context: Option<ResolvedContextPackReceipt>,
7340 materialization_id: Option<MaterializationId>,
7341 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7342 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7343 && !runtime_policy.provider_session_identity
7344 {
7345 self.bind_session_with_materialization(
7346 address,
7347 runtime_policy,
7348 environment_profile,
7349 bundle,
7350 context,
7351 materialization_id,
7352 );
7353 return Ok(None);
7354 }
7355 let record_owns_lifecycle = runtime_policy.provider_session_identity;
7356 let record = self.new_record(
7357 address,
7358 provider,
7359 mode,
7360 active_record_state(runtime_policy.provider_session_identity, false),
7361 None,
7362 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7363 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7364 record_owns_lifecycle.then(|| context.clone()).flatten(),
7365 )?;
7366 let record_id = record.record_id.clone();
7367 let transaction = self
7368 .state_transaction
7369 .lock()
7370 .unwrap_or_else(|poisoned| poisoned.into_inner());
7371 self.insert_record(record.clone())?;
7372 let previous_materialization = if record_owns_lifecycle {
7373 if let Some(materialization_id) = materialization_id.as_ref() {
7374 let mut materializations = self
7375 .materializations
7376 .lock()
7377 .unwrap_or_else(|poisoned| poisoned.into_inner());
7378 let Some(ownership) = materializations.get_mut(materialization_id) else {
7379 drop(materializations);
7380 self.remove_record_memory(&record_id);
7381 return Err(failure(
7382 NodeFailureCode::BackendOperationFailed,
7383 "session environment ownership disappeared before binding",
7384 ));
7385 };
7386 let previous = ownership.clone();
7387 if ownership
7388 .transfer_to_record(record_id.clone(), unix_time_ms())
7389 .is_err()
7390 {
7391 drop(materializations);
7392 self.remove_record_memory(&record_id);
7393 return Err(failure(
7394 NodeFailureCode::BackendOperationFailed,
7395 "session environment ownership transfer failed",
7396 ));
7397 }
7398 Some(previous)
7399 } else {
7400 None
7401 }
7402 } else {
7403 None
7404 };
7405 self.bind_managed_session_with_materialization(
7406 address,
7407 record_id.clone(),
7408 runtime_policy,
7409 environment_profile,
7410 bundle,
7411 context,
7412 materialization_id.clone(),
7413 );
7414 if let Err(error) = self.persist_state_locked() {
7415 self.remove_binding(address);
7416 self.remove_record_memory(&record_id);
7417 if let (Some(materialization_id), Some(previous)) =
7418 (materialization_id.as_ref(), previous_materialization)
7419 {
7420 self.materializations
7421 .lock()
7422 .unwrap_or_else(|poisoned| poisoned.into_inner())
7423 .insert(materialization_id.clone(), previous);
7424 }
7425 return Err(persistence_failure(error));
7426 }
7427 drop(transaction);
7428 self.publish_record(record);
7429 Ok(Some(record_id))
7430 }
7431
7432 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7433 for _ in 0..8 {
7434 let bytes = random_nonce().map_err(|error| {
7435 failure(
7436 NodeFailureCode::BackendOperationFailed,
7437 &format!("session record identity generation failed: {error}"),
7438 )
7439 })?;
7440 let mut value = String::with_capacity(27);
7441 value.push_str("sr-");
7442 for byte in &bytes[..12] {
7443 use std::fmt::Write as _;
7444 let _ = write!(&mut value, "{byte:02x}");
7445 }
7446 let record_id = SessionRecordId::new(value).map_err(|error| {
7447 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7448 })?;
7449 let records = self
7450 .session_records
7451 .lock()
7452 .unwrap_or_else(|poisoned| poisoned.into_inner());
7453 if !records.records.contains_key(&record_id) {
7454 return Ok(record_id);
7455 }
7456 }
7457 Err(failure(
7458 NodeFailureCode::BackendBusy,
7459 "could not allocate a unique session record ID",
7460 ))
7461 }
7462
7463 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7464 for _ in 0..8 {
7465 let bytes = random_nonce().map_err(|error| {
7466 failure(
7467 NodeFailureCode::BackendOperationFailed,
7468 &format!("task identity generation failed: {error}"),
7469 )
7470 })?;
7471 let mut nonce = [0_u8; 12];
7472 nonce.copy_from_slice(&bytes[..12]);
7473 let task_id = TaskId::from_nonce(nonce);
7474 let records = self
7475 .session_records
7476 .lock()
7477 .unwrap_or_else(|poisoned| poisoned.into_inner());
7478 if !records.records.values().any(|record| {
7479 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7480 == Some(&task_id)
7481 }) {
7482 return Ok(task_id);
7483 }
7484 }
7485 Err(failure(
7486 NodeFailureCode::BackendBusy,
7487 "could not allocate a unique task ID",
7488 ))
7489 }
7490
7491 fn default_record_name(&self, provider: &AgentId) -> String {
7492 let records = self
7493 .session_records
7494 .lock()
7495 .unwrap_or_else(|poisoned| poisoned.into_inner());
7496 let ordinal = records
7497 .records
7498 .values()
7499 .filter(|record| &record.provider == provider)
7500 .count()
7501 .saturating_add(1);
7502 format!("{} #{ordinal}", provider.as_str())
7503 }
7504
7505 fn new_record(
7506 &self,
7507 address: &SessionAddress,
7508 provider: AgentId,
7509 mode: SessionMode,
7510 state: ManagedSessionState,
7511 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7512 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7513 bundle: Option<ResolvedBundleReceipt>,
7514 context: Option<ResolvedContextPackReceipt>,
7515 ) -> Result<ManagedSessionRecord, NodeFailure> {
7516 let canonical_root = self.workspace_root(&address.workspace_id)?;
7517 let now = unix_time_ms();
7518 Ok(ManagedSessionRecord {
7519 record_id: self.allocate_record_id()?,
7520 display_name: self.default_record_name(&provider),
7521 provider,
7522 mode,
7523 state,
7524 workspace_id: address.workspace_id.clone(),
7525 canonical_root: opaque_windows_path(canonical_root),
7526 provider_session,
7527 active_session: Some(address.clone()),
7528 environment_profile,
7529 bundle,
7530 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7531 context,
7532 exported_context: None,
7533 task_binding: None,
7534 created_at_unix_ms: now,
7535 updated_at_unix_ms: now,
7536 last_error: None,
7537 })
7538 }
7539
7540 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7541 let mut records = self
7542 .session_records
7543 .lock()
7544 .unwrap_or_else(|poisoned| poisoned.into_inner());
7545 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7546 return Err(failure(
7547 NodeFailureCode::BackendBusy,
7548 "managed session record capacity is full; forget an inactive record first",
7549 ));
7550 }
7551 if let Some(identity) = record.provider_session.as_ref() {
7552 if records.records.values().any(|candidate| {
7553 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7554 session_registry::same_provider_session(
7555 &candidate.provider,
7556 candidate_identity,
7557 &record.provider,
7558 identity,
7559 )
7560 })
7561 }) {
7562 return Err(failure(
7563 NodeFailureCode::SessionRecordConflict,
7564 "provider session is already represented by another managed record",
7565 ));
7566 }
7567 }
7568 match records.records.entry(record.record_id.clone()) {
7569 std::collections::btree_map::Entry::Vacant(entry) => {
7570 entry.insert(record);
7571 }
7572 std::collections::btree_map::Entry::Occupied(_) => {
7573 return Err(failure(
7574 NodeFailureCode::SessionRecordConflict,
7575 "session record ID collision",
7576 ));
7577 }
7578 }
7579 Ok(())
7580 }
7581
7582 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7583 self.session_records
7584 .lock()
7585 .unwrap_or_else(|poisoned| poisoned.into_inner())
7586 .records
7587 .get(record_id)
7588 .cloned()
7589 .ok_or_else(|| {
7590 failure(
7591 NodeFailureCode::UnknownSessionRecord,
7592 "managed session record does not exist",
7593 )
7594 })
7595 }
7596
7597 fn revalidate_session_record_preview(
7598 &self,
7599 expected: &ManagedSessionRecord,
7600 identity: &ProviderSessionIdentity,
7601 ) -> Result<(), NodeFailure> {
7602 let current = self.record(&expected.record_id)?;
7603 let current_root = self.workspace_root(¤t.workspace_id)?;
7604 if current.provider != expected.provider
7605 || current.workspace_id != expected.workspace_id
7606 || current.canonical_root != expected.canonical_root
7607 || current.provider_session.as_ref() != Some(identity)
7608 || !platform::roots_equal(
7609 ¤t_root,
7610 windows_path_text(¤t.canonical_root),
7611 )
7612 {
7613 return Err(failure(
7614 NodeFailureCode::SessionRecordConflict,
7615 "managed session changed while its preview was loading",
7616 ));
7617 }
7618 Ok(())
7619 }
7620
7621 fn revalidate_session_record_context_export(
7622 &self,
7623 expected: &ManagedSessionRecord,
7624 identity: Option<&ProviderSessionIdentity>,
7625 session: &SessionAddress,
7626 allow_clean_detachment: bool,
7627 ) -> Result<(), NodeFailure> {
7628 let current = self.record(&expected.record_id)?;
7629 let current_root = self.workspace_root(¤t.workspace_id)?;
7630 let snapshot = self.internal_session_snapshot(session)?;
7631 let binding = self
7632 .session_bindings
7633 .lock()
7634 .unwrap_or_else(|poisoned| poisoned.into_inner())
7635 .get(&session.session.instance_id)
7636 .cloned()
7637 .ok_or_else(|| {
7638 failure(
7639 NodeFailureCode::SessionRecordConflict,
7640 "managed session runtime binding disappeared during context export",
7641 )
7642 })?;
7643 if !session_record_context_export_binding_is_exact(
7644 expected,
7645 ¤t,
7646 identity,
7647 session,
7648 &binding,
7649 &snapshot.agent_id,
7650 ¤t_root,
7651 allow_clean_detachment,
7652 ) || !session_record_context_export_source_is_usable(
7653 ¤t,
7654 &snapshot.status,
7655 allow_clean_detachment,
7656 ) {
7657 return Err(failure(
7658 NodeFailureCode::SessionRecordConflict,
7659 "managed session binding changed during context export",
7660 ));
7661 }
7662 Ok(())
7663 }
7664
7665 fn revalidate_loaded_history(
7666 &self,
7667 session: &SessionAddress,
7668 candidate_id: &str,
7669 loaded: &HistorySessionRecord,
7670 ) -> Result<(), NodeFailure> {
7671 let snapshot = self.internal_session_snapshot(session)?;
7672 if snapshot.history.pending.is_some()
7673 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7674 || snapshot.history.loaded.as_ref() != Some(loaded)
7675 {
7676 return Err(failure(
7677 NodeFailureCode::SessionRecordConflict,
7678 "managed session history changed during context export",
7679 ));
7680 }
7681 Ok(())
7682 }
7683
7684 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7685 self.session_records
7686 .lock()
7687 .unwrap_or_else(|poisoned| poisoned.into_inner())
7688 .records
7689 .remove(record_id)
7690 }
7691
7692 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7693 if self.destroy_record_materialization(record_id)? {
7694 self.publish(NodeEvent::SessionRecordRemoved {
7695 record_id: record_id.clone(),
7696 });
7697 return Ok(());
7698 }
7699 let transaction = self
7700 .state_transaction
7701 .lock()
7702 .unwrap_or_else(|poisoned| poisoned.into_inner());
7703 let Some(removed) = self.remove_record_memory(record_id) else {
7704 return Ok(());
7705 };
7706 if let Err(error) = self.persist_state_locked() {
7707 let _ = self.insert_record(removed);
7708 return Err(persistence_failure(error));
7709 }
7710 drop(transaction);
7711 self.publish(NodeEvent::SessionRecordRemoved {
7712 record_id: record_id.clone(),
7713 });
7714 Ok(())
7715 }
7716
7717 fn publish_record(&self, record: ManagedSessionRecord) {
7718 self.publish(NodeEvent::SessionRecordUpserted { record });
7719 }
7720
7721 fn mark_record_error(
7722 &self,
7723 record_id: &SessionRecordId,
7724 message: &str,
7725 ) -> Result<(), NodeFailure> {
7726 let transaction = self
7727 .state_transaction
7728 .lock()
7729 .unwrap_or_else(|poisoned| poisoned.into_inner());
7730 let previous = {
7731 let mut records = self
7732 .session_records
7733 .lock()
7734 .unwrap_or_else(|poisoned| poisoned.into_inner());
7735 let Some(record) = records.records.get_mut(record_id) else {
7736 return Ok(());
7737 };
7738 let previous = record.clone();
7739 record.state = ManagedSessionState::Unavailable;
7740 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7741 record.updated_at_unix_ms = unix_time_ms();
7742 previous
7743 };
7744 if let Err(error) = self.persist_state_locked() {
7745 self.session_records
7746 .lock()
7747 .unwrap_or_else(|poisoned| poisoned.into_inner())
7748 .records
7749 .insert(record_id.clone(), previous);
7750 return Err(persistence_failure(error));
7751 }
7752 let record = self.record(record_id)?;
7753 drop(transaction);
7754 self.publish_record(record);
7755 Ok(())
7756 }
7757
7758 fn rename_session_record(
7759 &self,
7760 record_id: &SessionRecordId,
7761 display_name: String,
7762 ) -> Result<ManagedSessionRecord, NodeFailure> {
7763 validate_display_name(&display_name)
7764 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7765 let _transaction = self
7766 .state_transaction
7767 .lock()
7768 .unwrap_or_else(|poisoned| poisoned.into_inner());
7769 let previous = {
7770 let mut records = self
7771 .session_records
7772 .lock()
7773 .unwrap_or_else(|poisoned| poisoned.into_inner());
7774 let record = records.records.get_mut(record_id).ok_or_else(|| {
7775 failure(
7776 NodeFailureCode::UnknownSessionRecord,
7777 "managed session record does not exist",
7778 )
7779 })?;
7780 let previous = record.clone();
7781 record.display_name = display_name;
7782 record.updated_at_unix_ms = unix_time_ms();
7783 previous
7784 };
7785 if let Err(error) = self.persist_state_locked() {
7786 self.session_records
7787 .lock()
7788 .unwrap_or_else(|poisoned| poisoned.into_inner())
7789 .records
7790 .insert(record_id.clone(), previous);
7791 return Err(persistence_failure(error));
7792 }
7793 let updated = self.record(record_id)?;
7794 self.publish_record(updated.clone());
7795 Ok(updated)
7796 }
7797
7798 fn set_session_task(
7799 &self,
7800 record_id: &SessionRecordId,
7801 expected_revision: u64,
7802 target: SessionTaskTargetV1,
7803 ) -> Result<ManagedSessionRecord, NodeFailure> {
7804 let transaction = self
7805 .state_transaction
7806 .lock()
7807 .unwrap_or_else(|poisoned| poisoned.into_inner());
7808 let current = self.record(record_id)?;
7809 let current_task_id = current
7810 .task_binding
7811 .as_ref()
7812 .and_then(|binding| binding.task_id.as_ref());
7813 let current_revision = current
7814 .task_binding
7815 .as_ref()
7816 .map_or(0, |binding| binding.revision);
7817 if expected_revision != current_revision {
7818 return Err(failure(
7819 NodeFailureCode::SessionRecordConflict,
7820 "managed session task binding revision changed",
7821 ));
7822 }
7823 let exact = match &target {
7824 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
7825 SessionTaskTargetV1::Clear => current_task_id.is_none(),
7826 SessionTaskTargetV1::New => false,
7827 };
7828 if exact {
7829 return Ok(current);
7830 }
7831 let revision = current_revision.checked_add(1).ok_or_else(|| {
7832 failure(
7833 NodeFailureCode::SessionRecordConflict,
7834 "managed session task binding revision is exhausted",
7835 )
7836 })?;
7837 let task_id = match target {
7838 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
7839 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
7840 SessionTaskTargetV1::Clear => None,
7841 };
7842 let changed_at_unix_ms = unix_time_ms()
7843 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
7844 failure(
7845 NodeFailureCode::SessionRecordConflict,
7846 "managed session record timestamp is exhausted",
7847 )
7848 })?);
7849 let updated = {
7850 let mut records = self
7851 .session_records
7852 .lock()
7853 .unwrap_or_else(|poisoned| poisoned.into_inner());
7854 let record = records.records.get_mut(record_id).ok_or_else(|| {
7855 failure(
7856 NodeFailureCode::UnknownSessionRecord,
7857 "managed session record does not exist",
7858 )
7859 })?;
7860 record.task_binding = Some(SessionTaskBindingV1 {
7861 revision,
7862 task_id,
7863 changed_at_unix_ms,
7864 });
7865 record.updated_at_unix_ms = changed_at_unix_ms;
7866 record.clone()
7867 };
7868 if let Err(error) = self.persist_state_locked() {
7869 self.session_records
7870 .lock()
7871 .unwrap_or_else(|poisoned| poisoned.into_inner())
7872 .records
7873 .insert(record_id.clone(), current);
7874 return Err(persistence_failure(error));
7875 }
7876 drop(transaction);
7877 self.publish_record(updated.clone());
7878 Ok(updated)
7879 }
7880
7881 fn index_provider_session(
7882 &self,
7883 workspace_id: WorkspaceId,
7884 provider: AgentId,
7885 identity: ProviderSessionIdentity,
7886 display_name: String,
7887 ) -> Result<ManagedSessionRecord, NodeFailure> {
7888 self.index_provider_session_with_policy(
7889 workspace_id,
7890 provider,
7891 identity,
7892 display_name,
7893 false,
7894 )
7895 }
7896
7897 fn index_provider_session_with_policy(
7898 &self,
7899 workspace_id: WorkspaceId,
7900 provider: AgentId,
7901 identity: ProviderSessionIdentity,
7902 display_name: String,
7903 allow_validated_transcript_path: bool,
7904 ) -> Result<ManagedSessionRecord, NodeFailure> {
7905 validate_display_name(&display_name)
7906 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7907 identity
7908 .validate()
7909 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7910 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
7911 return Err(failure(
7912 NodeFailureCode::InvalidRequest,
7913 "provider session reference index v1 does not accept transcript paths",
7914 ));
7915 }
7916 let canonical_root = self.workspace_root(&workspace_id)?;
7917 if !self.enabled_providers.contains(&provider) {
7918 return Err(failure(
7919 NodeFailureCode::InvalidRequest,
7920 "provider is not enabled on this node",
7921 ));
7922 }
7923
7924 let transaction = self
7925 .state_transaction
7926 .lock()
7927 .unwrap_or_else(|poisoned| poisoned.into_inner());
7928 {
7929 let records = self
7930 .session_records
7931 .lock()
7932 .unwrap_or_else(|poisoned| poisoned.into_inner());
7933 if let Some(existing) = records.records.values().find(|candidate| {
7934 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7935 candidate_identity.key == identity.key && candidate_identity.id == identity.id
7936 })
7937 }) {
7938 if existing.provider == provider && existing.workspace_id == workspace_id {
7939 return Ok(existing.clone());
7940 }
7941 return Err(failure(
7942 NodeFailureCode::SessionRecordConflict,
7943 "provider session reference conflicts with another provider or workspace",
7944 ));
7945 }
7946 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7947 return Err(failure(
7948 NodeFailureCode::BackendBusy,
7949 "managed session record capacity is full; forget an inactive record first",
7950 ));
7951 }
7952 }
7953
7954 let now = unix_time_ms();
7955 let record = ManagedSessionRecord {
7956 record_id: self.allocate_record_id()?,
7957 display_name,
7958 provider,
7959 mode: SessionMode::Pty,
7960 state: ManagedSessionState::Dormant,
7961 workspace_id,
7962 canonical_root: opaque_windows_path(canonical_root),
7963 provider_session: Some(identity),
7964 active_session: None,
7965 environment_profile: None,
7966 bundle: None,
7967 context_id: None,
7968 context: None,
7969 exported_context: None,
7970 task_binding: None,
7971 created_at_unix_ms: now,
7972 updated_at_unix_ms: now,
7973 last_error: None,
7974 };
7975 self.insert_record(record.clone())?;
7976 if let Err(error) = self.persist_state_locked() {
7977 self.remove_record_memory(&record.record_id);
7978 return Err(persistence_failure(error));
7979 }
7980 drop(transaction);
7981 self.publish_record(record.clone());
7982 Ok(record)
7983 }
7984
7985 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
7986 self.session_bindings
7987 .lock()
7988 .unwrap_or_else(|poisoned| poisoned.into_inner())
7989 .iter()
7990 .find_map(|(instance_id, binding)| {
7991 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
7992 workspace_id: binding.workspace_id.clone(),
7993 session: SessionKey {
7994 instance_id: *instance_id,
7995 generation: binding.generation,
7996 },
7997 })
7998 })
7999 }
8000
8001 async fn forget_session_record(
8002 &self,
8003 record_id: &SessionRecordId,
8004 ) -> Result<(), NodeFailure> {
8005 let record = self.record(record_id)?;
8006 if record.active_session.is_some()
8007 || matches!(
8008 record.state,
8009 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8010 )
8011 {
8012 return Err(failure(
8013 NodeFailureCode::SessionRecordBusy,
8014 "stop the managed session before forgetting it",
8015 ));
8016 }
8017 if let Some(address) = self.bound_address_for_record(record_id) {
8018 self.remove_session(&address).await?;
8019 }
8020 self.remove_managed_session_record(record_id, &record.workspace_id).await
8021 }
8022
8023 async fn remove_managed_session_record(
8038 &self,
8039 record_id: &SessionRecordId,
8040 workspace_id: &WorkspaceId,
8041 ) -> Result<(), NodeFailure> {
8042 let managed_lease_id = self.managed_worktrees.lock()
8043 .unwrap_or_else(|poisoned| poisoned.into_inner())
8044 .lease_for_workspace(workspace_id);
8045 if !self.destroy_record_materialization(record_id)? {
8046 let transaction = self
8047 .state_transaction
8048 .lock()
8049 .unwrap_or_else(|poisoned| poisoned.into_inner());
8050 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8051 failure(
8052 NodeFailureCode::UnknownSessionRecord,
8053 "managed session record does not exist",
8054 )
8055 })?;
8056 if let Err(error) = self.persist_state_locked() {
8057 let _ = self.insert_record(removed);
8058 return Err(persistence_failure(error));
8059 }
8060 drop(transaction);
8061 }
8062 self.publish(NodeEvent::SessionRecordRemoved {
8063 record_id: record_id.clone(),
8064 });
8065 if let Some(lease_id) = managed_lease_id {
8066 if let Some(snapshot) = self.managed_worktrees.lock()
8067 .unwrap_or_else(|poisoned| poisoned.into_inner())
8068 .get(&lease_id)
8069 .map(ManagedWorktreeLeaseRecord::snapshot)
8070 {
8071 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8072 }
8073 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8074 }
8075 Ok(())
8076 }
8077
8078 async fn retire_unavailable_session_records(&self) {
8091 let policy = self.session_record_retention;
8092 let age_enabled = policy.age_ms > 0;
8093 let keep_enabled = policy.keep_per_workspace > 0;
8094 if !age_enabled && !keep_enabled {
8095 return;
8096 }
8097 let now = unix_time_ms();
8098 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8099 self.session_records
8100 .lock()
8101 .unwrap_or_else(|poisoned| poisoned.into_inner())
8102 .records
8103 .values()
8104 .filter(|record| record.state == ManagedSessionState::Unavailable)
8105 .map(|record| {
8106 (
8107 record.record_id.clone(),
8108 record.workspace_id.clone(),
8109 record.provider.clone(),
8110 record.updated_at_unix_ms,
8111 )
8112 })
8113 .collect()
8114 };
8115 if candidates.is_empty() {
8116 return;
8117 }
8118 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8119 BTreeMap::new();
8120 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8121 by_workspace
8122 .entry(workspace_id)
8123 .or_default()
8124 .push((record_id, provider, updated_at_unix_ms));
8125 }
8126 let reason = if age_enabled && keep_enabled {
8127 "age+keep-n"
8128 } else if age_enabled {
8129 "age"
8130 } else {
8131 "keep-n"
8132 };
8133 let mut retired: u64 = 0;
8134 for (workspace_id, mut records) in by_workspace {
8135 records.sort_by(|left, right| right.2.cmp(&left.2));
8138 for (index, (record_id, provider, updated_at_unix_ms)) in
8139 records.into_iter().enumerate()
8140 {
8141 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8142 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8143 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8144 let should_retire = if age_enabled && keep_enabled {
8145 age_hit && keep_hit
8146 } else {
8147 age_hit || keep_hit
8148 };
8149 if !should_retire {
8150 continue;
8151 }
8152 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8153 Ok(()) => {
8154 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8155 retired += 1;
8156 tracing::info!(
8157 node_id = %self.node_id,
8158 record_id = %record_id,
8159 workspace_id = %workspace_id,
8160 provider = %provider,
8161 age_ms = record_age_ms,
8162 reason,
8163 "managed session record retired"
8164 );
8165 }
8166 Err(error) => tracing::warn!(
8167 node_id = %self.node_id,
8168 record_id = %record_id,
8169 workspace_id = %workspace_id,
8170 code = ?error.code,
8171 cause = %error.message,
8172 "session record retention sweep failed to retire a record"
8173 ),
8174 }
8175 }
8176 }
8177 if retired > 0 {
8178 let remaining_unavailable = self
8179 .session_records
8180 .lock()
8181 .unwrap_or_else(|poisoned| poisoned.into_inner())
8182 .records
8183 .values()
8184 .filter(|record| record.state == ManagedSessionState::Unavailable)
8185 .count();
8186 tracing::info!(
8187 node_id = %self.node_id,
8188 retired,
8189 remaining_unavailable,
8190 "session record retention sweep completed"
8191 );
8192 }
8193 }
8194
8195 async fn resume_session_record(
8196 &self,
8197 record_id: &SessionRecordId,
8198 terminal_size: gate4agent_types::TerminalSize,
8199 initial_prompt: Option<String>,
8200 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8201 let record = self.record(record_id)?;
8202 if record.active_session.is_some()
8203 || matches!(
8204 record.state,
8205 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8206 )
8207 {
8208 return Err(failure(
8209 NodeFailureCode::SessionRecordBusy,
8210 "managed session already has a live runtime binding",
8211 ));
8212 }
8213 let identity = record.provider_session.clone().ok_or_else(|| {
8214 failure(
8215 NodeFailureCode::SessionRecordNotResumable,
8216 "managed session has no verified provider session identity",
8217 )
8218 })?;
8219 let working_directory = self.workspace_root(&record.workspace_id)?;
8220 if !platform::roots_equal(
8221 &working_directory,
8222 windows_path_text(&record.canonical_root),
8223 ) {
8224 return Err(failure(
8225 NodeFailureCode::SessionRecordConflict,
8226 "managed session workspace root changed; refusing to resume in another directory",
8227 ));
8228 }
8229 {
8230 let records = self
8231 .session_records
8232 .lock()
8233 .unwrap_or_else(|poisoned| poisoned.into_inner());
8234 if records.records.values().any(|candidate| {
8235 candidate.record_id != *record_id
8236 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8237 session_registry::same_provider_session(
8238 &candidate.provider,
8239 candidate_identity,
8240 &record.provider,
8241 &identity,
8242 )
8243 })
8244 && (candidate.active_session.is_some()
8245 || matches!(
8246 candidate.state,
8247 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8248 ))
8249 }) {
8250 return Err(failure(
8251 NodeFailureCode::SessionRecordConflict,
8252 "provider session is already active through another managed record",
8253 ));
8254 }
8255 }
8256 let runtime_requirement = if initial_prompt.is_some() {
8257 ProviderRuntimeRequirement::ResumeWithPrompt
8258 } else {
8259 ProviderRuntimeRequirement::Resume
8260 };
8261 let runtime_policy = self.admit_provider_runtime(
8262 &record.provider,
8263 runtime_requirement,
8264 )
8265 .await?;
8266 let request = ResumeLaunchRequest {
8267 working_directory,
8268 terminal_size,
8269 initial_prompt,
8270 };
8271 request
8272 .validate()
8273 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8274 self.ensure_binding_capacity()?;
8275 let resolved_materialization = match self.resolve_record_materialization(
8276 record_id,
8277 &record.provider,
8278 record.mode,
8279 record.environment_profile.as_ref(),
8280 record.bundle.as_ref(),
8281 record.context.as_ref(),
8282 ) {
8283 Ok(materialization) => materialization,
8284 Err(error) => {
8285 let label = match error.code {
8286 NodeFailureCode::UnknownBundle => "bundle-unavailable",
8287 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8288 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8289 _ => "environment-profile-unavailable",
8290 };
8291 self.mark_record_error(record_id, label)?;
8292 return Err(error);
8293 }
8294 };
8295 let (materialization_id, environment_overlay) = match resolved_materialization {
8296 Some((id, overlay)) => (Some(id), overlay),
8297 None => (None, None),
8298 };
8299 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8300 let mut environment_selection = match SpawnPreparer::select_environment(self,
8301 instance_id,
8302 &record.provider,
8303 record.mode,
8304 record.environment_profile.as_ref(),
8305 ) {
8306 Ok(selection) => selection,
8307 Err(error) => {
8308 self.mark_record_error(record_id, "environment-profile-unavailable")?;
8309 return Err(error);
8310 }
8311 };
8312 let mut instance_overlay = environment_overlay
8313 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8314 .transpose()?
8315 .flatten();
8316 if let Some(stale_address) = self.bound_address_for_record(record_id) {
8317 self.remove_session(&stale_address).await?;
8318 }
8319 let address = SessionAddress {
8320 workspace_id: record.workspace_id.clone(),
8321 session: SessionKey {
8322 instance_id,
8323 generation: SessionGeneration(1),
8324 },
8325 };
8326 let transaction = self
8327 .state_transaction
8328 .lock()
8329 .unwrap_or_else(|poisoned| poisoned.into_inner());
8330 let current = self.record(record_id)?;
8331 if current.active_session.is_some()
8332 || matches!(
8333 current.state,
8334 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8335 )
8336 {
8337 return Err(failure(
8338 NodeFailureCode::SessionRecordBusy,
8339 "managed session became active while resume was being prepared",
8340 ));
8341 }
8342 self.bind_managed_session_with_materialization(
8343 &address,
8344 record_id.clone(),
8345 runtime_policy,
8346 record.environment_profile.clone(),
8347 record.bundle.clone(),
8348 record.context.clone(),
8349 materialization_id,
8350 );
8351 if let Some(selection) = environment_selection.take() {
8352 selection.retain();
8353 }
8354 if let Some(overlay) = instance_overlay.take() {
8355 overlay.retain();
8356 }
8357 drop(transaction);
8358
8359 let transport = match record.mode {
8360 SessionMode::Pty => TransportKind::Pty,
8361 SessionMode::Inline => TransportKind::Pipe,
8362 SessionMode::Acp => TransportKind::Acp,
8363 };
8364 let agent_id = record.provider.clone();
8365 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8366 if let Err(error) = self
8367 .dispatch_bounded(
8368 ControlCommand::Register {
8369 instance_id,
8370 agent_id,
8371 transport,
8372 },
8373 dispatch_timeout,
8374 )
8375 .await
8376 {
8377 if let Some(binding) = self.remove_binding(&address) {
8378 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8379 }
8380 return Err(error);
8381 }
8382 let command = self.prepare_command(ControlCommand::Resume {
8383 instance_id,
8384 target: ResumeTarget::ProviderSession { identity },
8385 runtime_policy,
8386 request,
8387 });
8388 let started_after = self.current_sequence();
8389 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8390 self.rollback_spawn(&address, dispatch_timeout).await?;
8391 return Err(error);
8392 }
8393 if let Err(error) = self.dispatch_envelope(command) {
8394 self.clear_armed_resume(&address);
8395 self.rollback_spawn(&address, dispatch_timeout).await?;
8396 return Err(error);
8397 }
8398 match self
8399 .wait_for_record_resume(&address, started_after)
8400 .await
8401 {
8402 Ok(settled_address) => {
8403 let managed_lease_id = {
8404 self.managed_worktrees.lock()
8405 .unwrap_or_else(|poisoned| poisoned.into_inner())
8406 .lease_for_workspace(&record.workspace_id)
8407 };
8408 if let Some(lease_id) = managed_lease_id {
8409 {
8410 let mut bindings = self.session_bindings.lock()
8411 .unwrap_or_else(|poisoned| poisoned.into_inner());
8412 let binding = bindings.get_mut(&settled_address.session.instance_id)
8413 .ok_or_else(|| failure(
8414 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8415 "resumed managed session lost its exact binding",
8416 ))?;
8417 if binding.generation != settled_address.session.generation {
8418 return Err(failure(
8419 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8420 "resumed managed session generation diverged",
8421 ));
8422 }
8423 binding.managed_worktree_lease_id = Some(lease_id.clone());
8424 }
8425 let snapshot = self.managed_worktrees.lock()
8426 .unwrap_or_else(|poisoned| poisoned.into_inner())
8427 .bind_session(
8428 &lease_id,
8429 ManagedWorktreeSessionHolder {
8430 incarnation_id: self.incarnation_id,
8431 instance_id: settled_address.session.instance_id,
8432 generation: settled_address.session.generation,
8433 },
8434 Some(record_id.clone()),
8435 unix_time_ms(),
8436 ).map_err(|message| failure(
8437 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8438 &message,
8439 ))?;
8440 if let Err(error) = self.persist_state() {
8441 let _ = self.remove_session(&settled_address).await;
8442 return Err(persistence_failure(error));
8443 }
8444 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8445 }
8446 Ok((self.record(record_id)?, settled_address))
8447 }
8448 Err(error) => {
8449 if matches!(
8450 error.code,
8451 NodeFailureCode::SessionRecordNotResumable
8452 | NodeFailureCode::BackendOperationFailed
8453 ) {
8454 if let Some(bound) = self.bound_address_for_record(record_id) {
8455 let _ = self.remove_session(&bound).await;
8456 }
8457 }
8458 Err(error)
8459 }
8460 }
8461 }
8462
8463 async fn wait_for_record_resume(
8464 &self,
8465 initial_address: &SessionAddress,
8466 after_sequence: u64,
8467 ) -> Result<SessionAddress, NodeFailure> {
8468 let deadline = Instant::now()
8469 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8470 let mut scan_after = after_sequence;
8471 loop {
8472 let events = self
8473 .history
8474 .lock()
8475 .unwrap_or_else(|poisoned| poisoned.into_inner())
8476 .events
8477 .iter()
8478 .filter(|event| event.sequence > scan_after)
8479 .cloned()
8480 .collect::<Vec<_>>();
8481 for envelope in events {
8482 scan_after = scan_after.max(envelope.sequence);
8483 let NodeEvent::Control { address, event } = envelope.event else {
8484 continue;
8485 };
8486 if address.session.instance_id != initial_address.session.instance_id {
8487 continue;
8488 }
8489 match event.event {
8490 ControlEventKind::Resumed { .. } => return Ok(address),
8491 ControlEventKind::ResumeDenied { reason } => {
8492 return Err(failure(
8493 NodeFailureCode::SessionRecordNotResumable,
8494 &reason,
8495 ));
8496 }
8497 ControlEventKind::ResumeFailed { message }
8498 | ControlEventKind::CommandRejected { message } => {
8499 return Err(failure(
8500 NodeFailureCode::BackendOperationFailed,
8501 &message,
8502 ));
8503 }
8504 _ => {}
8505 }
8506 }
8507 if Instant::now() >= deadline {
8508 return Err(failure(
8509 NodeFailureCode::BackendBusy,
8510 "managed resume did not settle before the bounded deadline",
8511 ));
8512 }
8513 sleep(Duration::from_millis(5)).await;
8514 }
8515 }
8516
8517 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8518 self.workspaces
8519 .read()
8520 .unwrap_or_else(|poisoned| poisoned.into_inner())
8521 .get(workspace_id)
8522 .cloned()
8523 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8524 }
8525
8526 fn workspace_roots(&self) -> Vec<PathBuf> {
8527 self.workspaces
8528 .read()
8529 .unwrap_or_else(|poisoned| poisoned.into_inner())
8530 .values()
8531 .map(PathBuf::from)
8532 .collect()
8533 }
8534
8535 async fn inspect_workspace(
8536 &self,
8537 workspace_id: WorkspaceId,
8538 ) -> Result<WorkspaceInspection, NodeFailure> {
8539 let _permit = self
8540 .inspection_slots
8541 .clone()
8542 .try_acquire_owned()
8543 .map_err(|_| {
8544 tracing::warn!(
8545 workspace_id = %workspace_id,
8546 "workspace inspection rejected: inspection capacity is busy",
8547 );
8548 failure(
8549 NodeFailureCode::BackendBusy,
8550 "workspace inspection capacity is busy",
8551 )
8552 })?;
8553 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8554 tracing::warn!(
8555 workspace_id = %workspace_id,
8556 code = ?error.code,
8557 "workspace inspection rejected: workspace is not registered",
8558 );
8559 error
8560 })?;
8561 let tree_root = canonical_root.clone();
8562 let started_at = Instant::now();
8563 let time_budget_ms = workspace_inspection_time_budget_ms();
8564 let entry_cap = workspace_inspection_entry_cap();
8565 let deadline = started_at + Duration::from_millis(time_budget_ms);
8566 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8567 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8568 })
8569 .await
8570 .map_err(|error| {
8571 tracing::warn!(
8572 workspace_id = %workspace_id,
8573 error = %error,
8574 "workspace inspection rejected: walk task failed",
8575 );
8576 failure(
8577 NodeFailureCode::BackendOperationFailed,
8578 &format!("workspace inspection task failed: {error}"),
8579 )
8580 })?;
8581 let (mut git, git_time_budget_exceeded) =
8582 inspect_git_workspace(&canonical_root, deadline).await;
8583 if !git.worktrees.is_empty() {
8584 let registered = self
8585 .workspaces
8586 .read()
8587 .unwrap_or_else(|poisoned| poisoned.into_inner())
8588 .iter()
8589 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8590 .collect::<Vec<_>>();
8591 for worktree in &mut git.worktrees {
8592 worktree.workspace_id = registered
8593 .iter()
8594 .find(|(_, root)| {
8595 worktree_paths_equal(root, windows_path_text(&worktree.path))
8596 })
8597 .map(|(workspace_id, _)| workspace_id.clone());
8598 }
8599 }
8600 if git.is_repository {
8601 git.managed_worktree = self
8602 .managed_worktrees
8603 .lock()
8604 .unwrap_or_else(|poisoned| poisoned.into_inner())
8605 .git_scope(&workspace_id, git.branch.as_deref());
8606 }
8607 let truncation = workspace_inspection_truncation(
8608 &walk_budget,
8609 git_time_budget_exceeded,
8610 started_at.elapsed(),
8611 );
8612 if let Some(truncation) = &truncation {
8613 tracing::info!(
8614 workspace_id = %workspace_id,
8615 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8616 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8617 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8618 entries_visited = truncation.entries_visited,
8619 elapsed_ms = truncation.elapsed_ms,
8620 "workspace inspection truncated by its inner budget",
8621 );
8622 }
8623 Ok(WorkspaceInspection {
8624 workspace_id,
8625 entries,
8626 tree_truncated,
8627 git,
8628 truncation,
8629 })
8630 }
8631
8632 async fn browse_host_directories(
8633 &self,
8634 directory: Option<OpaqueHostPath>,
8635 after: Option<OpaqueHostPath>,
8636 ) -> Result<HostDirectoryListing, NodeFailure> {
8637 let permit = self
8638 .inspection_slots
8639 .clone()
8640 .try_acquire_owned()
8641 .map_err(|_| {
8642 failure(
8643 NodeFailureCode::BackendBusy,
8644 "host directory browse capacity is busy",
8645 )
8646 })?;
8647 let task = tokio::task::spawn_blocking(move || {
8648 let _permit = permit;
8649 browse_host_directories(directory, after)
8650 });
8651 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8652 .await
8653 .map_err(|_| {
8654 failure(
8655 NodeFailureCode::HostDirectoryReadTimedOut,
8656 "host directory browse exceeded its bounded deadline",
8657 )
8658 })?
8659 .map_err(|_| {
8660 failure(
8661 NodeFailureCode::HostDirectoryReadFailed,
8662 "host directory browse task failed",
8663 )
8664 })?
8665 .map_err(host_directory_failure)
8666 }
8667
8668 async fn read_workspace_file(
8669 &self,
8670 workspace_id: WorkspaceId,
8671 path: RepositoryPath,
8672 ) -> Result<WorkspaceFileRead, NodeFailure> {
8673 let canonical_root = self.workspace_root(&workspace_id)?;
8674 let permit = self
8675 .inspection_slots
8676 .clone()
8677 .try_acquire_owned()
8678 .map_err(|_| {
8679 failure(
8680 NodeFailureCode::BackendBusy,
8681 "workspace file read capacity is busy",
8682 )
8683 })?;
8684 let path_to_read = path.clone();
8685 let task = tokio::task::spawn_blocking(move || {
8686 let _permit = permit;
8687 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8688 });
8689 let content = timeout(
8690 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8691 task,
8692 )
8693 .await
8694 .map_err(|_| {
8695 failure(
8696 NodeFailureCode::RepositoryFileReadTimedOut,
8697 "repository file read exceeded its bounded deadline",
8698 )
8699 })?
8700 .map_err(|_| {
8701 failure(
8702 NodeFailureCode::RepositoryFileReadFailed,
8703 "repository file read task failed",
8704 )
8705 })?
8706 .map_err(workspace_file_failure)?;
8707 let content = match content {
8708 WorkspaceFileBytes::Utf8(text) => {
8709 let revision = Some(workspace_file_revision(text.as_bytes()));
8710 return Ok(WorkspaceFileRead {
8711 workspace_id,
8712 path,
8713 content: WorkspaceFileContent::Utf8 {
8714 byte_len: u32::try_from(text.len())
8715 .expect("bounded workspace text length must fit u32"),
8716 text,
8717 },
8718 revision,
8719 });
8720 }
8721 WorkspaceFileBytes::NonUtf8 { byte_length } => {
8722 WorkspaceFileContent::NonUtf8 {
8723 byte_len: u32::try_from(byte_length)
8724 .expect("bounded workspace file length must fit u32"),
8725 }
8726 }
8727 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8728 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8729 },
8730 };
8731 Ok(WorkspaceFileRead {
8732 workspace_id,
8733 path,
8734 content,
8735 revision: None,
8736 })
8737 }
8738
8739 async fn write_workspace_file(
8740 &self,
8741 workspace_id: WorkspaceId,
8742 path: RepositoryPath,
8743 expected_revision: WorkspaceFileRevision,
8744 text: String,
8745 ) -> Result<WorkspaceFileRead, NodeFailure> {
8746 let canonical_root = self.workspace_root(&workspace_id)?;
8747 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8748 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8749 })?;
8750 let path_to_write = path.clone();
8751 let expected = expected_revision.as_str().to_owned();
8752 let text_to_write = text.clone();
8753 let task = tokio::task::spawn_blocking(move || {
8754 let _permit = permit;
8755 write_workspace_file_to_disk(
8756 Path::new(&canonical_root),
8757 &path_to_write,
8758 &expected,
8759 &text_to_write,
8760 )
8761 });
8762 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8763 .await
8764 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8765 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8766 .map_err(workspace_file_write_failure)?;
8767 Ok(WorkspaceFileRead {
8768 workspace_id,
8769 path,
8770 content: WorkspaceFileContent::Utf8 {
8771 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8772 text,
8773 },
8774 revision: Some(WorkspaceFileRevision::new(revision)
8775 .expect("workspace writer returns a valid SHA-256 revision")),
8776 })
8777 }
8778
8779 async fn create_workspace_file(
8780 &self,
8781 workspace_id: WorkspaceId,
8782 path: RepositoryPath,
8783 ) -> Result<WorkspaceFileRead, NodeFailure> {
8784 let canonical_root = self.workspace_root(&workspace_id)?;
8785 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8786 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8787 })?;
8788 let path_to_create = path.clone();
8789 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8790 let task_commit_state = Arc::clone(&commit_state);
8791 let task = tokio::task::spawn_blocking(move || {
8792 let _permit = permit;
8793 create_workspace_file_on_disk(
8794 Path::new(&canonical_root),
8795 &path_to_create,
8796 &task_commit_state,
8797 )
8798 });
8799 let revision = settle_workspace_entry_create(
8800 task,
8801 commit_state,
8802 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8803 "repository file create exceeded its bounded deadline",
8804 "repository file create task failed",
8805 )
8806 .await?;
8807 Ok(WorkspaceFileRead {
8808 workspace_id,
8809 path,
8810 content: WorkspaceFileContent::Utf8 {
8811 text: String::new(),
8812 byte_len: 0,
8813 },
8814 revision: Some(WorkspaceFileRevision::new(revision)
8815 .expect("workspace creator returns a valid SHA-256 revision")),
8816 })
8817 }
8818
8819 async fn create_workspace_directory(
8820 &self,
8821 workspace_id: WorkspaceId,
8822 path: RepositoryPath,
8823 ) -> Result<WorkspaceEntry, NodeFailure> {
8824 let canonical_root = self.workspace_root(&workspace_id)?;
8825 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8826 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8827 })?;
8828 let path_to_create = path.clone();
8829 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8830 let task_commit_state = Arc::clone(&commit_state);
8831 let task = tokio::task::spawn_blocking(move || {
8832 let _permit = permit;
8833 create_workspace_directory_on_disk(
8834 Path::new(&canonical_root),
8835 &path_to_create,
8836 &task_commit_state,
8837 )
8838 });
8839 settle_workspace_entry_create(
8840 task,
8841 commit_state,
8842 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8843 "repository directory create exceeded its bounded deadline",
8844 "repository directory create task failed",
8845 )
8846 .await?;
8847 Ok(WorkspaceEntry {
8848 relative_path: path,
8849 kind: WorkspaceEntryKind::Directory,
8850 })
8851 }
8852
8853 async fn read_git_history(
8854 &self,
8855 workspace_id: WorkspaceId,
8856 path: Option<RepositoryPath>,
8857 before: Option<GitObjectId>,
8858 limit: u16,
8859 ) -> Result<GitHistoryPage, NodeFailure> {
8860 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8861 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
8862 })?;
8863 let root = self.workspace_root(&workspace_id)?;
8864 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
8865 }
8866
8867 async fn read_git_diff(
8868 &self,
8869 workspace_id: WorkspaceId,
8870 request: GitDiffRequest,
8871 ) -> Result<GitDiff, NodeFailure> {
8872 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8873 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
8874 })?;
8875 let root = self.workspace_root(&workspace_id)?;
8876 read_git_diff_bounded(&root, request).await
8877 }
8878
8879 fn native_session_route_root(
8880 &self,
8881 route: &NativeSessionCatalogRoute,
8882 ) -> Result<Option<String>, NodeFailure> {
8883 route
8884 .validate()
8885 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8886 match route.scope {
8887 gate4agent_types::NativeSessionCatalogScope::Workspace => route
8888 .workspace_id
8889 .as_ref()
8890 .map(|workspace_id| self.workspace_root(workspace_id))
8891 .transpose(),
8892 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
8893 }
8894 }
8895
8896 fn project_native_session_entries(
8897 &self,
8898 route: &NativeSessionCatalogRoute,
8899 entries: Vec<ScopedNativeSessionCatalogEntry>,
8900 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
8901 let current_root = route
8902 .workspace_id
8903 .as_ref()
8904 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
8905 let records = self
8906 .session_records
8907 .lock()
8908 .unwrap_or_else(|poisoned| poisoned.into_inner());
8909 let mut seen_records = std::collections::HashSet::new();
8910 entries
8911 .into_iter()
8912 .map(|entry| {
8913 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
8914 let matches = records
8915 .records
8916 .values()
8917 .filter(|record| {
8918 record.provider == route.provider
8919 && record.workspace_id == *workspace_id
8920 && current_root.as_deref().is_some_and(|root| {
8921 platform::roots_equal(
8922 root,
8923 windows_path_text(&record.canonical_root),
8924 )
8925 })
8926 && record.provider_session.as_ref().is_some_and(|identity| {
8927 session_registry::same_provider_session(
8928 &record.provider,
8929 identity,
8930 &route.provider,
8931 &entry.provider_session,
8932 )
8933 })
8934 })
8935 .map(|record| record.record_id.clone())
8936 .collect::<Vec<_>>();
8937 (matches.len() == 1
8938 && seen_records.insert(matches[0].clone()))
8939 .then(|| matches[0].clone())
8940 });
8941 let metadata = entry.metadata;
8942 crate::protocol::NativeSessionCatalogEntry {
8943 selection_id: metadata.selection_id,
8944 title: metadata.title,
8945 modified_at_unix_ms: metadata.modified_at_unix_ms,
8946 model: metadata.model,
8947 message_count: metadata.message_count,
8948 completed_turn_count: metadata.completed_turn_count,
8949 external_group: entry.external_group,
8950 record_id,
8951 }
8952 })
8953 .collect()
8954 }
8955
8956 async fn catalog_native_sessions(
8957 &self,
8958 route: NativeSessionCatalogRoute,
8959 limit: u16,
8960 ) -> Result<(
8961 Vec<crate::protocol::NativeSessionCatalogEntry>,
8962 crate::protocol::NativeSessionCatalogSummary,
8963 ), NodeFailure> {
8964 let canonical_root = self.native_session_route_root(&route)?;
8965 let registered_workspace_roots = self.workspace_roots();
8966 let provider = route.provider.clone();
8967 let scope = route.scope;
8968 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8969 failure(
8970 NodeFailureCode::InvalidRequest,
8971 "native session catalog is not configured",
8972 )
8973 })?;
8974 let _permit = Arc::clone(&self.inspection_slots)
8975 .try_acquire_owned()
8976 .map_err(|_| {
8977 failure(
8978 NodeFailureCode::BackendBusy,
8979 "native session catalog capacity is busy",
8980 )
8981 })?;
8982 let page = run_native_session_catalog_operation(
8983 catalog,
8984 "native session catalog",
8985 move |catalog| {
8986 catalog.catalog_initial_for_scope(
8987 &provider,
8988 scope,
8989 canonical_root.as_deref().map(Path::new),
8990 ®istered_workspace_roots,
8991 limit,
8992 )
8993 },
8994 )
8995 .await?;
8996 let summary = crate::protocol::NativeSessionCatalogSummary {
8997 catalog_revision: page.revision,
8998 recent_cutoff_unix_ms: page.cutoff_unix_ms,
8999 recent_total_count: page.recent_total_count,
9000 older_total_count: page.older_total_count,
9001 recent_next_after_selection_id: page.next_after_selection_id,
9002 recent_has_more: page.remaining_count > 0,
9003 };
9004 Ok((self.project_native_session_entries(&route, page.entries), summary))
9005 }
9006
9007 async fn page_native_sessions(
9008 &self,
9009 route: NativeSessionCatalogRoute,
9010 window: crate::protocol::NativeSessionCatalogWindow,
9011 catalog_revision: u64,
9012 recent_cutoff_unix_ms: u64,
9013 after_selection_id: Option<String>,
9014 limit: u16,
9015 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9016 let canonical_root = self.native_session_route_root(&route)?;
9017 let registered_workspace_roots = self.workspace_roots();
9018 let provider = route.provider.clone();
9019 let scope = route.scope;
9020 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9021 failure(
9022 NodeFailureCode::InvalidRequest,
9023 "native session catalog 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 catalog capacity is busy",
9032 )
9033 })?;
9034 let page = run_native_session_catalog_operation(
9035 catalog,
9036 "native session catalog page",
9037 move |catalog| {
9038 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9039 &provider,
9040 scope,
9041 canonical_root.as_deref().map(Path::new),
9042 ®istered_workspace_roots,
9043 window,
9044 catalog_revision,
9045 recent_cutoff_unix_ms,
9046 after_selection_id.as_deref(),
9047 limit,
9048 ))
9049 },
9050 )
9051 .await?;
9052 let page = page.map_err(|error| match error {
9053 NativeSessionCatalogError::StaleCatalog => failure(
9054 NodeFailureCode::StaleNativeSessionCatalog,
9055 "native session catalog revision or cursor is stale",
9056 ),
9057 _ => failure(
9058 NodeFailureCode::BackendOperationFailed,
9059 "native session catalog page failed",
9060 ),
9061 })?;
9062 let entries = self.project_native_session_entries(&route, page.entries);
9063 Ok(crate::protocol::NativeSessionCatalogPage {
9064 window: page.window,
9065 revision: page.revision,
9066 entries,
9067 next_after_selection_id: page.next_after_selection_id,
9068 remaining_count: page.remaining_count,
9069 has_more: page.remaining_count > 0,
9070 })
9071 }
9072
9073 async fn preview_native_session(
9074 &self,
9075 selection: NativeSessionSelection,
9076 message_limit: u16,
9077 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9078 selection
9079 .validate()
9080 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9081 let canonical_root = self.native_session_route_root(&selection.route)?;
9082 let registered_workspace_roots = self.workspace_roots();
9083 let provider = selection.route.provider.clone();
9084 let scope = selection.route.scope;
9085 let catalog_revision = selection.catalog_revision;
9086 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9087 let selection_id = selection.selection_id.clone();
9088 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9089 failure(
9090 NodeFailureCode::InvalidRequest,
9091 "native session preview is not configured",
9092 )
9093 })?;
9094 let _permit = Arc::clone(&self.inspection_slots)
9095 .try_acquire_owned()
9096 .map_err(|_| {
9097 failure(
9098 NodeFailureCode::BackendBusy,
9099 "native session preview capacity is busy",
9100 )
9101 })?;
9102 let preview = run_native_session_catalog_operation(
9103 catalog,
9104 "native session preview",
9105 move |catalog| {
9106 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9107 &provider,
9108 scope,
9109 canonical_root.as_deref().map(Path::new),
9110 ®istered_workspace_roots,
9111 catalog_revision,
9112 recent_cutoff_unix_ms,
9113 &selection_id,
9114 message_limit,
9115 ))
9116 },
9117 )
9118 .await?;
9119 preview
9120 .map(Into::into)
9121 .map_err(native_session_preview_failure)
9122 }
9123
9124 async fn index_native_session(
9125 &self,
9126 selection: NativeSessionSelection,
9127 display_name: String,
9128 ) -> Result<ManagedSessionRecord, NodeFailure> {
9129 selection
9130 .validate()
9131 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9132 if selection.route.scope
9133 != gate4agent_types::NativeSessionCatalogScope::Workspace
9134 {
9135 return Err(failure(
9136 NodeFailureCode::WorkspaceRegistrationRequired,
9137 "register the external project as a workspace before indexing or resuming it",
9138 ));
9139 }
9140 let workspace_id = selection
9141 .route
9142 .workspace_id
9143 .clone()
9144 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9145 let canonical_root = self
9146 .native_session_route_root(&selection.route)?
9147 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9148 let registered_workspace_roots = self.workspace_roots();
9149 let provider = selection.route.provider.clone();
9150 let catalog_revision = selection.catalog_revision;
9151 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9152 let selection_id = selection.selection_id.clone();
9153 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9154 failure(
9155 NodeFailureCode::InvalidRequest,
9156 "native session index is not configured",
9157 )
9158 })?;
9159 let _permit = Arc::clone(&self.inspection_slots)
9160 .try_acquire_owned()
9161 .map_err(|_| {
9162 failure(
9163 NodeFailureCode::BackendBusy,
9164 "native session index capacity is busy",
9165 )
9166 })?;
9167 let resolved = run_native_session_catalog_operation(
9168 catalog,
9169 "native session index resolution",
9170 move |catalog| {
9171 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9172 &provider,
9173 gate4agent_types::NativeSessionCatalogScope::Workspace,
9174 Some(Path::new(&canonical_root)),
9175 ®istered_workspace_roots,
9176 catalog_revision,
9177 recent_cutoff_unix_ms,
9178 &selection_id,
9179 ))
9180 },
9181 )
9182 .await?
9183 .map_err(native_session_preview_failure)?;
9184 self.index_provider_session_with_policy(
9185 workspace_id,
9186 selection.route.provider,
9187 resolved.identity,
9188 display_name,
9189 true,
9190 )
9191 }
9192
9193 async fn preview_native_session_by_session_id(
9194 &self,
9195 workspace_id: WorkspaceId,
9196 provider: AgentId,
9197 session_id: String,
9198 message_limit: u16,
9199 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9200 let canonical_root = self.workspace_root(&workspace_id)?;
9201 let registered_workspace_roots = self.workspace_roots();
9202 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9203 failure(
9204 NodeFailureCode::InvalidRequest,
9205 "native session preview is not configured",
9206 )
9207 })?;
9208 let _permit = Arc::clone(&self.inspection_slots)
9209 .try_acquire_owned()
9210 .map_err(|_| {
9211 failure(
9212 NodeFailureCode::BackendBusy,
9213 "native session preview capacity is busy",
9214 )
9215 })?;
9216 run_native_session_catalog_operation(
9217 catalog,
9218 "native session preview",
9219 move |catalog| {
9220 catalog.preview_session_id_for_workspace(
9221 &provider,
9222 Path::new(&canonical_root),
9223 ®istered_workspace_roots,
9224 &session_id,
9225 message_limit,
9226 )
9227 },
9228 )
9229 .await
9230 }
9231
9232 async fn register_workspace(
9233 &self,
9234 workspace_id: WorkspaceId,
9235 root: String,
9236 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9237 validate_workspace_request_root(&root)?;
9238 let canonicalize_workspace_id = workspace_id.clone();
9239 let workspace = tokio::task::spawn_blocking(move || {
9240 WorkspaceConfig::new(canonicalize_workspace_id, root)
9241 })
9242 .await
9243 .map_err(|error| {
9244 failure(
9245 NodeFailureCode::BackendOperationFailed,
9246 &format!("workspace canonicalization task failed: {error}"),
9247 )
9248 })?
9249 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9250 let transaction = self
9251 .state_transaction
9252 .lock()
9253 .unwrap_or_else(|poisoned| poisoned.into_inner());
9254 let mut workspaces = self
9255 .workspaces
9256 .write()
9257 .unwrap_or_else(|poisoned| poisoned.into_inner());
9258 if workspaces.contains_key(&workspace_id) {
9259 return Err(failure(
9260 NodeFailureCode::DuplicateWorkspaceId,
9261 "workspace ID is already registered",
9262 ));
9263 }
9264 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9265 platform::roots_equal(existing_root, workspace.canonical_root())
9266 }) {
9267 return Err(failure(
9268 NodeFailureCode::DuplicateWorkspaceRoot,
9269 &format!("workspace root is already registered as '{existing_id}'"),
9270 ));
9271 }
9272 {
9273 let records = self
9274 .session_records
9275 .lock()
9276 .unwrap_or_else(|poisoned| poisoned.into_inner());
9277 if let Some(record) = records.records.values().find(|record| {
9278 record.workspace_id == workspace_id
9279 && !record
9280 .canonical_root
9281 .as_utf8()
9282 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9283 }) {
9284 return Err(failure(
9285 NodeFailureCode::SessionRecordConflict,
9286 &format!(
9287 "workspace ID is retained by session record '{}' at another root",
9288 record.record_id,
9289 ),
9290 ));
9291 }
9292 }
9293 let snapshot = WorkspaceSnapshot {
9294 workspace_id: workspace_id.clone(),
9295 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9296 sessions: Vec::new(),
9297 worktree_service_mode: Some(WorktreeServiceMode::Manual),
9298 managed_worktree_profiles: Some(WorktreeProfileInventory {
9299 profiles: Vec::new(),
9300 }),
9301 };
9302 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9303 drop(workspaces);
9304 let previous_records = {
9305 let mut records = self
9306 .session_records
9307 .lock()
9308 .unwrap_or_else(|poisoned| poisoned.into_inner());
9309 let previous = records.records.clone();
9310 for record in records.records.values_mut().filter(|record| {
9311 record.workspace_id == workspace_id
9312 && record
9313 .canonical_root
9314 .as_utf8()
9315 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9316 }) {
9317 if record.active_session.is_none() {
9318 record.state = detached_record_state(record);
9319 }
9320 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9321 record.last_error = None;
9322 }
9323 record.updated_at_unix_ms = unix_time_ms();
9324 }
9325 previous
9326 };
9327 if let Err(error) = self.persist_state_locked() {
9328 self.workspaces
9329 .write()
9330 .unwrap_or_else(|poisoned| poisoned.into_inner())
9331 .remove(&workspace_id);
9332 self.session_records
9333 .lock()
9334 .unwrap_or_else(|poisoned| poisoned.into_inner())
9335 .records = previous_records;
9336 return Err(persistence_failure(error));
9337 }
9338 let restored = self
9339 .session_records
9340 .lock()
9341 .unwrap_or_else(|poisoned| poisoned.into_inner())
9342 .records
9343 .values()
9344 .filter(|record| record.workspace_id == workspace_id)
9345 .cloned()
9346 .collect::<Vec<_>>();
9347 drop(transaction);
9348 self.publish(NodeEvent::WorkspaceAdded {
9349 workspace: snapshot.clone(),
9350 });
9351 for record in restored {
9352 self.publish_record(record);
9353 }
9354 Ok(snapshot)
9355 }
9356
9357 async fn create_standalone_workspace(
9358 &self,
9359 workspace_id: WorkspaceId,
9360 root: String,
9361 initial_branch: Option<String>,
9362 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9363 validate_workspace_request_root(&root)?;
9364 validate_git_revision("initial branch", initial_branch.as_deref())?;
9365 let preflight_workspace_id = workspace_id.clone();
9366 let preflight_root = root.clone();
9367 let canonical_candidate = tokio::task::spawn_blocking(move || {
9368 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9369 })
9370 .await
9371 .map_err(|_| failure(
9372 NodeFailureCode::BackendOperationFailed,
9373 "standalone workspace root preflight task failed",
9374 ))?
9375 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9376 {
9377 let workspaces = self
9378 .workspaces
9379 .read()
9380 .unwrap_or_else(|poisoned| poisoned.into_inner());
9381 if workspaces.contains_key(&workspace_id) {
9382 return Err(failure(
9383 NodeFailureCode::DuplicateWorkspaceId,
9384 "workspace ID is already registered",
9385 ));
9386 }
9387 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9388 platform::roots_equal(existing_root, &canonical_candidate)
9389 }) {
9390 return Err(failure(
9391 NodeFailureCode::DuplicateWorkspaceRoot,
9392 &format!("workspace root is already registered as '{existing_id}'"),
9393 ));
9394 }
9395 }
9396 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9397 .await
9398 .map_err(standalone_workspace_failure)?;
9399 prepared
9400 .verify_for_registration()
9401 .await
9402 .map_err(standalone_workspace_failure)?;
9403 match self
9404 .register_workspace(workspace_id, prepared.root().to_owned())
9405 .await
9406 {
9407 Ok(workspace) => Ok(workspace),
9408 Err(registration_error) => {
9409 if prepared.compensate_registration_failure().await {
9410 Err(registration_error)
9411 } else {
9412 Err(failure(
9413 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9414 "standalone workspace initialization succeeded but registration failed; recovery is required",
9415 ))
9416 }
9417 }
9418 }
9419 }
9420
9421 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9422 let bindings = self
9423 .session_bindings
9424 .lock()
9425 .unwrap_or_else(|poisoned| poisoned.into_inner());
9426 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9427 return Err(failure(
9428 NodeFailureCode::WorkspaceBusy,
9429 "workspace retains a bound session; remove every session before unregistering it",
9430 ));
9431 }
9432 drop(bindings);
9433 let transaction = self
9434 .state_transaction
9435 .lock()
9436 .unwrap_or_else(|poisoned| poisoned.into_inner());
9437 let mut workspaces = self
9438 .workspaces
9439 .write()
9440 .unwrap_or_else(|poisoned| poisoned.into_inner());
9441 if !workspaces.contains_key(workspace_id) {
9442 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9443 }
9444 if workspaces.len() == 1 {
9445 return Err(failure(
9446 NodeFailureCode::LastWorkspace,
9447 "node must retain at least one workspace",
9448 ));
9449 }
9450 let removed_root = workspaces
9451 .remove(workspace_id)
9452 .expect("workspace presence was validated");
9453 drop(workspaces);
9454 let previous_records = {
9455 let mut records = self
9456 .session_records
9457 .lock()
9458 .unwrap_or_else(|poisoned| poisoned.into_inner());
9459 let previous = records.records.clone();
9460 for record in records
9461 .records
9462 .values_mut()
9463 .filter(|record| &record.workspace_id == workspace_id)
9464 {
9465 record.active_session = None;
9466 record.state = ManagedSessionState::Unavailable;
9467 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9468 record.updated_at_unix_ms = unix_time_ms();
9469 }
9470 previous
9471 };
9472 if let Err(error) = self.persist_state_locked() {
9473 self.workspaces
9474 .write()
9475 .unwrap_or_else(|poisoned| poisoned.into_inner())
9476 .insert(workspace_id.clone(), removed_root);
9477 self.session_records
9478 .lock()
9479 .unwrap_or_else(|poisoned| poisoned.into_inner())
9480 .records = previous_records;
9481 return Err(persistence_failure(error));
9482 }
9483 let unavailable = self
9484 .session_records
9485 .lock()
9486 .unwrap_or_else(|poisoned| poisoned.into_inner())
9487 .records
9488 .values()
9489 .filter(|record| &record.workspace_id == workspace_id)
9490 .cloned()
9491 .collect::<Vec<_>>();
9492 drop(transaction);
9493 self.publish(NodeEvent::WorkspaceRemoved {
9494 workspace_id: workspace_id.clone(),
9495 });
9496 for record in unavailable {
9497 self.publish_record(record);
9498 }
9499 Ok(())
9500 }
9501
9502 async fn create_worktree(
9503 &self,
9504 source_workspace_id: WorkspaceId,
9505 workspace_id: WorkspaceId,
9506 target_root: String,
9507 branch: String,
9508 base: Option<String>,
9509 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9510 self.require_worktree_service(&source_workspace_id)?;
9511 validate_workspace_request_root(&target_root)?;
9512 validate_git_revision("worktree base", base.as_deref())?;
9513 let source_root = self.workspace_root(&source_workspace_id)?;
9514 {
9515 let workspaces = self
9516 .workspaces
9517 .read()
9518 .unwrap_or_else(|poisoned| poisoned.into_inner());
9519 if workspaces.contains_key(&workspace_id) {
9520 return Err(failure(
9521 NodeFailureCode::DuplicateWorkspaceId,
9522 "workspace ID is already registered",
9523 ));
9524 }
9525 if let Some((existing_id, _)) = workspaces
9526 .iter()
9527 .find(|(_, root)| worktree_paths_equal(root, &target_root))
9528 {
9529 return Err(failure(
9530 NodeFailureCode::DuplicateWorkspaceRoot,
9531 &format!("workspace root is already registered as '{existing_id}'"),
9532 ));
9533 }
9534 }
9535 let mut worktree = create_git_worktree(
9536 &source_root,
9537 &target_root,
9538 &branch,
9539 base.as_deref(),
9540 )
9541 .await
9542 .map_err(git_worktree_failure)?;
9543 let workspace = match self
9544 .register_workspace(workspace_id, worktree.path.clone())
9545 .await
9546 {
9547 Ok(workspace) => workspace,
9548 Err(registration_error) => {
9549 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9550 return Err(failure(
9551 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9552 "worktree registration failed and compensation requires recovery",
9553 ));
9554 }
9555 return Err(failure(
9556 registration_error.code,
9557 ®istration_error.message,
9558 ));
9559 }
9560 };
9561 worktree.workspace_id = Some(workspace.workspace_id.clone());
9562 Ok((protocol_worktree(worktree), workspace))
9563 }
9564
9565 async fn remove_worktree(
9566 &self,
9567 source_workspace_id: WorkspaceId,
9568 target_root: String,
9569 ) -> Result<Option<WorkspaceId>, NodeFailure> {
9570 self.require_worktree_service(&source_workspace_id)?;
9571 validate_workspace_request_root(&target_root)?;
9572 let source_root = self.workspace_root(&source_workspace_id)?;
9573 let target_root = normalize_worktree_removal_target(&target_root)
9574 .map_err(git_worktree_failure)?;
9575 let target = list_git_worktrees(&source_root)
9576 .await
9577 .map_err(git_worktree_failure)?
9578 .into_iter()
9579 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9580 .ok_or_else(|| {
9581 failure(
9582 NodeFailureCode::WorktreeProtected,
9583 "refusing to remove a path that is not in Git's worktree listing",
9584 )
9585 })?;
9586 let registered_workspace_id = {
9587 let workspaces = self
9588 .workspaces
9589 .read()
9590 .unwrap_or_else(|poisoned| poisoned.into_inner());
9591 let matched = workspaces
9592 .iter()
9593 .find(|(_, root)| worktree_paths_equal(root, &target.path))
9594 .map(|(workspace_id, _)| workspace_id.clone());
9595 if matched.is_some() && workspaces.len() == 1 {
9596 return Err(failure(
9597 NodeFailureCode::LastWorkspace,
9598 "node must retain at least one workspace",
9599 ));
9600 }
9601 matched
9602 };
9603 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9604 let bindings = self
9605 .session_bindings
9606 .lock()
9607 .unwrap_or_else(|poisoned| poisoned.into_inner());
9608 if bindings
9609 .values()
9610 .any(|binding| &binding.workspace_id == workspace_id)
9611 {
9612 return Err(failure(
9613 NodeFailureCode::WorkspaceBusy,
9614 "worktree workspace retains a bound session; remove every session first",
9615 ));
9616 }
9617 }
9618 remove_git_worktree(&source_root, &target.path)
9619 .await
9620 .map_err(git_worktree_failure)?;
9621 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9622 self.unregister_workspace(workspace_id)
9623 .map_err(|error| failure(error.code, &error.message))?;
9624 }
9625 Ok(registered_workspace_id)
9626 }
9627
9628 async fn cleanup_managed_worktree(
9629 &self,
9630 lease_id: &ManagedWorktreeLeaseId,
9631 explicit: bool,
9632 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9633 let lease = self.managed_worktrees.lock()
9634 .unwrap_or_else(|poisoned| poisoned.into_inner())
9635 .get(lease_id)
9636 .cloned()
9637 .ok_or_else(|| failure(
9638 NodeFailureCode::UnknownManagedWorktreeLease,
9639 "managed worktree lease does not exist",
9640 ))?;
9641 if lease.state == ManagedWorktreeLeaseState::Removed {
9642 return Ok(lease.snapshot());
9643 }
9644 if lease.has_holders() {
9645 return Err(failure(
9646 NodeFailureCode::ManagedWorktreeBusy,
9647 "managed worktree lease still has a session or durable-record holder",
9648 ));
9649 }
9650 if self
9651 .materializations
9652 .lock()
9653 .unwrap_or_else(|poisoned| poisoned.into_inner())
9654 .values()
9655 .any(|record| record.managed_lease_id() == Some(lease_id))
9656 {
9657 return Err(failure(
9658 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9659 "managed worktree cleanup is blocked by session environment ownership",
9660 ));
9661 }
9662 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9663 let snapshot = {
9664 let mut registry = self.managed_worktrees.lock()
9665 .unwrap_or_else(|poisoned| poisoned.into_inner());
9666 let current = registry.get_mut(lease_id)
9667 .expect("managed worktree lease remains present");
9668 current.state = ManagedWorktreeLeaseState::Retained;
9669 current.cleanup_failure = None;
9670 current.updated_at_unix_ms = unix_time_ms();
9671 current.snapshot()
9672 };
9673 if self.persist_state().is_err() {
9674 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9675 }
9676 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9677 return Ok(snapshot);
9678 }
9679 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9680 if profile.revision() != &lease.profile_revision {
9681 self.mark_managed_recovery_required(
9682 lease_id,
9683 ManagedWorktreeCleanupFailure::OwnershipConflict,
9684 );
9685 return Err(failure(
9686 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9687 "managed worktree profile revision changed",
9688 ));
9689 }
9690 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9691 self.mark_managed_recovery_required(
9692 lease_id,
9693 ManagedWorktreeCleanupFailure::OwnershipConflict,
9694 );
9695 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9696 })?;
9697 let source_root = self.workspace_root(&lease.source_workspace_id)?;
9698 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9699 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9700 managed_git_worktree_failure(error)
9701 })?;
9702 let target = listed.iter()
9703 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9704 if let Some(target) = target {
9705 if target.is_main || target.is_bare || !exact_owned_worktree(
9706 &lease,
9707 &target.path,
9708 target.branch.as_deref(),
9709 &target.head,
9710 ) {
9711 self.mark_managed_recovery_required(
9712 lease_id,
9713 ManagedWorktreeCleanupFailure::OwnershipConflict,
9714 );
9715 return Err(failure(
9716 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9717 "Git worktree identity no longer matches the managed lease",
9718 ));
9719 }
9720 if target.prunable {
9721 self.mark_managed_cleanup_blocked(
9722 lease_id,
9723 ManagedWorktreeCleanupFailure::Prunable,
9724 );
9725 return Err(failure(
9726 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9727 "managed worktree is prunable and requires recovery",
9728 ));
9729 }
9730 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9731 let failure_kind = match error.kind {
9732 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9733 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9734 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9735 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9736 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9737 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9738 };
9739 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9740 self.mark_managed_recovery_required(lease_id, failure_kind);
9741 } else {
9742 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9743 }
9744 return Err(managed_git_worktree_failure(error));
9745 }
9746 } else {
9747 match std::fs::symlink_metadata(&lease.target_root) {
9748 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9749 Ok(_) => {
9750 self.mark_managed_recovery_required(
9751 lease_id,
9752 ManagedWorktreeCleanupFailure::OwnershipConflict,
9753 );
9754 return Err(failure(
9755 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9756 "managed target exists but is not owned by Git",
9757 ));
9758 }
9759 Err(_) => {
9760 self.mark_managed_cleanup_blocked(
9761 lease_id,
9762 ManagedWorktreeCleanupFailure::Backend,
9763 );
9764 return Err(failure(
9765 NodeFailureCode::BackendOperationFailed,
9766 "managed target identity could not be inspected",
9767 ));
9768 }
9769 }
9770 }
9771 let registered = self.workspaces.read()
9772 .unwrap_or_else(|poisoned| poisoned.into_inner())
9773 .contains_key(&lease.workspace_id);
9774 if registered {
9775 self.unregister_workspace(&lease.workspace_id)?;
9776 }
9777 let removed = {
9778 self.managed_worktrees.lock()
9779 .unwrap_or_else(|poisoned| poisoned.into_inner())
9780 .tombstone(lease_id, unix_time_ms())
9781 .expect("managed worktree lease remains present during cleanup")
9782 };
9783 self.persist_state().map_err(persistence_failure)?;
9784 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9785 Ok(removed.snapshot())
9786 }
9787
9788 fn mark_managed_cleanup_blocked(
9789 &self,
9790 lease_id: &ManagedWorktreeLeaseId,
9791 failure_kind: ManagedWorktreeCleanupFailure,
9792 ) {
9793 let snapshot = {
9794 let mut registry = self.managed_worktrees.lock()
9795 .unwrap_or_else(|poisoned| poisoned.into_inner());
9796 let Some(lease) = registry.get_mut(lease_id) else { return };
9797 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9798 lease.cleanup_failure = Some(failure_kind);
9799 lease.updated_at_unix_ms = unix_time_ms();
9800 lease.snapshot()
9801 };
9802 let _ = self.persist_state();
9803 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9804 }
9805
9806 fn require_worktree_service(
9807 &self,
9808 source_workspace_id: &WorkspaceId,
9809 ) -> Result<(), NodeFailure> {
9810 if self.managed_worktrees.lock()
9811 .unwrap_or_else(|poisoned| poisoned.into_inner())
9812 .lease_for_workspace(source_workspace_id)
9813 .is_some()
9814 {
9815 return Err(failure(
9816 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9817 "managed worktree targets are internal-only workspaces",
9818 ));
9819 }
9820 match self
9821 .worktree_service_modes
9822 .get(source_workspace_id)
9823 .copied()
9824 .unwrap_or(WorktreeServiceMode::Manual)
9825 {
9826 WorktreeServiceMode::Manual => Ok(()),
9827 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
9828 NodeFailureCode::UnsupportedSpawnCapability,
9829 "Git worktree service mode is unavailable in this node build",
9830 )),
9831 }
9832 }
9833
9834 fn managed_reservation_conflict(
9835 &self,
9836 workspace_id: Option<&WorkspaceId>,
9837 root: Option<&str>,
9838 ) -> bool {
9839 self.managed_worktrees.lock()
9840 .unwrap_or_else(|poisoned| poisoned.into_inner())
9841 .active_records()
9842 .any(|lease| {
9843 workspace_id.is_some_and(|workspace_id| {
9844 workspace_id == &lease.source_workspace_id
9845 || workspace_id == &lease.workspace_id
9846 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
9847 })
9848 }
9849
9850 fn reject_managed_reservation(
9851 &self,
9852 workspace_id: Option<&WorkspaceId>,
9853 root: Option<&str>,
9854 ) -> Result<(), NodeFailure> {
9855 if self.managed_reservation_conflict(workspace_id, root) {
9856 Err(failure(
9857 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9858 "managed worktree ownership may only be mutated through its lease cleanup",
9859 ))
9860 } else {
9861 Ok(())
9862 }
9863 }
9864
9865 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
9866 self.validate_address(address)?;
9867 self.workspace_root(&address.workspace_id)
9868 }
9869
9870 fn require_session_runtime_policy(
9904 &self,
9905 address: &SessionAddress,
9906 requirement: ProviderRuntimeRequirement,
9907 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
9908 let runtime_policy = {
9909 let bindings = self
9910 .session_bindings
9911 .lock()
9912 .unwrap_or_else(|poisoned| poisoned.into_inner());
9913 let binding = bindings
9914 .get(&address.session.instance_id)
9915 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
9916 if binding.workspace_id != address.workspace_id {
9917 return Err(failure(
9918 NodeFailureCode::SessionWorkspaceMismatch,
9919 "session belongs to another workspace",
9920 ));
9921 }
9922 if binding.generation != address.session.generation {
9923 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
9924 }
9925 binding.runtime_policy
9926 };
9927 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
9928 let snapshot = self.internal_session_snapshot(address)?;
9929 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
9930 && snapshot.provider.lead_activity != ProviderActivity::Idle
9931 {
9932 return Err(failure(
9933 NodeFailureCode::TurnInFlight,
9934 "session already has a turn in flight; wait for it to complete before sending another prompt",
9935 ));
9936 }
9937 }
9938 require_policy(runtime_policy, false, false, requirement)
9939 .map(|()| runtime_policy)
9940 .map_err(|_| failure(
9941 NodeFailureCode::UnsupportedCapability,
9942 "session runtime policy does not admit this semantic operation",
9943 ))
9944 }
9945
9946 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
9947 let mut bindings = self
9948 .session_bindings
9949 .lock()
9950 .unwrap_or_else(|poisoned| poisoned.into_inner());
9951 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
9952 binding.workspace_id == address.workspace_id
9953 && binding.generation == address.session.generation
9954 }) {
9955 let removed = bindings.remove(&address.session.instance_id);
9956 self.clear_terminal_frame_watermark(address);
9957 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
9958 return removed;
9959 }
9960 None
9961 }
9962
9963 fn cleanup_session_owned_materialization(
9964 &self,
9965 address: &SessionAddress,
9966 binding: &SessionBinding,
9967 ) -> Result<(), NodeFailure> {
9968 let Some(id) = binding.materialization_id.as_ref() else {
9969 return Ok(());
9970 };
9971 let owner = self
9972 .materializations
9973 .lock()
9974 .unwrap_or_else(|poisoned| poisoned.into_inner())
9975 .get(id)
9976 .map(|record| record.owner().clone());
9977 match owner {
9978 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
9979 Some(MaterializationOwner::Session {
9980 incarnation_id,
9981 instance_id,
9982 generation,
9983 }) if incarnation_id == self.incarnation_id
9984 && instance_id == address.session.instance_id
9985 && generation == address.session.generation =>
9986 {
9987 self.cleanup_materialization(id)
9988 }
9989 Some(MaterializationOwner::Session { .. }) => Err(failure(
9990 NodeFailureCode::BackendOperationFailed,
9991 "session environment ownership requires recovery",
9992 )),
9993 }
9994 }
9995
9996 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
9997 let mut watermarks = self
9998 .terminal_frame_watermarks
9999 .lock()
10000 .unwrap_or_else(|poisoned| poisoned.into_inner());
10001 if watermarks
10002 .get(&address.session.instance_id)
10003 .is_some_and(|(current, _)| current == address)
10004 {
10005 watermarks.remove(&address.session.instance_id);
10006 }
10007 }
10008
10009 #[cfg(test)]
10010 fn address_for(
10011 &self,
10012 instance_id: AgentInstanceId,
10013 generation: SessionGeneration,
10014 ) -> Option<SessionAddress> {
10015 let binding = self.session_bindings
10016 .lock()
10017 .unwrap_or_else(|poisoned| poisoned.into_inner())
10018 .get(&instance_id)
10019 .cloned()?;
10020 (binding.generation == generation).then_some(SessionAddress {
10021 workspace_id: binding.workspace_id,
10022 session: SessionKey { instance_id, generation },
10023 })
10024 }
10025
10026 fn arm_resume(
10027 &self,
10028 address: &SessionAddress,
10029 command_id: CommandId,
10030 runtime_policy: ProviderRuntimePolicy,
10031 ) -> Result<(), NodeFailure> {
10032 let mut bindings = self
10033 .session_bindings
10034 .lock()
10035 .unwrap_or_else(|poisoned| poisoned.into_inner());
10036 let binding = bindings
10037 .get_mut(&address.session.instance_id)
10038 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10039 if binding.workspace_id != address.workspace_id {
10040 return Err(failure(
10041 NodeFailureCode::SessionWorkspaceMismatch,
10042 "session belongs to another workspace",
10043 ));
10044 }
10045 if binding.generation != address.session.generation {
10046 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10047 }
10048 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10049 Ok(())
10050 }
10051
10052 fn clear_armed_resume(&self, address: &SessionAddress) {
10053 let mut bindings = self
10054 .session_bindings
10055 .lock()
10056 .unwrap_or_else(|poisoned| poisoned.into_inner());
10057 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10058 if binding.workspace_id == address.workspace_id
10059 && binding.generation == address.session.generation
10060 {
10061 binding.pending_resume = None;
10062 }
10063 }
10064 }
10065
10066 fn publish_control(&self, event: ControlEvent) {
10067 let revokes_harness_mcp = matches!(
10068 &event.event,
10069 ControlEventKind::Exited { .. }
10070 | ControlEventKind::Failed { .. }
10071 | ControlEventKind::Removed
10072 );
10073 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10074 let mut bindings = self
10075 .session_bindings
10076 .lock()
10077 .unwrap_or_else(|poisoned| poisoned.into_inner());
10078 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10079 return;
10080 };
10081 let previous_address = SessionAddress {
10082 workspace_id: binding.workspace_id.clone(),
10083 session: SessionKey {
10084 instance_id: event.instance_id,
10085 generation: binding.generation,
10086 },
10087 };
10088 let mut resume_command_rejected = false;
10089 if binding.generation == event.generation {
10090 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10091 let rejected_resume = matches!(
10092 &event.event,
10093 ControlEventKind::CommandRejected { .. }
10094 ) && event.command_id == Some(pending_command_id);
10095 let resume_failed = matches!(
10096 &event.event,
10097 ControlEventKind::ResumeDenied { .. }
10098 | ControlEventKind::ResumeFailed { .. }
10099 );
10100 if pending_generation == binding.generation
10101 && (rejected_resume || resume_failed)
10102 {
10103 resume_command_rejected = rejected_resume;
10104 binding.pending_resume = None;
10105 }
10106 }
10107 } else {
10108 let expected = binding.generation.0.checked_add(1);
10109 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10110 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10111 generation == binding.generation
10112 })
10113 || expected != Some(event.generation.0)
10114 || !authorized
10115 {
10116 return;
10117 }
10118 let (_, _, runtime_policy) = binding
10119 .pending_resume
10120 .expect("authorized resume retains its pending runtime policy");
10121 binding.generation = event.generation;
10122 binding.runtime_policy = runtime_policy;
10123 binding.pending_resume = None;
10124 }
10125 (
10126 SessionAddress {
10127 workspace_id: binding.workspace_id.clone(),
10128 session: SessionKey {
10129 instance_id: event.instance_id,
10130 generation: event.generation,
10131 },
10132 },
10133 (previous_address.session.generation != event.generation)
10134 .then_some(previous_address),
10135 binding.record_id.clone(),
10136 binding.runtime_policy.provider_session_identity,
10137 resume_command_rejected,
10138 )
10139 };
10140 if let Some(previous_address) = previous_address.as_ref() {
10141 self.clear_terminal_frame_watermark(previous_address);
10142 }
10143 if let Some(record_id) = record_id {
10144 self.reconcile_managed_record(
10145 &record_id,
10146 &address,
10147 &event,
10148 identity_admitted,
10149 resume_command_rejected,
10150 );
10151 }
10152 match &event.event {
10160 ControlEventKind::Running { process_id } => {
10161 tracing::info!(
10162 node_id = %self.node_id,
10163 workspace_id = %address.workspace_id,
10164 session = ?address.session,
10165 pid = ?process_id,
10166 "session process is running",
10167 );
10168 }
10169 ControlEventKind::Exited { exit_code, forced } => {
10170 tracing::info!(
10171 node_id = %self.node_id,
10172 workspace_id = %address.workspace_id,
10173 session = ?address.session,
10174 exit_code = ?exit_code,
10175 forced = forced,
10176 "session process exited",
10177 );
10178 }
10179 ControlEventKind::Failed { message } => {
10180 tracing::warn!(
10181 node_id = %self.node_id,
10182 workspace_id = %address.workspace_id,
10183 session = ?address.session,
10184 cause = %message,
10185 "session failed",
10186 );
10187 }
10188 ControlEventKind::CommandRejected { message } => {
10189 tracing::warn!(
10190 node_id = %self.node_id,
10191 workspace_id = %address.workspace_id,
10192 session = ?address.session,
10193 cause = %message,
10194 "session command rejected",
10195 );
10196 }
10197 ControlEventKind::Removed => {
10198 tracing::info!(
10199 node_id = %self.node_id,
10200 workspace_id = %address.workspace_id,
10201 session = ?address.session,
10202 "session removed",
10203 );
10204 }
10205 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10206 tracing::info!(
10207 node_id = %self.node_id,
10208 workspace_id = %address.workspace_id,
10209 session = ?address.session,
10210 provider_session = ?provider_session,
10211 "session resume authorized",
10212 );
10213 }
10214 ControlEventKind::Resumed { session: provider_session, process_id } => {
10215 tracing::info!(
10216 node_id = %self.node_id,
10217 workspace_id = %address.workspace_id,
10218 session = ?address.session,
10219 provider_session = ?provider_session,
10220 pid = ?process_id,
10221 "session resumed",
10222 );
10223 }
10224 ControlEventKind::ResumeDenied { reason } => {
10225 tracing::warn!(
10226 node_id = %self.node_id,
10227 workspace_id = %address.workspace_id,
10228 session = ?address.session,
10229 cause = %reason,
10230 "session resume denied",
10231 );
10232 }
10233 ControlEventKind::ResumeFailed { message } => {
10234 tracing::warn!(
10235 node_id = %self.node_id,
10236 workspace_id = %address.workspace_id,
10237 session = ?address.session,
10238 cause = %message,
10239 "session resume failed",
10240 );
10241 }
10242 _ => {}
10243 }
10244 let stream_chunk = agent_stream_chunk(&event);
10245 self.publish(NodeEvent::Control {
10246 address: address.clone(),
10247 event,
10248 });
10249 if let Some(chunk) = stream_chunk {
10250 self.publish_agent_stream_chunk(address.clone(), chunk);
10251 }
10252 if revokes_harness_mcp {
10253 SessionLifecycleHook::on_session_ended(self, &address);
10254 }
10255 }
10256
10257 fn reconcile_managed_record(
10258 &self,
10259 record_id: &SessionRecordId,
10260 address: &SessionAddress,
10261 event: &ControlEvent,
10262 identity_admitted: bool,
10263 resume_command_rejected: bool,
10264 ) {
10265 let _transaction = self
10266 .state_transaction
10267 .lock()
10268 .unwrap_or_else(|poisoned| poisoned.into_inner());
10269 let observed_identity = match &event.event {
10270 ControlEventKind::ProviderEvent {
10271 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10272 ..
10273 } if identity_admitted => Some(identity.clone()),
10274 _ => None,
10275 };
10276 let identity_was_observed = observed_identity.is_some();
10277 let replacement_id = observed_identity
10278 .as_ref()
10279 .and_then(|_| self.allocate_record_id().ok());
10280 let mut upserts = Vec::new();
10281 let mut removals = Vec::new();
10282 let mut rebind = None;
10283 let original_records;
10284 let original_materializations = self
10285 .materializations
10286 .lock()
10287 .unwrap_or_else(|poisoned| poisoned.into_inner())
10288 .clone();
10289 {
10290 let mut records = self
10291 .session_records
10292 .lock()
10293 .unwrap_or_else(|poisoned| poisoned.into_inner());
10294 original_records = records.records.clone();
10295 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10296 return;
10297 };
10298 if let Some(identity) = observed_identity {
10299 let existing_id = records
10300 .records
10301 .values()
10302 .find(|record| {
10303 record.record_id != *record_id
10304 && record.provider_session.as_ref().is_some_and(|record_identity| {
10305 session_registry::same_provider_session(
10306 &record.provider,
10307 record_identity,
10308 ¤t_snapshot.provider,
10309 &identity,
10310 )
10311 })
10312 })
10313 .map(|record| record.record_id.clone());
10314 if let Some(existing_id) = existing_id {
10315 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10316 record.mode == current_snapshot.mode
10317 && record.workspace_id == current_snapshot.workspace_id
10318 && record.environment_profile == current_snapshot.environment_profile
10319 && record.bundle == current_snapshot.bundle
10320 && record.context_id == current_snapshot.context_id
10321 && record.context == current_snapshot.context
10322 && record
10323 .canonical_root
10324 .as_utf8()
10325 .zip(current_snapshot.canonical_root.as_utf8())
10326 .is_some_and(|(left, right)| platform::roots_equal(left, right))
10327 });
10328 if !scope_matches {
10329 if let Some(current) = records.records.get_mut(record_id) {
10330 current.state = ManagedSessionState::Unavailable;
10331 current.last_error =
10332 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10333 current.updated_at_unix_ms = unix_time_ms();
10334 upserts.push(current.clone());
10335 }
10336 } else {
10337 let existing_is_active = records
10338 .records
10339 .get(&existing_id)
10340 .is_some_and(|record| record.active_session.is_some());
10341 if existing_is_active {
10342 if let Some(current) = records.records.get_mut(record_id) {
10343 current.state = ManagedSessionState::Unavailable;
10344 current.last_error =
10345 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10346 current.updated_at_unix_ms = unix_time_ms();
10347 upserts.push(current.clone());
10348 }
10349 } else {
10350 if current_snapshot.provider_session.is_some() {
10351 if let Some(current) = records.records.get_mut(record_id) {
10352 current.active_session = None;
10353 current.state = ManagedSessionState::Dormant;
10354 current.updated_at_unix_ms = unix_time_ms();
10355 upserts.push(current.clone());
10356 }
10357 } else {
10358 records.records.remove(record_id);
10359 removals.push(record_id.clone());
10360 }
10361 if let Some(existing) = records.records.get_mut(&existing_id) {
10362 existing.provider_session = Some(identity.clone());
10363 existing.active_session = Some(address.clone());
10364 existing.state = ManagedSessionState::Live;
10365 existing.updated_at_unix_ms = unix_time_ms();
10366 existing.last_error = None;
10367 upserts.push(existing.clone());
10368 }
10369 rebind = Some(existing_id);
10370 }
10371 }
10372 } else if current_snapshot
10373 .provider_session
10374 .as_ref()
10375 .is_some_and(|current| {
10376 !session_registry::same_provider_session(
10377 ¤t_snapshot.provider,
10378 current,
10379 ¤t_snapshot.provider,
10380 &identity,
10381 )
10382 })
10383 {
10384 if let Some(current) = records.records.get_mut(record_id) {
10385 current.active_session = None;
10386 if current.bundle.is_some() || current.context.is_some() {
10387 let had_bundle = current.bundle.is_some();
10388 current.provider_session = None;
10389 current.bundle = None;
10390 current.context_id = None;
10391 current.context = None;
10392 current.state = ManagedSessionState::Unavailable;
10393 current.last_error = Some(if had_bundle {
10394 "bundle-unavailable".to_owned()
10395 } else {
10396 "context-unavailable".to_owned()
10397 });
10398 } else {
10399 current.state = ManagedSessionState::Dormant;
10400 }
10401 current.updated_at_unix_ms = unix_time_ms();
10402 upserts.push(current.clone());
10403 }
10404 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10405 if let Some(current) = records.records.get_mut(record_id) {
10406 current.state = ManagedSessionState::Unavailable;
10407 current.last_error =
10408 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10409 current.updated_at_unix_ms = unix_time_ms();
10410 upserts.push(current.clone());
10411 }
10412 } else if let Some(new_record_id) = replacement_id {
10413 let now = unix_time_ms();
10414 let replacement = ManagedSessionRecord {
10415 record_id: new_record_id.clone(),
10416 display_name: format!(
10417 "{} #{}",
10418 current_snapshot.provider.as_str(),
10419 records
10420 .records
10421 .values()
10422 .filter(|record| {
10423 record.provider == current_snapshot.provider
10424 })
10425 .count()
10426 .saturating_add(1),
10427 ),
10428 provider: current_snapshot.provider.clone(),
10429 mode: current_snapshot.mode,
10430 state: ManagedSessionState::Live,
10431 workspace_id: current_snapshot.workspace_id,
10432 canonical_root: current_snapshot.canonical_root,
10433 provider_session: Some(identity),
10434 active_session: Some(address.clone()),
10435 environment_profile: current_snapshot.environment_profile,
10436 bundle: current_snapshot.bundle,
10437 context_id: current_snapshot.context_id,
10438 context: current_snapshot.context,
10439 exported_context: None,
10445 task_binding: current_snapshot.task_binding,
10446 created_at_unix_ms: now,
10447 updated_at_unix_ms: now,
10448 last_error: None,
10449 };
10450 records
10451 .records
10452 .insert(new_record_id.clone(), replacement.clone());
10453 upserts.push(replacement);
10454 rebind = Some(new_record_id);
10455 } else if let Some(current) = records.records.get_mut(record_id) {
10456 current.state = ManagedSessionState::Unavailable;
10457 current.last_error =
10458 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10459 upserts.push(current.clone());
10460 }
10461 } else if let Some(current) = records.records.get_mut(record_id) {
10462 current.provider_session = Some(identity);
10463 current.active_session = Some(address.clone());
10464 current.state = ManagedSessionState::Live;
10465 current.updated_at_unix_ms = unix_time_ms();
10466 current.last_error = None;
10467 upserts.push(current.clone());
10468 }
10469 } else if let Some(current) = records.records.get_mut(record_id) {
10470 if resume_command_rejected {
10471 current.active_session = None;
10472 current.state = detached_record_state(current);
10473 current.updated_at_unix_ms = unix_time_ms();
10474 current.last_error =
10475 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10476 upserts.push(current.clone());
10477 } else {
10478 match &event.event {
10479 ControlEventKind::Running { .. }
10480 | ControlEventKind::ResumeAuthorized { .. }
10481 | ControlEventKind::Resumed { .. } => {
10482 current.active_session = Some(address.clone());
10483 current.state = active_record_state(
10484 identity_admitted,
10485 current.provider_session.is_some(),
10486 );
10487 current.updated_at_unix_ms = unix_time_ms();
10488 current.last_error = None;
10489 upserts.push(current.clone());
10490 }
10491 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10492 current.active_session = None;
10493 current.state = detached_record_state(current);
10494 current.updated_at_unix_ms = unix_time_ms();
10495 upserts.push(current.clone());
10496 }
10497 ControlEventKind::Failed { message }
10498 | ControlEventKind::ResumeFailed { message } => {
10499 current.active_session = None;
10500 current.state = detached_record_state(current);
10501 current.updated_at_unix_ms = unix_time_ms();
10502 current.last_error = Some(
10503 session_registry::sanitized_record_error_summary(message),
10504 );
10505 upserts.push(current.clone());
10506 }
10507 ControlEventKind::ResumeDenied { reason } => {
10508 current.active_session = None;
10509 current.state = detached_record_state(current);
10510 current.updated_at_unix_ms = unix_time_ms();
10511 current.last_error = Some(
10512 session_registry::sanitized_record_error_summary(reason),
10513 );
10514 upserts.push(current.clone());
10515 }
10516 _ => {}
10517 }
10518 }
10519 }
10520 }
10521 let materialization_owner_record = identity_was_observed.then(|| {
10522 rebind.clone().unwrap_or_else(|| record_id.clone())
10523 }).and_then(|candidate| {
10524 self.session_records
10525 .lock()
10526 .unwrap_or_else(|poisoned| poisoned.into_inner())
10527 .records
10528 .get(&candidate)
10529 .filter(|record| {
10530 record.provider_session.is_some()
10531 && record.active_session.as_ref() == Some(address)
10532 })
10533 .map(|_| candidate)
10534 });
10535 if let Some(owner_record_id) = materialization_owner_record {
10536 let materialization_id = self
10537 .session_bindings
10538 .lock()
10539 .unwrap_or_else(|poisoned| poisoned.into_inner())
10540 .get(&address.session.instance_id)
10541 .and_then(|binding| binding.materialization_id.clone());
10542 if let Some(materialization_id) = materialization_id {
10543 let mut transfer_failed = false;
10544 {
10545 let mut materializations = self
10546 .materializations
10547 .lock()
10548 .unwrap_or_else(|poisoned| poisoned.into_inner());
10549 if let Some(ownership) = materializations.get_mut(&materialization_id) {
10550 match ownership.owner() {
10551 MaterializationOwner::Session {
10552 incarnation_id,
10553 instance_id,
10554 generation,
10555 } if *incarnation_id == self.incarnation_id
10556 && *instance_id == address.session.instance_id
10557 && *generation == address.session.generation =>
10558 {
10559 transfer_failed = ownership
10560 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10561 .is_err();
10562 }
10563 MaterializationOwner::Record { record_id }
10564 if record_id == &owner_record_id => {}
10565 MaterializationOwner::Record { record_id } => {
10566 let expected = record_id.clone();
10567 transfer_failed = ownership
10568 .transfer_record_owner(
10569 &expected,
10570 owner_record_id.clone(),
10571 unix_time_ms(),
10572 )
10573 .is_err();
10574 }
10575 _ => transfer_failed = true,
10576 }
10577 if transfer_failed {
10578 let _ = ownership.mark_recovery_required(unix_time_ms());
10579 }
10580 } else {
10581 transfer_failed = true;
10582 }
10583 }
10584 if transfer_failed {
10585 if let Some(record) = self
10586 .session_records
10587 .lock()
10588 .unwrap_or_else(|poisoned| poisoned.into_inner())
10589 .records
10590 .get_mut(&owner_record_id)
10591 {
10592 record.state = ManagedSessionState::Unavailable;
10593 record.last_error = Some("environment-profile-unavailable".to_owned());
10594 record.updated_at_unix_ms = unix_time_ms();
10595 upserts.push(record.clone());
10596 }
10597 }
10598 }
10599 }
10600 if let Some(new_record_id) = rebind.clone() {
10601 if let Some(binding) = self
10602 .session_bindings
10603 .lock()
10604 .unwrap_or_else(|poisoned| poisoned.into_inner())
10605 .get_mut(&address.session.instance_id)
10606 {
10607 binding.record_id = Some(new_record_id);
10608 }
10609 }
10610 if upserts.is_empty() && removals.is_empty() {
10611 return;
10612 }
10613 if self.persist_state_locked().is_err() {
10614 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10615 let mut records = self
10616 .session_records
10617 .lock()
10618 .unwrap_or_else(|poisoned| poisoned.into_inner());
10619 records.records = original_records;
10620 *self
10621 .materializations
10622 .lock()
10623 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10624 if let Some(current) = records.records.get_mut(record_id) {
10625 current.state = ManagedSessionState::Unavailable;
10626 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10627 current.updated_at_unix_ms = unix_time_ms();
10628 upserts = vec![current.clone()];
10629 } else {
10630 upserts.clear();
10631 }
10632 removals.clear();
10633 drop(records);
10634 if let Some(binding) = self
10635 .session_bindings
10636 .lock()
10637 .unwrap_or_else(|poisoned| poisoned.into_inner())
10638 .get_mut(&address.session.instance_id)
10639 {
10640 binding.record_id = Some(record_id.clone());
10641 }
10642 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10643 }
10644 for record in upserts {
10645 self.publish_record(record);
10646 }
10647 for record_id in removals {
10648 self.publish(NodeEvent::SessionRecordRemoved { record_id });
10649 }
10650 }
10651
10652 fn snapshot(&self) -> NodeSnapshot {
10653 let control = self.handle.snapshot();
10654 let bindings = self
10655 .session_bindings
10656 .lock()
10657 .unwrap_or_else(|poisoned| poisoned.into_inner())
10658 .clone();
10659 let workspace_roots = self
10660 .workspaces
10661 .read()
10662 .unwrap_or_else(|poisoned| poisoned.into_inner())
10663 .clone();
10664 let environment_profiles = self
10665 .environment_profiles
10666 .read()
10667 .unwrap_or_else(|poisoned| poisoned.into_inner());
10668 let mut workspaces = workspace_roots
10669 .iter()
10670 .map(|(workspace_id, canonical_root)| {
10671 (
10672 workspace_id.clone(),
10673 WorkspaceSnapshot {
10674 workspace_id: workspace_id.clone(),
10675 canonical_root: opaque_windows_path(canonical_root.clone()),
10676 sessions: Vec::new(),
10677 worktree_service_mode: Some(
10678 self.worktree_service_modes
10679 .get(workspace_id)
10680 .copied()
10681 .unwrap_or(WorktreeServiceMode::Manual),
10682 ),
10683 managed_worktree_profiles: Some(WorktreeProfileInventory {
10684 profiles: self
10685 .managed_worktree_profiles
10686 .get(workspace_id)
10687 .into_iter()
10688 .flat_map(BTreeMap::values)
10689 .map(|profile| ManagedWorktreeProfileSummary {
10690 id: profile.profile_id().clone(),
10691 revision: profile.revision().clone(),
10692 retention: profile.retention(),
10693 })
10694 .collect(),
10695 }),
10696 },
10697 )
10698 })
10699 .collect::<BTreeMap<_, _>>();
10700 let mut agent_progress = Vec::new();
10701 for session in &control.sessions {
10702 let Some(binding) = bindings.get(&session.instance_id) else {
10703 continue;
10704 };
10705 if binding.generation != session.generation {
10706 continue;
10707 }
10708 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10709 let address = SessionAddress {
10710 workspace_id: binding.workspace_id.clone(),
10711 session: SessionKey {
10712 instance_id: session.instance_id,
10713 generation: session.generation,
10714 },
10715 };
10716 if let Some(progress) =
10717 agent_progress_from_provider_snapshot(address, &session.provider)
10718 {
10719 agent_progress.push(progress);
10720 }
10721 workspace.sessions.push(session.clone());
10722 }
10723 }
10724 NodeSnapshot {
10725 node_id: self.node_id.clone(),
10726 enabled_providers: self.enabled_providers.clone(),
10727 provider_runtime_statuses: {
10728 let updates = self
10729 .provider_runtime_status_updates
10730 .read()
10731 .unwrap_or_else(|poisoned| poisoned.into_inner());
10732 ProviderRuntimeStatuses::new(
10733 self.provider_runtime_statuses
10734 .iter()
10735 .filter(|status| !updates.contains_key(status.provider()))
10736 .cloned()
10737 .chain(updates.values().cloned()),
10738 )
10739 .expect("refreshed provider runtime inventory remains bounded and unique")
10740 },
10741 workspaces: workspaces.into_values().collect(),
10742 session_records: self
10743 .session_records
10744 .lock()
10745 .unwrap_or_else(|poisoned| poisoned.into_inner())
10746 .records
10747 .values()
10748 .cloned()
10749 .collect(),
10750 managed_worktrees: self.managed_worktrees
10751 .lock()
10752 .unwrap_or_else(|poisoned| poisoned.into_inner())
10753 .snapshots(),
10754 launch_inventory: Some(LaunchInventory {
10755 spawn_profiles: Some(
10756 self.spawn_profiles
10757 .iter()
10758 .filter_map(|profile| {
10759 resolved_spawn_profile_summary(profile, &environment_profiles)
10760 })
10761 .collect(),
10762 ),
10763 bundles: Some(
10764 self.bundle_catalog
10765 .read()
10766 .unwrap_or_else(|poisoned| poisoned.into_inner())
10767 .iter()
10768 .map(NodeBundle::receipt)
10769 .collect(),
10770 ),
10771 network_allowlists: {
10774 let catalog = self
10775 .network_allowlist_catalog
10776 .read()
10777 .unwrap_or_else(|poisoned| poisoned.into_inner());
10778 if catalog.is_empty() {
10779 None
10780 } else {
10781 Some(catalog.ids().cloned().collect())
10782 }
10783 },
10784 }),
10785 agent_progress,
10786 }
10787 }
10788
10789 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
10790 assert!(
10791 !matches!(
10792 &event,
10793 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
10794 ),
10795 "terminal frame and agent stream events must use their own dedicated live channel",
10796 );
10797 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10798 let sequence = history
10799 .last_sequence
10800 .checked_add(1)
10801 .expect("node event sequence exhausted");
10802 if history.events.len() == NODE_EVENT_HISTORY_MAX {
10803 if let Some(evicted) = history.events.pop_front() {
10804 history.replay_floor_sequence = evicted
10805 .sequence
10806 .checked_add(1)
10807 .expect("node replay floor sequence exhausted");
10808 history.removed_record_providers.remove(&evicted.sequence);
10809 }
10810 }
10811 match &event {
10812 NodeEvent::HarnessMcpReadCall { .. } => {
10813 panic!("transient harness MCP calls must not enter durable event history")
10814 }
10815 NodeEvent::SessionRecordUpserted { record } => {
10816 history
10817 .record_providers
10818 .insert(record.record_id.clone(), record.provider.clone());
10819 }
10820 NodeEvent::SessionRecordRemoved { record_id } => {
10821 if let Some(provider) = history.record_providers.remove(record_id) {
10822 history.removed_record_providers.insert(sequence, provider);
10823 }
10824 }
10825 NodeEvent::ManagedWorktreeUpserted { .. }
10826 | NodeEvent::ManagedWorktreeRemoved { .. }
10827 | NodeEvent::Control { .. }
10828 | NodeEvent::ControllerChanged { .. }
10829 | NodeEvent::SessionRecordHistorySummarized { .. }
10830 | NodeEvent::WorkspaceAdded { .. }
10831 | NodeEvent::WorkspaceRemoved { .. }
10832 | NodeEvent::ResyncRequired { .. }
10833 | NodeEvent::AgentStream { .. }
10834 | NodeEvent::TerminalFrame { .. } => {}
10835 }
10836 let envelope = NodeEventEnvelope { sequence, event };
10837 history.events.push_back(envelope.clone());
10838 history.last_sequence = sequence;
10839 let _ = self.event_tx.send(envelope.clone());
10840 drop(history);
10841 envelope
10842 }
10843
10844 fn publish_transient(&self, event: NodeEvent) -> bool {
10855 assert!(event.requires_harness_mcp_proxy_capability());
10856 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
10857 let envelope = NodeEventEnvelope { sequence: 0, event };
10858 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
10859 Ok(_) => true,
10860 Err(broadcast::error::SendError(undelivered)) => {
10861 if let Some(NodeEventEnvelope {
10862 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
10863 ..
10864 }) = undelivered.first() {
10865 tracing::warn!(
10871 call_id = call_id.as_str(),
10872 content_type = ?request.content_type,
10873 cause = "no-subscribers",
10874 "harness MCP proxy event could not be published",
10875 );
10876 }
10877 false
10878 }
10879 }
10880 }
10881
10882 fn publish_terminal_frames(&self) {
10883 let snapshot = self.handle.snapshot();
10884 let bindings = self
10885 .session_bindings
10886 .lock()
10887 .unwrap_or_else(|poisoned| poisoned.into_inner());
10888 let candidates = snapshot
10889 .sessions
10890 .iter()
10891 .filter_map(|session| {
10892 let binding = bindings.get(&session.instance_id)?;
10893 if binding.generation != session.generation {
10894 return None;
10895 }
10896 let frame = session.terminal_frame.clone()?;
10897 Some((
10898 SessionAddress {
10899 workspace_id: binding.workspace_id.clone(),
10900 session: SessionKey {
10901 instance_id: session.instance_id,
10902 generation: session.generation,
10903 },
10904 },
10905 frame,
10906 ))
10907 })
10908 .collect();
10909 drop(bindings);
10910 self.publish_terminal_frame_candidates(candidates);
10911 }
10912
10913 fn publish_terminal_frame_candidates(
10914 &self,
10915 candidates: Vec<(SessionAddress, TerminalFrame)>,
10916 ) {
10917 let mut watermarks = self
10918 .terminal_frame_watermarks
10919 .lock()
10920 .unwrap_or_else(|poisoned| poisoned.into_inner());
10921 let mut events = Vec::with_capacity(candidates.len());
10922 for (address, frame) in candidates {
10923 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
10924 let advanced = watermarks
10925 .get(&address.session.instance_id)
10926 .map_or(true, |(current_address, sequence)| {
10927 current_address != &address || frame.sequence > *sequence
10928 });
10929 if !advanced {
10930 continue;
10931 }
10932 if is_first_frame {
10933 if self.controller_state().is_none() {
10941 tracing::warn!(
10942 node_id = %self.node_id,
10943 workspace_id = %address.workspace_id,
10944 session = ?address.session,
10945 terminal_size = ?frame.size,
10946 "session producing output with no controller attached",
10947 );
10948 } else {
10949 tracing::info!(
10950 node_id = %self.node_id,
10951 workspace_id = %address.workspace_id,
10952 session = ?address.session,
10953 terminal_size = ?frame.size,
10954 "session produced its first output",
10955 );
10956 }
10957 }
10958 watermarks.insert(
10959 address.session.instance_id,
10960 (address.clone(), frame.sequence),
10961 );
10962 events.push(NodeEvent::TerminalFrame { address, frame });
10963 }
10964 drop(watermarks);
10965 if events.is_empty() {
10966 return;
10967 }
10968 let mut history = self
10969 .history
10970 .lock()
10971 .unwrap_or_else(|poisoned| poisoned.into_inner());
10972 let mut envelopes = Vec::with_capacity(events.len());
10973 for event in events {
10974 let sequence = history
10975 .last_sequence
10976 .checked_add(1)
10977 .expect("node event sequence exhausted");
10978 history.last_sequence = sequence;
10979 envelopes.push(NodeEventEnvelope { sequence, event });
10980 }
10981 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
10982 drop(history);
10983 }
10984
10985 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
10994 let mut history = self
10995 .history
10996 .lock()
10997 .unwrap_or_else(|poisoned| poisoned.into_inner());
10998 let sequence = history
10999 .last_sequence
11000 .checked_add(1)
11001 .expect("node event sequence exhausted");
11002 history.last_sequence = sequence;
11003 drop(history);
11004 tracing::debug!(
11012 source_sequence = chunk.source_sequence,
11013 kind = agent_stream_chunk_kind_label(&chunk.kind),
11014 envelope_sequence = sequence,
11015 "publishing agent stream chunk",
11016 );
11017 let envelope = NodeEventEnvelope {
11018 sequence,
11019 event: NodeEvent::AgentStream { address, chunk },
11020 };
11021 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11022 }
11023
11024 fn current_sequence(&self) -> u64 {
11025 self.history
11026 .lock()
11027 .unwrap_or_else(|poisoned| poisoned.into_inner())
11028 .last_sequence
11029 }
11030
11031 fn controller_state(&self) -> Option<ControllerState> {
11032 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11033 let lease = controller.as_ref().copied()?;
11034 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11035 if remaining.is_zero() {
11036 *controller = None;
11037 return None;
11038 }
11039 Some(ControllerState {
11040 connection_id: lease.connection_id,
11041 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11042 })
11043 }
11044
11045 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11046 if role != ClientRole::Operator {
11047 tracing::warn!(
11048 node_id = %self.node_id,
11049 connection_id,
11050 "controller lease rejected: observer connections cannot acquire control",
11051 );
11052 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11053 }
11054 let lease_ms = if lease_ms == 0 {
11055 DEFAULT_CONTROLLER_LEASE_MS
11056 } else {
11057 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11058 };
11059 let now = Instant::now();
11060 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11061 if let Some(active) = controller.as_ref() {
11062 if active.connection_id != connection_id && active.expires_at > now {
11063 tracing::warn!(
11064 node_id = %self.node_id,
11065 connection_id,
11066 holder_connection_id = active.connection_id,
11067 "controller lease rejected: another operator holds it",
11068 );
11069 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11070 }
11071 }
11072 let is_new_attach = match controller.as_ref() {
11079 Some(active) => active.connection_id != connection_id,
11080 None => true,
11081 };
11082 *controller = Some(ControllerLease {
11083 connection_id,
11084 expires_at: now + Duration::from_millis(lease_ms),
11085 });
11086 drop(controller);
11087 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11088 if is_new_attach {
11089 tracing::info!(
11090 node_id = %self.node_id,
11091 connection_id,
11092 lease_ms,
11093 "controller lease acquired",
11094 );
11095 } else {
11096 tracing::debug!(
11097 node_id = %self.node_id,
11098 connection_id,
11099 lease_ms,
11100 "controller lease renewed",
11101 );
11102 }
11103 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11104 Ok(state)
11105 }
11106
11107 fn release_controller(&self, connection_id: u64) -> bool {
11108 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11109 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11110 if released {
11111 *controller = None;
11112 }
11113 drop(controller);
11114 if released {
11115 tracing::info!(
11116 node_id = %self.node_id,
11117 connection_id,
11118 "controller lease released",
11119 );
11120 self.publish(NodeEvent::ControllerChanged { controller: None });
11121 }
11122 released
11123 }
11124
11125 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11126 if role != ClientRole::Operator {
11127 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11128 }
11129 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11130 let now = Instant::now();
11131 match controller.as_ref().copied() {
11132 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11133 Some(active) if active.expires_at <= now => {
11134 *controller = None;
11135 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11136 }
11137 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11138 }
11139 }
11140
11141 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11142 if !self
11143 .workspaces
11144 .read()
11145 .unwrap_or_else(|poisoned| poisoned.into_inner())
11146 .contains_key(&address.workspace_id)
11147 {
11148 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11149 }
11150 let binding = self
11151 .session_bindings
11152 .lock()
11153 .unwrap_or_else(|poisoned| poisoned.into_inner())
11154 .get(&address.session.instance_id)
11155 .cloned()
11156 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11157 if binding.workspace_id != address.workspace_id {
11158 return Err(failure(
11159 NodeFailureCode::SessionWorkspaceMismatch,
11160 "session belongs to another workspace",
11161 ));
11162 }
11163 if binding.generation != address.session.generation {
11164 return Err(failure(
11165 NodeFailureCode::StaleGeneration,
11166 &format!(
11167 "stale session generation: expected {}, received {}",
11168 binding.generation.0,
11169 address.session.generation.0,
11170 ),
11171 ));
11172 }
11173 let snapshot = self.handle.snapshot();
11174 let Some(current) = snapshot
11175 .sessions
11176 .iter()
11177 .find(|item| item.instance_id == address.session.instance_id)
11178 else {
11179 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11180 };
11181 if current.generation != address.session.generation {
11182 return Err(failure(
11183 NodeFailureCode::StaleGeneration,
11184 &format!(
11185 "stale session generation: expected {}, received {}",
11186 current.generation.0,
11187 address.session.generation.0,
11188 ),
11189 ));
11190 }
11191 Ok(current.agent_id.clone())
11192 }
11193
11194 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11195 self.dispatch_envelope(self.prepare_command(command))
11196 }
11197
11198 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11199 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11200 CommandEnvelope {
11201 id: CommandId(id),
11202 command,
11203 }
11204 }
11205
11206 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11207 let command_id = envelope.id;
11208 self.handle
11209 .dispatch(envelope)
11210 .map_err(|error| match error {
11211 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11212 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11213 })?;
11214 Ok(command_id)
11215 }
11216
11217 fn record_rejected_command(
11226 &self,
11227 command_id: CommandId,
11228 message: String,
11229 unsupported_transport: bool,
11230 ) {
11231 let now = Instant::now();
11232 let mut reasons = self.rejected_command_reasons.lock()
11233 .unwrap_or_else(|poisoned| poisoned.into_inner());
11234 reasons.retain(|_, reason| reason.expires_at > now);
11235 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11236 if let Some(&oldest) = reasons.keys().next() {
11237 reasons.remove(&oldest);
11238 }
11239 }
11240 reasons.insert(
11241 command_id,
11242 RejectedCommandReason {
11243 message,
11244 unsupported_transport,
11245 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11246 },
11247 );
11248 }
11249
11250 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11257 let now = Instant::now();
11258 let mut reasons = self.rejected_command_reasons.lock()
11259 .unwrap_or_else(|poisoned| poisoned.into_inner());
11260 match reasons.remove(&command_id) {
11261 Some(reason) if reason.expires_at > now => Some(reason),
11262 _ => None,
11263 }
11264 }
11265
11266 async fn dispatch_bounded(
11267 &self,
11268 command: ControlCommand,
11269 timeout_duration: Duration,
11270 ) -> Result<CommandId, NodeFailure> {
11271 let deadline = Instant::now() + timeout_duration;
11272 loop {
11273 match self.dispatch(command.clone()) {
11274 Ok(command_id) => return Ok(command_id),
11275 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11276 sleep(Duration::from_millis(2)).await;
11277 }
11278 Err(error) => return Err(error),
11279 }
11280 }
11281 }
11282
11283 fn internal_session_snapshot(
11284 &self,
11285 address: &SessionAddress,
11286 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11287 self.validate_address(address)?;
11288 self.handle
11289 .snapshot()
11290 .sessions
11291 .clone()
11292 .into_iter()
11293 .find(|session| {
11294 session.instance_id == address.session.instance_id
11295 && session.generation == address.session.generation
11296 })
11297 .ok_or_else(|| {
11298 failure(
11299 NodeFailureCode::UnknownSession,
11300 "session snapshot is unavailable",
11301 )
11302 })
11303 }
11304
11305 fn require_acp_session(
11313 &self,
11314 address: &SessionAddress,
11315 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11316 let snapshot = self.internal_session_snapshot(address)?;
11317 if snapshot.transport != TransportKind::Acp {
11318 return Err(failure(
11319 NodeFailureCode::UnsupportedTransport,
11320 "this verb requires a session spawned over the ACP transport",
11321 ));
11322 }
11323 Ok(snapshot)
11324 }
11325
11326 async fn dispatch_history_bounded(
11327 &self,
11328 address: &SessionAddress,
11329 command: ControlCommand,
11330 operation: HistoryOperation,
11331 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11332 self.validate_address(address)?;
11333 let started_after = self.current_sequence();
11334 let command_id = self
11335 .dispatch_bounded(
11336 command,
11337 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11338 )
11339 .await?;
11340 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11341 let mut scan_after = started_after;
11342 let mut accepted_after = None;
11343 loop {
11344 let events = self
11345 .history
11346 .lock()
11347 .unwrap_or_else(|poisoned| poisoned.into_inner())
11348 .events
11349 .iter()
11350 .filter(|envelope| envelope.sequence > scan_after)
11351 .cloned()
11352 .collect::<Vec<_>>();
11353 for envelope in events {
11354 scan_after = scan_after.max(envelope.sequence);
11355 let NodeEvent::Control {
11356 address: event_address,
11357 event,
11358 } = envelope.event
11359 else {
11360 continue;
11361 };
11362 if event_address != *address {
11363 continue;
11364 }
11365 if event.command_id == Some(command_id) {
11366 match &event.event {
11367 ControlEventKind::CommandRejected { .. } => {
11368 return Err(failure(
11369 NodeFailureCode::InvalidRequest,
11370 "history request was rejected",
11371 ));
11372 }
11373 ControlEventKind::HistoryRequested {
11374 operation: requested,
11375 ..
11376 } if requested == &operation => {
11377 accepted_after = Some(envelope.sequence);
11378 continue;
11379 }
11380 _ => {}
11381 }
11382 }
11383 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11384 match (&operation, &event.event) {
11385 (
11386 HistoryOperation::Discover { .. },
11387 ControlEventKind::HistoryDiscovered { .. },
11388 )
11389 | (
11390 HistoryOperation::Load { .. },
11391 ControlEventKind::HistoryLoaded { .. },
11392 ) => return self.internal_session_snapshot(address),
11393 (_, ControlEventKind::HistoryFailed { .. }) => {
11394 return Err(failure(
11395 NodeFailureCode::BackendOperationFailed,
11396 "history operation failed",
11397 ));
11398 }
11399 _ => {}
11400 }
11401 }
11402 }
11403 if Instant::now() >= deadline {
11404 return Err(failure(
11405 NodeFailureCode::BackendBusy,
11406 if accepted_after.is_some() {
11407 "history operation did not settle before the bounded deadline"
11408 } else {
11409 "history operation was not accepted before the bounded deadline"
11410 },
11411 ));
11412 }
11413 sleep(Duration::from_millis(2)).await;
11414 }
11415 }
11416
11417 async fn discover_history(
11418 &self,
11419 address: &SessionAddress,
11420 limit: u16,
11421 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11422 let query = HistoryQuery {
11423 working_directory: Some(self.workspace_root(&address.workspace_id)?),
11424 limit,
11425 };
11426 query
11427 .validate()
11428 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11429 let snapshot = self
11430 .dispatch_history_bounded(
11431 address,
11432 ControlCommand::DiscoverHistory {
11433 instance_id: address.session.instance_id,
11434 query: query.clone(),
11435 },
11436 HistoryOperation::Discover { query },
11437 )
11438 .await?;
11439 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11440 return Err(failure(
11441 NodeFailureCode::BackendOperationFailed,
11442 "history discovery settled into an invalid state",
11443 ));
11444 }
11445 Ok(snapshot.history.candidates)
11446 }
11447
11448 async fn load_history(
11449 &self,
11450 address: &SessionAddress,
11451 candidate_id: String,
11452 ) -> Result<HistorySessionRecord, NodeFailure> {
11453 validate_candidate_id(&candidate_id)
11454 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11455 let snapshot = self
11456 .dispatch_history_bounded(
11457 address,
11458 ControlCommand::LoadHistory {
11459 instance_id: address.session.instance_id,
11460 candidate_id: candidate_id.clone(),
11461 },
11462 HistoryOperation::Load {
11463 candidate_id: candidate_id.clone(),
11464 },
11465 )
11466 .await?;
11467 if snapshot.history.pending.is_some()
11468 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11469 {
11470 return Err(failure(
11471 NodeFailureCode::BackendOperationFailed,
11472 "history load settled into an invalid state",
11473 ));
11474 }
11475 snapshot.history.loaded.ok_or_else(|| {
11476 failure(
11477 NodeFailureCode::BackendOperationFailed,
11478 "history load produced no bounded session",
11479 )
11480 })
11481 }
11482
11483 async fn export_context_pack_for_session_record(
11490 &self,
11491 record_id: &SessionRecordId,
11492 session: &SessionAddress,
11493 allow_clean_detachment: bool,
11494 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11495 let outcome = self
11501 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11502 .await;
11503 if let Err(failure) = &outcome {
11504 tracing::warn!(
11505 record = %record_id,
11506 code = ?failure.code,
11507 reason = %failure.message,
11508 "session-record context-pack export refused",
11509 );
11510 }
11511 outcome
11512 }
11513
11514 async fn export_context_pack_for_session_record_inner(
11515 &self,
11516 record_id: &SessionRecordId,
11517 session: &SessionAddress,
11518 allow_clean_detachment: bool,
11519 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11520 let step = |name: &'static str| move |failure: NodeFailure| {
11525 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11526 failure
11527 };
11528 let record = self.record(record_id).map_err(step("record-lookup"))?;
11529 let identity = match record.provider_session.clone() {
11545 Some(identity) => {
11546 if identity.key != ProviderSessionKey::SessionId {
11547 return Err(step("provider-identity-key")(failure(
11548 NodeFailureCode::BackendOperationFailed,
11549 "managed session provider identity cannot bind history exactly",
11550 )));
11551 }
11552 Some(identity)
11553 }
11554 None if record.state == ManagedSessionState::IdentityPending => {
11555 return Err(step("provider-session-identity")(failure(
11556 NodeFailureCode::BackendOperationFailed,
11557 "managed session has not yet observed its provider session identity",
11558 )));
11559 }
11560 None => None,
11561 };
11562 self.revalidate_session_record_context_export(
11563 &record,
11564 identity.as_ref(),
11565 session,
11566 allow_clean_detachment,
11567 ).map_err(step("revalidate-before-discovery"))?;
11568 let candidates = self
11569 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11570 .await
11571 .map_err(step("discover-history"))?;
11572 let candidate_id = select_history_candidate(
11577 &candidates,
11578 identity.as_ref().map(|identity| identity.id.as_str()),
11579 ).map_err(step("unique-candidate"))?;
11580 let loaded = self.load_history(session, candidate_id.clone()).await
11581 .map_err(step("load-history"))?;
11582 if let Some(identity) = identity.as_ref() {
11583 if loaded.session_id != identity.id {
11584 return Err(failure(
11585 NodeFailureCode::SessionRecordConflict,
11586 "loaded history identity does not match the managed session record",
11587 ));
11588 }
11589 }
11590 self.revalidate_session_record_context_export(
11591 &record,
11592 identity.as_ref(),
11593 session,
11594 allow_clean_detachment,
11595 )?;
11596 self.revalidate_loaded_history(session, &candidate_id, &loaded)
11597 .map_err(step("revalidate-loaded-history"))?;
11598 let pack = self.materialize_context_pack(session).await
11599 .map_err(step("materialize-context-pack"))?;
11600 self.commit_context_pack_for_session_record(
11601 &record,
11602 identity.as_ref(),
11603 session,
11604 &candidate_id,
11605 &loaded,
11606 pack,
11607 allow_clean_detachment,
11608 )
11609 }
11610
11611 async fn export_context_pack(
11612 &self,
11613 address: &SessionAddress,
11614 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11615 let pack = self.materialize_context_pack(address).await?;
11616 self.commit_context_pack(pack)
11617 }
11618
11619 async fn materialize_context_pack(
11620 &self,
11621 address: &SessionAddress,
11622 ) -> Result<NodeContextPack, NodeFailure> {
11623 let snapshot = self.internal_session_snapshot(address)?;
11624 if !context_pack_source_status_is_usable(&snapshot.status) {
11625 return Err(failure(
11626 NodeFailureCode::InvalidRequest,
11627 "context export requires a running or successfully exited source session",
11628 ));
11629 }
11630 if snapshot.history.pending.is_some()
11631 || snapshot.history.loaded_candidate_id.is_none()
11632 {
11633 return Err(failure(
11634 NodeFailureCode::InvalidRequest,
11635 "context export requires settled loaded history",
11636 ));
11637 }
11638 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11639 failure(
11640 NodeFailureCode::InvalidRequest,
11641 "context export requires settled loaded history",
11642 )
11643 })?;
11644 let source_provider = snapshot.agent_id.clone();
11645 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11646 let repository = self
11647 .context_pack_repository(&address.workspace_id)
11648 .await?;
11649 let current = self.internal_session_snapshot(address)?;
11650 if !context_pack_source_status_is_usable(¤t.status)
11651 || current.agent_id != source_provider
11652 || current.history.pending.is_some()
11653 || current.history.loaded_candidate_id != loaded_candidate_id
11654 || current.history.loaded.as_ref() != Some(&loaded)
11655 {
11656 return Err(failure(
11657 NodeFailureCode::ContextPackMaterializationFailed,
11658 "context source changed during bounded repository capture",
11659 ));
11660 }
11661 NodeContextPack::export_with_repository(
11662 ContextPackLineageReceipt {
11663 source_node_id: self.node_id.clone(),
11664 source_session: address.clone(),
11665 source_provider,
11666 },
11667 &loaded,
11668 Some(repository),
11669 )
11670 .map_err(|_| {
11671 failure(
11672 NodeFailureCode::ContextPackMaterializationFailed,
11673 "bounded context pack export failed",
11674 )
11675 })
11676 }
11677
11678 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
11684 let mut store = self
11685 .context_pack_store
11686 .lock()
11687 .unwrap_or_else(|poisoned| poisoned.into_inner());
11688 let Some(store) = store.as_mut() else {
11689 return Ok(());
11690 };
11691 store.commit(pack).map_err(|_| {
11692 failure(
11693 NodeFailureCode::ContextPackMaterializationFailed,
11694 "durable context pack commit failed",
11695 )
11696 })
11697 }
11698
11699 fn commit_context_pack(
11700 &self,
11701 pack: NodeContextPack,
11702 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11703 self.commit_context_pack_durably(&pack)?;
11704 self.context_catalog
11705 .write()
11706 .unwrap_or_else(|poisoned| poisoned.into_inner())
11707 .insert(pack)
11708 .map_err(|_| {
11709 failure(
11710 NodeFailureCode::ContextPackMaterializationFailed,
11711 "context pack catalog is full",
11712 )
11713 })
11714 }
11715
11716 fn commit_context_pack_for_session_record(
11717 &self,
11718 expected: &ManagedSessionRecord,
11719 identity: Option<&ProviderSessionIdentity>,
11720 session: &SessionAddress,
11721 candidate_id: &str,
11722 loaded: &HistorySessionRecord,
11723 pack: NodeContextPack,
11724 allow_clean_detachment: bool,
11725 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11726 let current_root = self.workspace_root(&expected.workspace_id)?;
11727 let _transaction = self
11728 .state_transaction
11729 .lock()
11730 .unwrap_or_else(|poisoned| poisoned.into_inner());
11731 let records = self
11732 .session_records
11733 .lock()
11734 .unwrap_or_else(|poisoned| poisoned.into_inner());
11735 let current = records.records.get(&expected.record_id).ok_or_else(|| {
11736 failure(
11737 NodeFailureCode::SessionRecordConflict,
11738 "managed session record disappeared during context export",
11739 )
11740 })?;
11741 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
11742 return Err(failure(
11743 NodeFailureCode::SessionRecordConflict,
11744 "managed session record changed during context export",
11745 ));
11746 }
11747 let bindings = self
11748 .session_bindings
11749 .lock()
11750 .unwrap_or_else(|poisoned| poisoned.into_inner());
11751 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
11752 failure(
11753 NodeFailureCode::SessionRecordConflict,
11754 "managed session runtime binding disappeared during context export",
11755 )
11756 })?;
11757 let runtime = self
11758 .handle
11759 .snapshot()
11760 .sessions
11761 .iter()
11762 .find(|snapshot| {
11763 snapshot.instance_id == session.session.instance_id
11764 && snapshot.generation == session.session.generation
11765 })
11766 .cloned()
11767 .ok_or_else(|| {
11768 failure(
11769 NodeFailureCode::SessionRecordConflict,
11770 "managed session runtime disappeared during context export",
11771 )
11772 })?;
11773 let receipt = pack.receipt();
11774 if !session_record_context_export_binding_is_exact(
11775 expected,
11776 current,
11777 identity,
11778 session,
11779 binding,
11780 &runtime.agent_id,
11781 ¤t_root,
11782 allow_clean_detachment,
11783 ) || !session_record_context_export_source_is_usable(
11784 current,
11785 &runtime.status,
11786 allow_clean_detachment,
11787 ) || runtime.history.pending.is_some()
11788 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
11789 || runtime.history.loaded.as_ref() != Some(loaded)
11790 || receipt.lineage.source_node_id != self.node_id
11791 || receipt.lineage.source_session != *session
11792 || receipt.lineage.source_provider != current.provider
11793 || receipt.source_message_count != loaded.message_count
11794 {
11795 return Err(failure(
11796 NodeFailureCode::SessionRecordConflict,
11797 "managed session source changed before context catalog commit",
11798 ));
11799 }
11800 self.commit_context_pack_durably(&pack)?;
11801 self.context_catalog
11802 .write()
11803 .unwrap_or_else(|poisoned| poisoned.into_inner())
11804 .insert(pack)
11805 .map_err(|_| {
11806 failure(
11807 NodeFailureCode::ContextPackMaterializationFailed,
11808 "context pack catalog is full",
11809 )
11810 })
11811 }
11812
11813 async fn context_pack_repository(
11814 &self,
11815 workspace_id: &WorkspaceId,
11816 ) -> Result<ContextPackRepository, NodeFailure> {
11817 let first = self
11818 .context_pack_repository_observation(workspace_id)
11819 .await?;
11820 let second = self
11821 .context_pack_repository_observation(workspace_id)
11822 .await?;
11823 stable_context_pack_repository(first, second)
11824 }
11825
11826 async fn context_pack_repository_observation(
11827 &self,
11828 workspace_id: &WorkspaceId,
11829 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
11830 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
11831 let head = if git.is_repository {
11832 let root = self.workspace_root(workspace_id)?;
11833 match resolve_base_commit_with_timeout(
11834 &root,
11835 "HEAD",
11836 GIT_COMMAND_TIMEOUT_MS,
11837 )
11838 .await
11839 {
11840 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
11841 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
11842 ContextPackRepositoryHead::Unborn
11843 }
11844 Err(_) => {
11845 return Err(failure(
11846 NodeFailureCode::ContextPackMaterializationFailed,
11847 "context pack HEAD observation failed",
11848 ));
11849 }
11850 }
11851 } else {
11852 ContextPackRepositoryHead::NotRepository
11853 };
11854 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
11855 for selected_path in CONTEXT_PACK_SELECTED_FILES {
11856 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
11857 failure(
11858 NodeFailureCode::InvalidRepositoryPath,
11859 "context pack selected file path is invalid",
11860 )
11861 })?;
11862 match self.read_workspace_file(workspace_id.clone(), path).await {
11863 Ok(WorkspaceFileRead {
11864 content: WorkspaceFileContent::Utf8 { text, byte_len },
11865 ..
11866 }) => files.push(ContextPackRepositoryFileSource::utf8(
11867 selected_path,
11868 text,
11869 byte_len,
11870 )),
11871 Ok(WorkspaceFileRead {
11872 content: WorkspaceFileContent::NonUtf8 { byte_len },
11873 ..
11874 }) => files.push(ContextPackRepositoryFileSource::skipped(
11875 selected_path,
11876 ContextPackSelectedFileSkipReason::NonUtf8,
11877 Some(byte_len),
11878 )),
11879 Ok(WorkspaceFileRead {
11880 content: WorkspaceFileContent::TooLarge { .. },
11881 ..
11882 }) => files.push(ContextPackRepositoryFileSource::skipped(
11883 selected_path,
11884 ContextPackSelectedFileSkipReason::TooLarge,
11885 None,
11886 )),
11887 Err(error) => {
11888 let reason = if matches!(
11889 error.code,
11890 NodeFailureCode::InvalidRepositoryPath
11891 | NodeFailureCode::RepositoryFileNotRegular
11892 | NodeFailureCode::RepositoryPathUnsafe
11893 ) {
11894 ContextPackSelectedFileSkipReason::Unsafe
11895 } else {
11896 ContextPackSelectedFileSkipReason::Unavailable
11897 };
11898 files.push(ContextPackRepositoryFileSource::skipped(
11899 selected_path,
11900 reason,
11901 None,
11902 ));
11903 }
11904 }
11905 }
11906 Ok(ContextPackRepositoryObservation { head, git, files })
11907 }
11908
11909 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
11910 let referenced_by_binding = self
11911 .session_bindings
11912 .lock()
11913 .unwrap_or_else(|poisoned| poisoned.into_inner())
11914 .values()
11915 .any(|binding| {
11916 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
11917 });
11918 let referenced_by_record = self
11919 .session_records
11920 .lock()
11921 .unwrap_or_else(|poisoned| poisoned.into_inner())
11922 .records
11923 .values()
11924 .any(|record| record.context_id.as_ref() == Some(context_id));
11925 let referenced_by_materialization = self
11926 .materializations
11927 .lock()
11928 .unwrap_or_else(|poisoned| poisoned.into_inner())
11929 .values()
11930 .any(|ownership| {
11931 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
11932 });
11933 if referenced_by_binding
11934 || referenced_by_record
11935 || referenced_by_materialization
11936 {
11937 return Err(failure(
11938 NodeFailureCode::ContextPackBusy,
11939 "context pack is still referenced",
11940 ));
11941 }
11942 let mut catalog = self.context_catalog
11943 .write()
11944 .unwrap_or_else(|poisoned| poisoned.into_inner());
11945 if catalog.get(context_id).is_none() {
11946 return Err(failure(
11947 NodeFailureCode::UnknownContextPack,
11948 "context pack is unavailable",
11949 ));
11950 }
11951 self.spawn_idempotency
11952 .lock()
11953 .unwrap_or_else(|poisoned| poisoned.into_inner())
11954 .entries
11955 .retain(|_, entry| !entry.value.references_context(context_id));
11956 let removed = catalog.remove(context_id);
11957 debug_assert!(removed.is_some(), "validated context pack remains present");
11958 Ok(())
11959 }
11960
11961 async fn dispatch_input_bounded(
11962 &self,
11963 address: &SessionAddress,
11964 action: InputAction,
11965 ) -> Result<(), NodeFailure> {
11966 let started_after = self.current_sequence();
11967 let command_id = self
11968 .dispatch_bounded(
11969 ControlCommand::SendInput {
11970 instance_id: address.session.instance_id,
11971 action,
11972 },
11973 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11974 )
11975 .await?;
11976 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
11980 let mut scan_after = started_after;
11981 let mut accepted_after = None;
11982 loop {
11983 let events = self
11984 .history
11985 .lock()
11986 .unwrap_or_else(|poisoned| poisoned.into_inner())
11987 .events
11988 .iter()
11989 .filter(|envelope| envelope.sequence > scan_after)
11990 .cloned()
11991 .collect::<Vec<_>>();
11992 for envelope in events {
11993 scan_after = scan_after.max(envelope.sequence);
11994 let NodeEvent::Control {
11995 address: event_address,
11996 event,
11997 } = envelope.event
11998 else {
11999 continue;
12000 };
12001 if event_address != *address {
12002 continue;
12003 }
12004 if event.command_id == Some(command_id) {
12005 match &event.event {
12006 ControlEventKind::CommandRejected { message } => {
12007 return Err(failure(NodeFailureCode::InvalidRequest, message));
12008 }
12009 ControlEventKind::InputRequested { .. } => {
12010 accepted_after = Some(envelope.sequence);
12011 continue;
12012 }
12013 _ => {}
12014 }
12015 }
12016 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12017 match &event.event {
12018 ControlEventKind::InputCompleted { .. } => return Ok(()),
12019 ControlEventKind::InputFailed { message, .. } => {
12020 return Err(failure(
12021 NodeFailureCode::BackendOperationFailed,
12022 message,
12023 ));
12024 }
12025 _ => {}
12026 }
12027 }
12028 }
12029 if Instant::now() >= deadline {
12030 return Err(failure(
12031 NodeFailureCode::BackendBusy,
12032 if accepted_after.is_some() {
12033 "input was accepted but did not settle before the bounded deadline"
12034 } else {
12035 "input was not accepted before the bounded deadline"
12036 },
12037 ));
12038 }
12039 sleep(Duration::from_millis(2)).await;
12040 }
12041 }
12042
12043 async fn dispatch_resize_bounded(
12044 &self,
12045 address: &SessionAddress,
12046 size: gate4agent_types::TerminalSize,
12047 ) -> Result<(), NodeFailure> {
12048 let started_after = self.current_sequence();
12049 let command_id = self
12050 .dispatch_bounded(
12051 ControlCommand::Resize {
12052 instance_id: address.session.instance_id,
12053 size,
12054 },
12055 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12056 )
12057 .await?;
12058 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12059 let mut scan_after = started_after;
12060 let mut accepted_after = None;
12061 loop {
12062 let events = self
12063 .history
12064 .lock()
12065 .unwrap_or_else(|poisoned| poisoned.into_inner())
12066 .events
12067 .iter()
12068 .filter(|envelope| envelope.sequence > scan_after)
12069 .cloned()
12070 .collect::<Vec<_>>();
12071 for envelope in events {
12072 scan_after = scan_after.max(envelope.sequence);
12073 let NodeEvent::Control {
12074 address: event_address,
12075 event,
12076 } = envelope.event
12077 else {
12078 continue;
12079 };
12080 if event_address != *address {
12081 continue;
12082 }
12083 if event.command_id == Some(command_id) {
12084 match &event.event {
12085 ControlEventKind::CommandRejected { message } => {
12086 return Err(failure(NodeFailureCode::InvalidRequest, message));
12087 }
12088 ControlEventKind::ResizeRequested {
12089 size: requested_size,
12090 ..
12091 } if *requested_size == size => {
12092 accepted_after = Some(envelope.sequence);
12093 continue;
12094 }
12095 _ => {}
12096 }
12097 }
12098 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12099 match &event.event {
12100 ControlEventKind::Resized { size: settled_size }
12101 if *settled_size == size =>
12102 {
12103 return Ok(());
12104 }
12105 ControlEventKind::ResizeFailed { message } => {
12106 return Err(failure(
12107 NodeFailureCode::BackendOperationFailed,
12108 message,
12109 ));
12110 }
12111 _ => {}
12112 }
12113 }
12114 }
12115 if Instant::now() >= deadline {
12116 return Err(failure(
12117 NodeFailureCode::BackendBusy,
12118 if accepted_after.is_some() {
12119 "resize was accepted but did not settle before the bounded deadline"
12120 } else {
12121 "resize was not accepted before the bounded deadline"
12122 },
12123 ));
12124 }
12125 sleep(Duration::from_millis(2)).await;
12126 }
12127 }
12128
12129 async fn spawn_session(
12130 &self,
12131 workspace_id: WorkspaceId,
12132 provider: AgentId,
12133 mode: SessionMode,
12134 terminal_size: gate4agent_types::TerminalSize,
12135 initial_prompt: Option<String>,
12136 ) -> Result<SessionAddress, NodeFailure> {
12137 if self.managed_worktrees.lock()
12138 .unwrap_or_else(|poisoned| poisoned.into_inner())
12139 .lease_for_workspace(&workspace_id)
12140 .is_some()
12141 {
12142 return Err(failure(
12143 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12144 "managed worktree targets may only be spawned through their lease request",
12145 ));
12146 }
12147 self
12148 .spawn_session_with_deadline(
12149 workspace_id,
12150 provider,
12151 mode,
12152 terminal_size,
12153 initial_prompt,
12154 None,
12155 None,
12156 None,
12157 None,
12158 None,
12159 SpawnRecordPolicy::ProviderIdentityOnly,
12160 None,
12161 &[],
12162 None,
12163 ApprovalLevel::default(),
12169 None,
12170 None,
12171 )
12172 .await
12173 .map(|(session, _runtime_policy)| session)
12174 }
12175
12176 async fn spawn_session_with_deadline(
12177 &self,
12178 workspace_id: WorkspaceId,
12179 provider: AgentId,
12180 mode: SessionMode,
12181 terminal_size: gate4agent_types::TerminalSize,
12182 initial_prompt: Option<String>,
12183 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12184 bundle: Option<ResolvedBundleReceipt>,
12185 context: Option<ResolvedContextPackReceipt>,
12186 managed_authority: Option<ManagedWorktreeLeaseId>,
12187 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12188 record_policy: SpawnRecordPolicy,
12189 deadline: Option<Instant>,
12190 required_capabilities: &[ProviderRuntimeCapability],
12191 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12192 approval_level: ApprovalLevel,
12193 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12194 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12200 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12201 let reserved_lease = self.managed_worktrees.lock()
12202 .unwrap_or_else(|poisoned| poisoned.into_inner())
12203 .lease_for_workspace(&workspace_id);
12204 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12205 (Some(expected), Some(provided)) if expected == provided => {}
12206 (None, None) => {}
12207 _ => {
12208 return Err(failure(
12209 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12210 "spawn lacks exact managed worktree lease authority",
12211 ));
12212 }
12213 }
12214 self.ensure_workspace_environment_profile_compatible(
12218 &workspace_id,
12219 environment_profile.as_ref(),
12220 )?;
12221 let runtime_requirement = match (mode, initial_prompt.is_some()) {
12222 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12223 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12224 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12225 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12226 };
12227 let transport = match mode {
12233 SessionMode::Pty => TransportKind::Pty,
12234 SessionMode::Inline => TransportKind::Pipe,
12235 SessionMode::Acp => TransportKind::Acp,
12236 };
12237 let pipe_transport = self.provider_supports_pipe_transport(&provider);
12238 let acp_transport = self.provider_supports_acp_transport(&provider);
12239 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12240 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12241 NodeFailureCode::UnsupportedSpawnCapability,
12242 "pre-admitted provider runtime no longer satisfies spawn requirements",
12243 ))?;
12244 runtime_policy
12245 } else if let Some(deadline) = deadline {
12246 let remaining = spawn_deadline_remaining(deadline)?;
12247 timeout(
12248 remaining,
12249 self.admit_provider_runtime(&provider, runtime_requirement),
12250 )
12251 .await
12252 .map_err(|_| {
12253 failure(
12254 NodeFailureCode::SpawnDeadlineExceeded,
12255 "provider admission exceeded the spawn deadline",
12256 )
12257 })??
12258 } else {
12259 self.admit_provider_runtime(&provider, runtime_requirement).await?
12260 };
12261 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12262 let runtime_policy =
12274 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12275 if required_capabilities
12276 .iter()
12277 .any(|capability| !runtime_policy.admits(*capability))
12278 {
12279 return Err(failure(
12280 NodeFailureCode::UnsupportedSpawnCapability,
12281 "provider runtime does not admit a required spawn capability",
12282 ));
12283 }
12284 if let Some(deadline) = deadline {
12285 spawn_deadline_remaining(deadline)?;
12286 }
12287 self.ensure_binding_capacity()?;
12288 let working_directory = self.workspace_root(&workspace_id)?;
12289 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12290 let session = SessionKey {
12291 instance_id,
12292 generation: SessionGeneration(1),
12293 };
12294 let address = SessionAddress {
12295 workspace_id: workspace_id.clone(),
12296 session,
12297 };
12298 tracing::info!(
12306 node_id = %self.node_id,
12307 workspace_id = %workspace_id,
12308 provider = %provider,
12309 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12310 session = ?address.session,
12311 "session spawn request accepted",
12312 );
12313 let mut prepared_spawn = SpawnPreparer::prepare(
12314 self,
12315 SpawnPrepareRequest {
12316 instance_id,
12317 address: &address,
12318 provider: &provider,
12319 mode,
12320 environment_profile: environment_profile.as_ref(),
12321 bundle: bundle.as_ref(),
12322 context: context.as_ref(),
12323 managed_authority: managed_authority.as_ref(),
12324 harness_mcp,
12325 approval_level,
12326 network_allowlist: network_allowlist.as_ref(),
12327 browser_profile_id,
12328 },
12329 )?;
12330 let materialization_id = prepared_spawn.materialization_id.take();
12331 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12332 let record_id = self.bind_spawn_session_with_materialization(
12333 &address,
12334 provider.clone(),
12335 mode,
12336 runtime_policy,
12337 record_policy,
12338 environment_profile,
12339 bundle,
12340 context,
12341 materialization_id,
12342 )?;
12343 prepared_spawn.retain_session_guards();
12344 let agent_id = provider;
12348 let register_command_id = match self.dispatch_bounded(
12349 ControlCommand::Register {
12350 instance_id,
12351 agent_id,
12352 transport,
12353 },
12354 dispatch_timeout,
12355 )
12356 .await {
12357 Ok(command_id) => command_id,
12358 Err(error) => {
12359 if let Some(binding) = self.remove_binding(&address) {
12360 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12361 }
12362 if let Some(record_id) = record_id.as_ref() {
12363 self.discard_record(record_id)?;
12364 }
12365 return Err(spawn_dispatch_error(error, deadline));
12366 }
12367 };
12368 let start_timeout = match spawn_dispatch_timeout(deadline) {
12369 Ok(timeout) => timeout,
12370 Err(error) => {
12371 let recovery = self
12372 .rollback_spawn(
12373 &address,
12374 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12375 )
12376 .await;
12377 return match recovery {
12378 Ok(()) => {
12379 if let Some(record_id) = record_id.as_ref() {
12380 self.discard_record(record_id)?;
12381 }
12382 Err(error)
12383 }
12384 Err(recovery_error) => {
12385 if let Some(record_id) = record_id.as_ref() {
12386 self.mark_record_error(
12387 record_id,
12388 "spawn deadline elapsed and registration recovery failed",
12389 )?;
12390 }
12391 Err(recovery_error)
12392 }
12393 };
12394 }
12395 };
12396 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12397 if let Err(recovery_error) = self
12398 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12399 .await
12400 {
12401 return Err(recovery_error);
12402 }
12403 if let Some(record_id) = record_id.as_ref() {
12404 self.discard_record(record_id)?;
12405 }
12406 return Err(start_error);
12407 }
12408 let start_result = self
12409 .dispatch_bounded(
12410 ControlCommand::Start {
12411 instance_id,
12412 runtime_policy,
12413 request: StartRequest {
12414 working_directory,
12415 terminal_size,
12416 initial_prompt,
12417 session_options: None,
12418 approval_level,
12419 },
12420 },
12421 start_timeout,
12422 )
12423 .await;
12424 let start_command_id = match start_result {
12425 Ok(command_id) => command_id,
12426 Err(start_error) => {
12427 let start_error = spawn_dispatch_error(start_error, deadline);
12428 let recovery = self
12429 .rollback_spawn(
12430 &address,
12431 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12432 )
12433 .await;
12434 return match recovery {
12435 Ok(()) => {
12436 if let Some(record_id) = record_id.as_ref() {
12437 self.discard_record(record_id)?;
12438 }
12439 Err(start_error)
12440 }
12441 Err(recovery_error) => {
12442 let diagnostic = format!(
12443 "start failed and registration recovery failed: {}",
12444 recovery_error.message,
12445 );
12446 if let Some(record_id) = record_id.as_ref() {
12447 self.mark_record_error(record_id, &diagnostic)?;
12448 }
12449 Err(failure(recovery_error.code, &diagnostic))
12450 }
12451 };
12452 }
12453 };
12454
12455 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12456 loop {
12457 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12466 .or_else(|| self.take_rejected_command_reason(start_command_id))
12467 {
12468 self.rollback_spawn(
12469 &address,
12470 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12471 ).await.map_err(|error| {
12472 failure(
12473 error.code,
12474 &format!(
12475 "spawn was rejected ({}) and registration recovery failed: {}",
12476 reason.message, error.message
12477 ),
12478 )
12479 })?;
12480 if let Some(record_id) = record_id.as_ref() {
12481 self.discard_record(record_id)?;
12482 }
12483 let code = if reason.unsupported_transport {
12484 NodeFailureCode::UnsupportedTransport
12485 } else {
12486 NodeFailureCode::BackendOperationFailed
12487 };
12488 return Err(failure(code, &reason.message));
12489 }
12490 if let Some(current) = self
12491 .handle
12492 .snapshot()
12493 .sessions
12494 .iter()
12495 .find(|current| current.instance_id == instance_id)
12496 {
12497 if current.generation != session.generation {
12498 let divergence = failure(
12499 NodeFailureCode::StaleGeneration,
12500 "spawn generation diverged",
12501 );
12502 return match self
12503 .rollback_spawn(
12504 &address,
12505 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12506 )
12507 .await
12508 {
12509 Ok(()) => {
12510 if let Some(record_id) = record_id.as_ref() {
12511 self.discard_record(record_id)?;
12512 }
12513 Err(divergence)
12514 }
12515 Err(recovery_error) => {
12516 if let Some(record_id) = record_id.as_ref() {
12517 self.mark_record_error(
12518 record_id,
12519 "spawn generation diverged and recovery failed",
12520 )?;
12521 }
12522 Err(recovery_error)
12523 }
12524 };
12525 }
12526 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12527 prepared_spawn.retain_launch_guards();
12528 return Ok((address, runtime_policy));
12529 }
12530 }
12531 if Instant::now() >= commit_deadline {
12532 self.rollback_spawn(
12533 &address,
12534 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12535 ).await.map_err(|error| {
12536 failure(
12537 error.code,
12538 &format!(
12539 "start did not commit and registration recovery failed: {}",
12540 error.message
12541 ),
12542 )
12543 })?;
12544 if let Some(record_id) = record_id.as_ref() {
12545 self.discard_record(record_id)?;
12546 }
12547 return Err(failure(
12548 if deadline.is_some() {
12549 NodeFailureCode::SpawnDeadlineExceeded
12550 } else {
12551 NodeFailureCode::BackendBusy
12552 },
12553 "start did not commit before the bounded deadline; registration was removed",
12554 ));
12555 }
12556 sleep(Duration::from_millis(2)).await;
12557 }
12558 }
12559
12560 fn effective_spawn_runtime_policy(
12561 &self,
12562 runtime_policy: ProviderRuntimePolicy,
12563 ) -> ProviderRuntimePolicy {
12564 #[cfg(feature = "fixture")]
12565 let runtime_policy = if self.fixture_semantic_hook_policy {
12566 ProviderRuntimePolicy::new(true, true, true, true, false, true)
12567 .expect("monitoring Hook fixture policy is internally valid")
12568 } else {
12569 runtime_policy
12570 };
12571 runtime_policy
12572 }
12573
12574 fn verify_harness_mcp_before_start(
12575 &self,
12576 prepared: Option<&PreparedHarnessMcpSpawn>,
12577 ) -> Result<(), NodeFailure> {
12578 prepared
12579 .map(PreparedHarnessMcpSpawn::verify_helper_program)
12580 .transpose()
12581 .map(|_| ())
12582 .map_err(harness_mcp_failure)
12583 }
12584
12585 async fn rollback_spawn(
12586 &self,
12587 address: &SessionAddress,
12588 commit_timeout: Duration,
12589 ) -> Result<(), NodeFailure> {
12590 self.dispatch_bounded(
12591 ControlCommand::Remove {
12592 instance_id: address.session.instance_id,
12593 },
12594 commit_timeout,
12595 )
12596 .await?;
12597 self.wait_until_removed(address, commit_timeout).await?;
12598 if let Some(binding) = self.remove_binding(address) {
12599 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
12600 }
12601 Ok(())
12602 }
12603
12604 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12605 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12606 self.dispatch_bounded(
12607 ControlCommand::Remove {
12608 instance_id: address.session.instance_id,
12609 },
12610 commit_timeout,
12611 )
12612 .await?;
12613 self.wait_until_removed(address, commit_timeout).await?;
12614 let removed = self.remove_binding(address);
12615 if let Some(binding) = removed.as_ref() {
12616 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
12617 }
12618 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12619 let snapshot = self.managed_worktrees.lock()
12620 .unwrap_or_else(|poisoned| poisoned.into_inner())
12621 .release_session(
12622 &lease_id,
12623 self.incarnation_id,
12624 address.session.instance_id,
12625 address.session.generation,
12626 unix_time_ms(),
12627 );
12628 if self.persist_state().is_err() {
12629 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12630 }
12631 if let Some(snapshot) = snapshot {
12632 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12633 }
12634 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12635 }
12636 Ok(())
12637 }
12638
12639 async fn wait_until_removed(
12685 &self,
12686 address: &SessionAddress,
12687 commit_timeout: Duration,
12688 ) -> Result<(), NodeFailure> {
12689 let deadline = Instant::now() + commit_timeout;
12690 let mut next_dispatch = Instant::now();
12691 loop {
12692 let snapshot = self.handle.snapshot();
12693 let status = snapshot
12694 .sessions
12695 .iter()
12696 .find(|session| session.instance_id == address.session.instance_id)
12697 .map(|session| session.status.clone());
12698 let Some(status) = status else {
12699 return Ok(());
12700 };
12701 let now = Instant::now();
12702 if now >= deadline {
12703 return Err(failure(
12704 NodeFailureCode::BackendBusy,
12705 "remove did not commit before the bounded deadline; session binding was retained",
12706 ));
12707 }
12708 if status.allows_remove() && now >= next_dispatch {
12709 let _ = self.dispatch(ControlCommand::Remove {
12710 instance_id: address.session.instance_id,
12711 });
12712 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
12713 }
12714 sleep(Duration::from_millis(2)).await;
12715 }
12716 }
12717
12718 fn resync(&self, after_sequence: u64) -> NodeResponse {
12719 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12720 let event_sequence = history.last_sequence;
12721 let oldest_available_sequence = history.replay_floor_sequence;
12722 let events = history
12723 .events
12724 .iter()
12725 .filter(|event| event.sequence > after_sequence)
12726 .cloned()
12727 .collect();
12728 drop(history);
12729 NodeResponse::Resync {
12730 event_sequence,
12731 oldest_available_sequence,
12732 snapshot: self.snapshot(),
12733 events,
12734 }
12735 }
12736}
12737
12738fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
12743 match kind {
12744 AgentStreamChunkKindV1::Text { .. } => "text",
12745 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
12746 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
12747 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
12748 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
12749 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
12750 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
12751 }
12752}
12753
12754async fn accept_connections(
12768 endpoint: &str,
12769 call_home: Option<std::net::SocketAddr>,
12770 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
12771 shared: Arc<NodeShared>,
12772) -> Result<(), NodeServerError> {
12773 let mut connections = JoinSet::new();
12774 let result = match call_home {
12775 None => accept_connections_inner(endpoint, shared, &mut connections).await,
12776 Some(relay) => {
12777 let listener =
12778 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
12779 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
12780 tokio::pin!(listener);
12781 tokio::pin!(dialler);
12782 tokio::select! {
12783 result = &mut listener => result,
12784 result = &mut dialler => result,
12785 }
12786 }
12787 };
12788 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
12789 while !connections.is_empty() {
12790 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
12791 if remaining.is_zero()
12792 || timeout(remaining, connections.join_next()).await.is_err()
12793 {
12794 break;
12795 }
12796 }
12797 connections.abort_all();
12798 while connections.join_next().await.is_some() {}
12799 result
12800}
12801
12802const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
12806
12807async fn call_home_loop(
12821 relay: std::net::SocketAddr,
12822 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
12823 shared: Arc<NodeShared>,
12824) -> Result<(), NodeServerError> {
12825 let mut failures = 0_usize;
12826 loop {
12827 if shared.shutdown.load(Ordering::Acquire) {
12828 return Ok(());
12829 }
12830 let permit = tokio::select! {
12831 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12832 permit.map_err(|_| NodeServerError::PreauthClosed)?
12833 }
12834 _ = shared.shutdown_notify.notified() => return Ok(()),
12835 };
12836 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
12837 Ok(()) => {
12838 failures = 0;
12842 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed");
12843 }
12844 Err(error) => {
12845 failures = failures.saturating_add(1);
12846 tracing::warn!(
12847 node_id = %shared.node_id,
12848 %relay,
12849 cause = %error,
12850 attempt = failures,
12851 "call-home connection failed",
12852 );
12853 let backoff = CALL_HOME_BACKOFFS_MS
12854 [failures.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)];
12855 tokio::select! {
12856 _ = sleep(Duration::from_millis(backoff)) => {}
12857 _ = shared.shutdown_notify.notified() => return Ok(()),
12858 }
12859 }
12860 }
12861 }
12862}
12863
12864async fn dial_relay_and_serve(
12866 relay: std::net::SocketAddr,
12867 identity: Option<&hatchery_enroll::NodeIdentity>,
12868 shared: Arc<NodeShared>,
12869 preauth_permit: OwnedSemaphorePermit,
12870) -> Result<(), NodeServerError> {
12871 let mut stream = tokio::net::TcpStream::connect(relay)
12872 .await
12873 .map_err(NodeServerError::CallHomeTransport)?;
12874 let _ = stream.set_nodelay(true);
12878 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
12879 .await
12880 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12881 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
12882 let mut session_token = None;
12883 if let Some(identity) = identity {
12884 let mut magic = [0_u8; 4];
12888 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
12889 .await
12890 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
12891 .map_err(NodeServerError::CallHomeTransport)?;
12892 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
12893 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
12894 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
12895 .await
12896 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
12897 .map_err(NodeServerError::CallHomeTransport)?;
12898 let (answer, token) = identity
12899 .answer_challenge(shared.node_id.as_str(), &frame)
12900 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12901 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
12902 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
12903 session_token = Some(token);
12904 }
12905 }
12906 serve_connection(stream, shared, session_token, preauth_permit).await
12907}
12908
12909async fn accept_connections_inner(
12910 endpoint: &str,
12911 shared: Arc<NodeShared>,
12912 connections: &mut JoinSet<Result<(), NodeServerError>>,
12913) -> Result<(), NodeServerError> {
12914 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
12915 loop {
12916 while connections.try_join_next().is_some() {}
12917 if shared.shutdown.load(Ordering::Acquire) {
12918 return Ok(());
12919 }
12920 let preauth_permit = tokio::select! {
12921 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12922 permit.map_err(|_| NodeServerError::PreauthClosed)?
12923 }
12924 _ = shared.shutdown_notify.notified() => return Ok(()),
12925 };
12926 let server = tokio::select! {
12927 result = listener.accept() => result?,
12928 _ = shared.shutdown_notify.notified() => return Ok(()),
12929 };
12930 let connection_shared = Arc::clone(&shared);
12931 connections.spawn(async move {
12932 serve_connection(server, connection_shared, None, preauth_permit).await
12933 });
12934 if shared.shutdown.load(Ordering::Acquire) {
12935 return Ok(());
12936 }
12937 }
12938}
12939
12940async fn serve_connection<S>(
12948 mut pipe: S,
12949 shared: Arc<NodeShared>,
12950 session_token: Option<String>,
12951 preauth_permit: OwnedSemaphorePermit,
12952) -> Result<(), NodeServerError>
12953where
12954 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
12955{
12956 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
12958 let hello_frame = timeout(
12959 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
12960 read_json_frame_limited_body_timeout(
12961 &mut pipe,
12962 MAX_NODE_HELLO_FRAME_BYTES,
12963 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12964 ),
12965 )
12966 .await
12967 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
12968 let ClientFrame::Hello(hello) = hello_frame else {
12969 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
12970 };
12971 if hello.build_stamp != BUILD_STAMP {
12972 return Err(NodeServerError::Handshake(format!(
12973 "build stamp mismatch: local={BUILD_STAMP} remote={}",
12974 hello.build_stamp,
12975 )));
12976 }
12977 let compatibility = match hello.compatibility.as_ref() {
12978 Some(offer) => {
12979 let mut selected = node_compatibility_support(&shared)?
12980 .negotiate(offer)
12981 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12982 project_negotiated_provider_ids(&mut selected);
12983 Some(selected)
12984 }
12985 None => None,
12986 };
12987 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
12988 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
12989 (Some(offer), Some(selected)) => negotiated_auth_proof(
12990 auth_token,
12991 AuthDirection::Server,
12992 hello.role,
12993 &hello.client_nonce,
12994 &server_nonce,
12995 offer,
12996 selected,
12997 ),
12998 (None, None) => auth_proof(
12999 auth_token,
13000 AuthDirection::Server,
13001 hello.role,
13002 &hello.client_nonce,
13003 &server_nonce,
13004 ),
13005 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13006 }
13007 .map_err(NodeServerError::Authentication)?;
13008 write_json_frame_limited(
13009 &mut pipe,
13010 &ServerFrame::Challenge(ServerChallenge {
13011 build_stamp: BUILD_STAMP.to_owned(),
13012 server_nonce,
13013 server_proof,
13014 compatibility: compatibility.clone(),
13015 }),
13016 MAX_NODE_HELLO_FRAME_BYTES,
13017 )
13018 .await?;
13019 let authentication = timeout(
13020 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13021 read_json_frame_limited_body_timeout(
13022 &mut pipe,
13023 MAX_NODE_HELLO_FRAME_BYTES,
13024 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13025 ),
13026 )
13027 .await
13028 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13029 let ClientFrame::Authenticate(authentication) = authentication else {
13030 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13031 };
13032 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13033 (Some(offer), Some(selected)) => negotiated_auth_proof(
13034 auth_token,
13035 AuthDirection::Client,
13036 hello.role,
13037 &hello.client_nonce,
13038 &server_nonce,
13039 offer,
13040 selected,
13041 ),
13042 (None, None) => auth_proof(
13043 auth_token,
13044 AuthDirection::Client,
13045 hello.role,
13046 &hello.client_nonce,
13047 &server_nonce,
13048 ),
13049 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13050 }
13051 .map_err(NodeServerError::Authentication)?;
13052 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13053 return Err(NodeServerError::Handshake("access denied".to_owned()));
13054 }
13055 let selected_capabilities = compatibility
13056 .as_ref()
13057 .map(|selected| selected.capabilities.clone())
13058 .unwrap_or_default();
13059 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13060 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13061 });
13062 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13063 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13064 });
13065 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13066 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13067 });
13068 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13069 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13070 });
13071 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13072 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13073 });
13074 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13075 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13076 }) && selected_capabilities.iter().any(|capability| {
13077 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13078 });
13079 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13080 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13081 });
13082 let include_session_bundles = selected_capabilities.iter().any(|capability| {
13083 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13084 });
13085 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13086 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13087 });
13088 let include_agent_progress = selected_capabilities.iter().any(|capability| {
13089 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13090 });
13091 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13092 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13093 });
13094 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13095 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13096 });
13097 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13098 .try_acquire_owned()
13099 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13100 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13101 let _release_guard = ControllerReleaseGuard {
13102 shared: Arc::clone(&shared),
13103 connection_id,
13104 };
13105 drop(preauth_permit);
13106 let _authenticated_permit = authenticated_permit;
13107 let mut event_rx = shared.event_tx.subscribe();
13108 let mut terminal_event_rx = terminal_event_subscription(
13109 &shared,
13110 include_terminal_frame_events,
13111 );
13112 let mut agent_stream_event_rx = agent_stream_event_subscription(
13113 &shared,
13114 include_agent_stream_events,
13115 );
13116 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13117 &shared,
13118 include_harness_mcp_proxy,
13119 );
13120 write_json_frame(
13121 &mut pipe,
13122 &ServerFrame::Hello(NodeHello {
13123 build_stamp: BUILD_STAMP.to_owned(),
13124 incarnation_id: shared.incarnation_id,
13125 connection_id,
13126 role: hello.role,
13127 event_sequence: shared.current_sequence(),
13128 controller: shared.controller_state(),
13129 snapshot: snapshot_for_wire(
13130 &shared,
13131 include_provider_runtime_status,
13132 include_open_provider_ids,
13133 include_spawn_profiles,
13134 include_managed_worktrees,
13135 include_child_environment_profiles,
13136 include_session_bundles,
13137 include_history_context_packs,
13138 include_agent_progress,
13139 include_session_task_correlation,
13140 ),
13141 compatibility,
13142 }),
13143 )
13144 .await?;
13145 let (mut reader, mut writer) = tokio::io::split(pipe);
13146 let (frame_tx, mut frame_rx) = mpsc::channel(16);
13147 let reader_task = tokio::spawn(async move {
13148 loop {
13149 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13150 &mut reader,
13151 MAX_NODE_CLIENT_FRAME_BYTES,
13152 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13153 )
13154 .await;
13155 let terminal = frame.is_err();
13156 if frame_tx.send(frame).await.is_err() || terminal {
13157 break;
13158 }
13159 }
13160 });
13161 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13162 let mut pending_events = Vec::new();
13163 let mut resync_required = false;
13164 let mut discard_events_through = 0_u64;
13165 loop {
13166 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13171 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13172 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13173 for _ in 0..durable_drain_budget {
13174 match event_rx.try_recv() {
13175 Ok(event) => queue_connection_event(
13176 &mut pending_events,
13177 event,
13178 discard_events_through,
13179 ),
13180 Err(broadcast::error::TryRecvError::Empty) => break,
13181 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13182 resync_required = true;
13183 break;
13184 }
13185 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13186 }
13187 }
13188 if let Some(receiver) = terminal_event_rx.as_mut() {
13189 match receiver.try_recv() {
13190 Ok(events) => queue_connection_event_batch(
13191 &mut pending_events,
13192 &events,
13193 discard_events_through,
13194 ),
13195 Err(broadcast::error::TryRecvError::Empty) => {}
13196 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13197 resync_required = true;
13198 }
13199 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13200 }
13201 }
13202 if let Some(receiver) = agent_stream_event_rx.as_mut() {
13203 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13213 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13214 for _ in 0..agent_stream_drain_budget {
13215 match receiver.try_recv() {
13216 Ok(events) => queue_connection_event_batch(
13217 &mut pending_events,
13218 &events,
13219 discard_events_through,
13220 ),
13221 Err(broadcast::error::TryRecvError::Empty) => break,
13222 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13223 tracing::warn!(
13224 connection_id,
13225 dropped,
13226 "agent stream event subscription lagged; forcing a resync",
13227 );
13228 resync_required = true;
13229 break;
13230 }
13231 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13232 }
13233 }
13234 }
13235 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13236 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13242 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13243 for _ in 0..harness_mcp_proxy_drain_budget {
13244 match receiver.try_recv() {
13245 Ok(events) => queue_connection_event_batch(
13246 &mut pending_events,
13247 &events,
13248 discard_events_through,
13249 ),
13250 Err(broadcast::error::TryRecvError::Empty) => break,
13251 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13252 tracing::warn!(
13253 connection_id,
13254 dropped,
13255 "harness MCP proxy event subscription lagged; forcing a resync",
13256 );
13257 resync_required = true;
13258 break;
13259 }
13260 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13261 }
13262 }
13263 }
13264 if resync_required {
13265 let event = resync_required_event(&shared);
13266 apply_connection_resync_watermark(
13267 &mut pending_events,
13268 &mut discard_events_through,
13269 event.sequence,
13270 );
13271 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13272 resync_required = false;
13273 continue;
13274 }
13275 if !pending_events.is_empty() {
13276 pending_events.sort_unstable_by_key(|event| event.sequence);
13277 let burst_len = connection_event_burst_len(pending_events.len());
13278 shared
13279 .connection_events_sent
13280 .fetch_add(burst_len as u64, Ordering::Relaxed);
13281 for event in pending_events.drain(..burst_len) {
13282 let event = if include_managed_worktrees {
13283 Some(event)
13284 } else {
13285 project_event_without_managed_worktrees(event)
13286 };
13287 let event = event.and_then(|event| if include_open_provider_ids {
13288 Some(event)
13289 } else {
13290 project_event_legacy_provider_ids(&shared, event)
13291 });
13292 let event = event.map(|event| if include_child_environment_profiles {
13293 event
13294 } else {
13295 project_event_without_child_environment_profile(event)
13296 });
13297 let event = event.map(|event| if include_session_bundles {
13298 event
13299 } else {
13300 project_event_without_session_bundle(event)
13301 });
13302 let event = event.map(|event| {
13303 project_event_history_for_wire(event, include_history_context_packs)
13304 });
13305 let event = event.map(|event| if include_history_context_packs {
13306 event
13307 } else {
13308 project_event_without_context_pack(event)
13309 });
13310 let event = event.map(|event| if include_session_task_correlation {
13311 event
13312 } else {
13313 project_event_without_session_task_binding(event)
13314 });
13315 let exact_harness_controller = hello.role == ClientRole::Operator
13316 && shared.controller_state().is_some_and(|controller| {
13317 controller.connection_id == connection_id
13318 });
13319 let event = event.and_then(|event| {
13320 if include_harness_mcp_proxy && exact_harness_controller {
13321 Some(event)
13322 } else {
13323 project_event_without_harness_mcp_proxy(event)
13324 }
13325 });
13326 if let Some(event) = event {
13327 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13328 }
13329 }
13330 }
13331 tokio::select! {
13332 biased;
13333 frame = frame_rx.recv() => {
13334 let frame = match frame {
13335 Some(frame) => frame,
13336 None => break,
13337 };
13338 let frame = match frame {
13339 Ok(frame) => frame,
13340 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13341 Err(error) => return Err(error.into()),
13342 };
13343 let ClientFrame::Request(request) = frame else {
13344 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13345 };
13346 #[cfg(feature = "fixture")]
13347 let probe_spawn_managed_worktree_v2 = matches!(
13348 &request.request,
13349 NodeRequest::SpawnManagedWorktreeV2 { .. }
13350 );
13351 let requires_open_provider_ids =
13352 request_requires_open_provider_ids(&shared, &request.request);
13353 let requires_child_environment_profile =
13354 request_requires_child_environment_profile(&shared, &request.request);
13355 let requires_session_bundle =
13356 request_requires_session_bundle(&shared, &request.request);
13357 let requires_history_context_pack =
13358 request_requires_history_context_pack(&shared, &request.request);
13359 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13360 ResponseEnvelope {
13361 request_id: request.request_id,
13362 result: Err(failure(
13363 NodeFailureCode::UnsupportedCapability,
13364 "open provider IDs were not negotiated",
13365 )),
13366 }
13367 } else if requires_child_environment_profile
13368 && !include_child_environment_profiles
13369 {
13370 ResponseEnvelope {
13371 request_id: request.request_id,
13372 result: Err(failure(
13373 NodeFailureCode::UnsupportedCapability,
13374 "child environment profiles were not negotiated",
13375 )),
13376 }
13377 } else if requires_session_bundle && !include_session_bundles {
13378 ResponseEnvelope {
13379 request_id: request.request_id,
13380 result: Err(failure(
13381 NodeFailureCode::UnsupportedCapability,
13382 "session bundles were not negotiated",
13383 )),
13384 }
13385 } else if requires_history_context_pack && !include_history_context_packs {
13386 ResponseEnvelope {
13387 request_id: request.request_id,
13388 result: Err(failure(
13389 NodeFailureCode::UnsupportedCapability,
13390 "history context packs were not negotiated",
13391 )),
13392 }
13393 } else if request_uses_unnegotiated_capability(
13394 &request.request,
13395 &selected_capabilities,
13396 )
13397 {
13398 ResponseEnvelope {
13399 request_id: request.request_id,
13400 result: Err(failure(
13401 NodeFailureCode::UnsupportedCapability,
13402 "request capability was not negotiated",
13403 )),
13404 }
13405 } else {
13406 process_request(&shared, connection_id, hello.role, request).await
13407 };
13408 #[cfg(feature = "fixture")]
13409 if probe_spawn_managed_worktree_v2 {
13410 shared
13411 .fixture_spawn_managed_worktree_v2_failure_probe
13412 .record(reply.result.as_ref().err().map(|failure| failure.code));
13413 }
13414 if !include_provider_runtime_status {
13415 clear_response_provider_runtime_status(&mut reply);
13416 }
13417 if !include_managed_worktrees {
13418 project_response_without_managed_worktrees(&mut reply);
13419 }
13420 if !include_open_provider_ids {
13421 project_response_legacy_provider_ids(&shared, &mut reply);
13422 }
13423 if !include_child_environment_profiles {
13424 project_response_without_child_environment_profile(&mut reply);
13425 }
13426 if !include_session_bundles {
13427 project_response_without_session_bundle(&mut reply);
13428 }
13429 project_response_history_for_wire(
13430 &mut reply,
13431 include_history_context_packs,
13432 );
13433 if !include_history_context_packs {
13434 project_response_without_context_pack(&mut reply);
13435 }
13436 if !include_agent_progress {
13437 project_response_without_agent_progress(&mut reply);
13438 }
13439 if !include_session_task_correlation {
13440 project_response_without_session_task_binding(&mut reply);
13441 }
13442 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13443 }
13444 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13445 event = event_rx.recv() => {
13446 match event {
13447 Ok(event) => queue_connection_event(
13448 &mut pending_events,
13449 event,
13450 discard_events_through,
13451 ),
13452 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13453 Err(broadcast::error::RecvError::Closed) => break,
13454 }
13455 }
13456 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13457 match events {
13458 Ok(events) => queue_connection_event_batch(
13459 &mut pending_events,
13460 &events,
13461 discard_events_through,
13462 ),
13463 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13464 Err(broadcast::error::RecvError::Closed) => break,
13465 }
13466 }
13467 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13468 match events {
13469 Ok(events) => queue_connection_event_batch(
13470 &mut pending_events,
13471 &events,
13472 discard_events_through,
13473 ),
13474 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13475 Err(broadcast::error::RecvError::Closed) => break,
13476 }
13477 }
13478 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13479 match events {
13480 Ok(events) => queue_connection_event_batch(
13481 &mut pending_events,
13482 &events,
13483 discard_events_through,
13484 ),
13485 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13486 Err(broadcast::error::RecvError::Closed) => break,
13487 }
13488 }
13489 _ = shared.shutdown_notify.notified() => break,
13490 }
13491 }
13492 Ok(())
13493}
13494
13495async fn receive_terminal_event_batch(
13496 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13497) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13498 match receiver {
13499 Some(receiver) => receiver.recv().await,
13500 None => std::future::pending().await,
13501 }
13502}
13503
13504fn terminal_event_subscription(
13505 shared: &NodeShared,
13506 enabled: bool,
13507) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13508 enabled.then(|| shared.terminal_event_tx.subscribe())
13509}
13510
13511fn agent_stream_event_subscription(
13515 shared: &NodeShared,
13516 enabled: bool,
13517) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13518 enabled.then(|| shared.agent_stream_event_tx.subscribe())
13519}
13520
13521fn harness_mcp_proxy_event_subscription(
13526 shared: &NodeShared,
13527 enabled: bool,
13528) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13529 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13530}
13531
13532fn queue_connection_event(
13533 pending: &mut Vec<NodeEventEnvelope>,
13534 event: NodeEventEnvelope,
13535 discard_through: u64,
13536) {
13537 if event.sequence <= discard_through
13538 && !event.event.requires_harness_mcp_proxy_capability()
13539 {
13540 return;
13541 }
13542 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13543 if let Some(index) = pending.iter().position(|current| {
13544 matches!(
13545 ¤t.event,
13546 NodeEvent::TerminalFrame {
13547 address: current_address,
13548 ..
13549 } if current_address == address
13550 )
13551 }) {
13552 pending.remove(index);
13553 }
13554 }
13555 pending.push(event);
13556}
13557
13558fn project_event_without_harness_mcp_proxy(
13559 envelope: NodeEventEnvelope,
13560) -> Option<NodeEventEnvelope> {
13561 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13562}
13563
13564fn queue_connection_event_batch(
13565 pending: &mut Vec<NodeEventEnvelope>,
13566 events: &[NodeEventEnvelope],
13567 discard_through: u64,
13568) {
13569 for event in events {
13570 queue_connection_event(pending, event.clone(), discard_through);
13571 }
13572}
13573
13574fn apply_connection_resync_watermark(
13575 pending: &mut Vec<NodeEventEnvelope>,
13576 discard_through: &mut u64,
13577 marker_sequence: u64,
13578) {
13579 *discard_through = (*discard_through).max(marker_sequence);
13580 pending.retain(|event| event.sequence > *discard_through);
13581}
13582
13583fn connection_event_burst_len(pending_len: usize) -> usize {
13584 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13585}
13586
13587fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13588 let history = shared
13589 .history
13590 .lock()
13591 .unwrap_or_else(|poisoned| poisoned.into_inner());
13592 let sequence = history.last_sequence;
13593 let oldest_available_sequence = history.replay_floor_sequence;
13594 NodeEventEnvelope {
13595 sequence,
13596 event: NodeEvent::ResyncRequired {
13597 oldest_available_sequence,
13598 },
13599 }
13600}
13601
13602fn node_compatibility_support(
13603 shared: &NodeShared,
13604) -> Result<NodeCompatibilitySupport, NodeServerError> {
13605 let mut support = node_compatibility_support_for_manifest(
13606 &shared.provider_contracts,
13607 &shared.provider_adapter_contracts,
13608 )?;
13609 if shared
13610 .delivery_store
13611 .lock()
13612 .unwrap_or_else(|poisoned| poisoned.into_inner())
13613 .is_some()
13614 {
13615 support.capabilities.push(
13616 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13617 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13618 );
13619 }
13620 if shared.harness_mcp_registry.is_some() {
13621 support.capabilities.push(
13622 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
13623 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13624 );
13625 }
13626 Ok(support)
13627}
13628
13629fn node_compatibility_support_for_manifest(
13630 provider_contracts: &[ProviderContractSupport],
13631 provider_adapter_contracts: &[ProviderAdapterContractSupport],
13632) -> Result<NodeCompatibilitySupport, NodeServerError> {
13633 Ok(NodeCompatibilitySupport {
13634 build_stamp: BUILD_STAMP.to_owned(),
13635 capabilities: baseline_capabilities()?,
13636 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
13637 path_semantics: platform::path_semantics(),
13638 local_transport: platform::local_transport(),
13639 state_schema: StateSchemaSupport {
13640 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
13641 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13642 },
13643 provider_contracts: provider_contracts.to_vec(),
13644 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
13645 })
13646}
13647
13648fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
13649 let capabilities = [
13650 NODE_COMPATIBILITY_METADATA_CAPABILITY,
13651 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
13652 NODE_REPOSITORY_PATH_CAPABILITY,
13653 NODE_WORKSPACE_FILE_READ_CAPABILITY,
13654 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
13655 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
13656 NODE_GIT_READ_CAPABILITY,
13657 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
13658 NODE_PROVIDER_ID_OPEN_CAPABILITY,
13659 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
13660 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
13661 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
13662 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
13663 NODE_ACP_CONTROL_CAPABILITY,
13664 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
13665 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
13666 NODE_WORKTREE_SELECTION_CAPABILITY,
13667 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
13668 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
13669 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
13670 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
13671 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
13672 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
13673 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
13674 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
13675 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
13676 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
13677 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
13678 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
13679 ]
13680 .into_iter()
13681 .map(|capability| {
13682 CapabilityId::new(capability)
13683 .map_err(|error| NodeServerError::Handshake(error.to_string()))
13684 })
13685 .collect::<Result<Vec<_>, _>>()?;
13686 #[cfg(windows)]
13687 let capabilities = {
13688 let mut capabilities = capabilities;
13689 capabilities.push(
13690 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
13691 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13692 );
13693 capabilities
13694 };
13695 Ok(capabilities)
13696}
13697
13698fn request_uses_unnegotiated_capability(
13699 request: &NodeRequest,
13700 selected_capabilities: &[CapabilityId],
13701) -> bool {
13702 let selected = |required: &str| {
13703 selected_capabilities
13704 .iter()
13705 .any(|capability| capability.as_str() == required)
13706 };
13707 request
13708 .required_capability()
13709 .is_some_and(|required| !selected(required))
13710 || (request.requires_worktree_selection_capability()
13711 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
13712 || (request.requires_spawn_spec_defaults_overrides_capability()
13713 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
13714 || (request.requires_spawn_profile_revision_capability()
13715 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
13716 || (request.requires_child_environment_profile_capability()
13717 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
13718 || (request.requires_session_bundle_materialization_capability()
13719 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
13720 || (request.requires_history_context_pack_capability()
13721 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
13722}
13723
13724fn snapshot_for_wire(
13725 shared: &NodeShared,
13726 include_provider_runtime_status: bool,
13727 include_open_provider_ids: bool,
13728 include_spawn_profiles: bool,
13729 include_managed_worktrees: bool,
13730 include_child_environment_profiles: bool,
13731 include_session_bundles: bool,
13732 include_history_context_packs: bool,
13733 include_agent_progress: bool,
13734 include_session_task_correlation: bool,
13735) -> NodeSnapshot {
13736 let mut snapshot = shared.snapshot();
13737 if !include_provider_runtime_status {
13738 snapshot.provider_runtime_statuses.clear();
13739 }
13740 if !include_open_provider_ids {
13741 project_snapshot_legacy_provider_ids(&mut snapshot);
13742 }
13743 if !include_managed_worktrees {
13744 snapshot.managed_worktrees.clear();
13745 for workspace in &mut snapshot.workspaces {
13746 workspace.managed_worktree_profiles = None;
13747 }
13748 }
13749 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
13750 if !include_spawn_profiles {
13751 inventory.spawn_profiles = None;
13752 }
13753 if !include_session_bundles {
13754 inventory.bundles = None;
13755 }
13756 if inventory.spawn_profiles.is_none()
13757 && inventory.bundles.is_none()
13758 && inventory.network_allowlists.is_none()
13759 {
13760 snapshot.launch_inventory = None;
13761 }
13762 }
13763 if !include_child_environment_profiles {
13764 clear_snapshot_child_environment_profiles(&mut snapshot);
13765 }
13766 if !include_session_bundles {
13767 clear_snapshot_session_bundles(&mut snapshot);
13768 }
13769 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
13770 if !include_history_context_packs {
13771 clear_snapshot_context_packs(&mut snapshot);
13772 }
13773 if !include_agent_progress {
13774 snapshot.agent_progress.clear();
13775 }
13776 if !include_session_task_correlation {
13777 clear_snapshot_session_task_bindings(&mut snapshot);
13778 }
13779 snapshot
13780}
13781
13782fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
13783 for record in &mut snapshot.session_records {
13784 record.task_binding = None;
13785 }
13786}
13787
13788fn project_event_without_session_task_binding(
13789 mut envelope: NodeEventEnvelope,
13790) -> NodeEventEnvelope {
13791 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
13792 record.task_binding = None;
13793 }
13794 envelope
13795}
13796
13797fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
13798 let Ok(response) = reply.result.as_mut() else { return };
13799 match response {
13800 NodeResponse::Snapshot { snapshot, .. } => {
13801 clear_snapshot_session_task_bindings(snapshot);
13802 }
13803 NodeResponse::Resync { snapshot, events, .. } => {
13804 clear_snapshot_session_task_bindings(snapshot);
13805 for event in events {
13806 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
13807 record.task_binding = None;
13808 }
13809 }
13810 }
13811 NodeResponse::SessionRecordUpdated { record }
13812 | NodeResponse::ProviderSessionIndexed { record }
13813 | NodeResponse::NativeSessionIndexed { record, .. }
13814 | NodeResponse::SessionRecordResumed { record, .. } => {
13815 record.task_binding = None;
13816 }
13817 _ => {}
13818 }
13819}
13820
13821fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
13822 let Ok(response) = reply.result.as_mut() else { return };
13823 match response {
13824 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
13825 snapshot.agent_progress.clear();
13826 }
13827 _ => {}
13828 }
13829}
13830
13831fn project_event_without_managed_worktrees(
13832 mut envelope: NodeEventEnvelope,
13833) -> Option<NodeEventEnvelope> {
13834 if matches!(
13835 envelope.event,
13836 NodeEvent::ManagedWorktreeUpserted { .. }
13837 | NodeEvent::ManagedWorktreeRemoved { .. }
13838 ) {
13839 return None;
13840 }
13841 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
13842 workspace.managed_worktree_profiles = None;
13843 }
13844 Some(envelope)
13845}
13846
13847fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
13848 if matches!(
13849 reply.result,
13850 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
13851 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
13852 ) {
13853 reply.result = Err(failure(
13854 NodeFailureCode::UnsupportedCapability,
13855 "managed worktree capability was not negotiated",
13856 ));
13857 return;
13858 }
13859 let Ok(response) = reply.result.as_mut() else { return };
13860 match response {
13861 NodeResponse::Snapshot { snapshot, .. } => {
13862 snapshot.managed_worktrees.clear();
13863 for workspace in &mut snapshot.workspaces {
13864 workspace.managed_worktree_profiles = None;
13865 }
13866 }
13867 NodeResponse::Resync { snapshot, events, .. } => {
13868 snapshot.managed_worktrees.clear();
13869 for workspace in &mut snapshot.workspaces {
13870 workspace.managed_worktree_profiles = None;
13871 }
13872 *events = std::mem::take(events)
13873 .into_iter()
13874 .filter_map(project_event_without_managed_worktrees)
13875 .collect();
13876 }
13877 NodeResponse::WorkspaceRegistered { workspace }
13878 | NodeResponse::StandaloneWorkspaceCreated { workspace }
13879 | NodeResponse::WorktreeCreated { workspace, .. } => {
13880 workspace.managed_worktree_profiles = None;
13881 }
13882 NodeResponse::WorkspaceInspected { inspection } => {
13883 inspection.git.managed_worktree = None;
13884 }
13885 _ => {}
13886 }
13887}
13888
13889fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
13890 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
13891 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13892 });
13893 if includes_open_provider_ids {
13894 return;
13895 }
13896 compatibility
13897 .provider_contracts
13898 .retain(|contract| provider_id_is_legacy(&contract.provider));
13899 compatibility
13900 .provider_adapter_contracts
13901 .retain(|contract| provider_id_is_legacy(&contract.provider));
13902}
13903
13904fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
13905 if let Some(spec) = match request {
13906 NodeRequest::SpawnSpec { spec }
13907 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13908 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13909 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13910 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13911 _ => None,
13912 } {
13913 return shared
13914 .resolve_spawn_spec(spec)
13915 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
13916 }
13917 request_requires_open_provider_ids_with(
13918 request,
13919 |session| shared.validate_address(session).ok(),
13920 |record_id| shared.record(record_id).ok().map(|record| record.provider),
13921 )
13922}
13923
13924fn request_requires_child_environment_profile(
13925 shared: &NodeShared,
13926 request: &NodeRequest,
13927) -> bool {
13928 if let Some(spec) = match request {
13929 NodeRequest::SpawnSpec { spec }
13930 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13931 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13932 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13933 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13934 _ => None,
13935 } {
13936 return shared
13937 .resolve_spawn_spec(spec)
13938 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
13939 }
13940 match request {
13941 NodeRequest::Resume { session, .. }
13942 | NodeRequest::Prompt { session, .. }
13943 | NodeRequest::Paste { session, .. }
13944 | NodeRequest::Input { session, .. }
13945 | NodeRequest::TerminalBytes { session, .. }
13946 | NodeRequest::TerminalControl { session, .. }
13947 | NodeRequest::Resize { session, .. }
13948 | NodeRequest::Interrupt { session }
13949 | NodeRequest::Stop { session, .. }
13950 | NodeRequest::Remove { session }
13951 | NodeRequest::ResolveInteraction { session, .. }
13952 | NodeRequest::SetSessionMode { session, .. }
13953 | NodeRequest::SetSessionConfigOption { session, .. }
13954 | NodeRequest::SetSessionModel { session, .. }
13955 | NodeRequest::DiscoverHistory { session, .. }
13956 | NodeRequest::LoadHistory { session, .. }
13957 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13958 | NodeRequest::ExportContextPack { session } => shared
13959 .session_bindings
13960 .lock()
13961 .unwrap_or_else(|poisoned| poisoned.into_inner())
13962 .get(&session.session.instance_id)
13963 .filter(|binding| {
13964 binding.workspace_id == session.workspace_id
13965 && binding.generation == session.session.generation
13966 })
13967 .map_or(true, |binding| binding.environment_profile.is_some()),
13968 NodeRequest::RenameSessionRecord { record_id, .. }
13969 | NodeRequest::SetSessionTask { record_id, .. }
13970 | NodeRequest::ResumeSessionRecord { record_id, .. }
13971 | NodeRequest::ForgetSessionRecord { record_id } => shared
13972 .record(record_id)
13973 .map_or(true, |record| record.environment_profile.is_some()),
13974 NodeRequest::ArmHarnessMcpReservation { .. }
13975 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13976 | NodeRequest::ActivateHarnessMcpReservation { .. }
13977 | NodeRequest::AbortHarnessMcpReservation { .. }
13978 | NodeRequest::PutHarnessMcpReplyChunk { .. }
13979 | NodeRequest::RejectHarnessMcpCall { .. }
13980 | NodeRequest::Snapshot
13981 | NodeRequest::Resync { .. }
13982 | NodeRequest::BeginDeliveryStage { .. }
13983 | NodeRequest::PutDeliveryBlobChunk { .. }
13984 | NodeRequest::CommitDeliveryStage { .. }
13985 | NodeRequest::AbortDeliveryStage { .. }
13986 | NodeRequest::BrowseHostDirectories { .. }
13987 | NodeRequest::InspectWorkspace { .. }
13988 | NodeRequest::ReadWorkspaceFile { .. }
13989 | NodeRequest::WriteWorkspaceFile { .. }
13990 | NodeRequest::CreateWorkspaceFile { .. }
13991 | NodeRequest::CreateWorkspaceDirectory { .. }
13992 | NodeRequest::ReadGitHistory { .. }
13993 | NodeRequest::ReadGitDiff { .. }
13994 | NodeRequest::AcquireController { .. }
13995 | NodeRequest::ReleaseController
13996 | NodeRequest::Spawn { .. }
13997 | NodeRequest::SpawnSpec { .. }
13998 | NodeRequest::RegisterWorkspace { .. }
13999 | NodeRequest::CreateStandaloneWorkspace { .. }
14000 | NodeRequest::UnregisterWorkspace { .. }
14001 | NodeRequest::CreateWorktree { .. }
14002 | NodeRequest::RemoveWorktree { .. }
14003 | NodeRequest::SpawnManagedWorktree { .. }
14004 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14005 | NodeRequest::CleanupManagedWorktree { .. }
14006 | NodeRequest::IndexProviderSession { .. }
14007 | NodeRequest::IndexNativeSession { .. }
14008 | NodeRequest::CatalogNativeSessions { .. }
14009 | NodeRequest::PageNativeSessions { .. }
14010 | NodeRequest::PreviewNativeSession { .. }
14011 | NodeRequest::PreviewSessionRecord { .. }
14012 | NodeRequest::ForgetContextPack { .. }
14013 | NodeRequest::ResolveDurableContextPack { .. }
14014 | NodeRequest::ReadContextPack { .. }
14015 | NodeRequest::Shutdown => false,
14016 }
14017}
14018
14019fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14020 if let Some(spec) = match request {
14021 NodeRequest::SpawnSpec { spec }
14022 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14023 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14024 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14025 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14026 _ => None,
14027 } {
14028 return shared
14029 .resolve_spawn_spec(spec)
14030 .is_ok_and(|resolved| resolved.bundle_id.is_some());
14031 }
14032 match request {
14033 NodeRequest::Resume { session, .. }
14034 | NodeRequest::Prompt { session, .. }
14035 | NodeRequest::Paste { session, .. }
14036 | NodeRequest::Input { session, .. }
14037 | NodeRequest::TerminalBytes { session, .. }
14038 | NodeRequest::TerminalControl { session, .. }
14039 | NodeRequest::Resize { session, .. }
14040 | NodeRequest::Interrupt { session }
14041 | NodeRequest::Stop { session, .. }
14042 | NodeRequest::Remove { session }
14043 | NodeRequest::ResolveInteraction { session, .. }
14044 | NodeRequest::SetSessionMode { session, .. }
14045 | NodeRequest::SetSessionConfigOption { session, .. }
14046 | NodeRequest::SetSessionModel { session, .. }
14047 | NodeRequest::DiscoverHistory { session, .. }
14048 | NodeRequest::LoadHistory { session, .. }
14049 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14050 | NodeRequest::ExportContextPack { session } => shared
14051 .session_bindings
14052 .lock()
14053 .unwrap_or_else(|poisoned| poisoned.into_inner())
14054 .get(&session.session.instance_id)
14055 .filter(|binding| {
14056 binding.workspace_id == session.workspace_id
14057 && binding.generation == session.session.generation
14058 })
14059 .map_or(true, |binding| binding.bundle.is_some()),
14060 NodeRequest::RenameSessionRecord { record_id, .. }
14061 | NodeRequest::SetSessionTask { record_id, .. }
14062 | NodeRequest::ResumeSessionRecord { record_id, .. }
14063 | NodeRequest::ForgetSessionRecord { record_id } => shared
14064 .record(record_id)
14065 .map_or(true, |record| record.bundle.is_some()),
14066 NodeRequest::ArmHarnessMcpReservation { .. }
14067 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14068 | NodeRequest::ActivateHarnessMcpReservation { .. }
14069 | NodeRequest::AbortHarnessMcpReservation { .. }
14070 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14071 | NodeRequest::RejectHarnessMcpCall { .. }
14072 | NodeRequest::Snapshot
14073 | NodeRequest::Resync { .. }
14074 | NodeRequest::BeginDeliveryStage { .. }
14075 | NodeRequest::PutDeliveryBlobChunk { .. }
14076 | NodeRequest::CommitDeliveryStage { .. }
14077 | NodeRequest::AbortDeliveryStage { .. }
14078 | NodeRequest::BrowseHostDirectories { .. }
14079 | NodeRequest::InspectWorkspace { .. }
14080 | NodeRequest::ReadWorkspaceFile { .. }
14081 | NodeRequest::WriteWorkspaceFile { .. }
14082 | NodeRequest::CreateWorkspaceFile { .. }
14083 | NodeRequest::CreateWorkspaceDirectory { .. }
14084 | NodeRequest::ReadGitHistory { .. }
14085 | NodeRequest::ReadGitDiff { .. }
14086 | NodeRequest::AcquireController { .. }
14087 | NodeRequest::ReleaseController
14088 | NodeRequest::Spawn { .. }
14089 | NodeRequest::SpawnSpec { .. }
14090 | NodeRequest::RegisterWorkspace { .. }
14091 | NodeRequest::CreateStandaloneWorkspace { .. }
14092 | NodeRequest::UnregisterWorkspace { .. }
14093 | NodeRequest::CreateWorktree { .. }
14094 | NodeRequest::RemoveWorktree { .. }
14095 | NodeRequest::SpawnManagedWorktree { .. }
14096 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14097 | NodeRequest::CleanupManagedWorktree { .. }
14098 | NodeRequest::IndexProviderSession { .. }
14099 | NodeRequest::IndexNativeSession { .. }
14100 | NodeRequest::CatalogNativeSessions { .. }
14101 | NodeRequest::PageNativeSessions { .. }
14102 | NodeRequest::PreviewNativeSession { .. }
14103 | NodeRequest::PreviewSessionRecord { .. }
14104 | NodeRequest::ForgetContextPack { .. }
14105 | NodeRequest::ResolveDurableContextPack { .. }
14106 | NodeRequest::ReadContextPack { .. }
14107 | NodeRequest::Shutdown => false,
14108 }
14109}
14110
14111fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14112 if request.requires_history_context_pack_capability() {
14113 return true;
14114 }
14115 if let Some(spec) = match request {
14116 NodeRequest::SpawnSpec { spec }
14117 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14118 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14119 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14120 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14121 _ => None,
14122 } {
14123 return shared
14124 .resolve_spawn_spec(spec)
14125 .map_or(true, |resolved| resolved.context_id.is_some());
14126 }
14127 match request {
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, .. } => shared
14142 .session_bindings
14143 .lock()
14144 .unwrap_or_else(|poisoned| poisoned.into_inner())
14145 .get(&session.session.instance_id)
14146 .filter(|binding| {
14147 binding.workspace_id == session.workspace_id
14148 && binding.generation == session.session.generation
14149 })
14150 .map_or(true, |binding| binding.context.is_some()),
14151 NodeRequest::RenameSessionRecord { record_id, .. }
14152 | NodeRequest::SetSessionTask { record_id, .. }
14153 | NodeRequest::ResumeSessionRecord { record_id, .. }
14154 | NodeRequest::ForgetSessionRecord { record_id } => shared
14155 .record(record_id)
14156 .map_or(true, |record| record.context.is_some()),
14157 NodeRequest::ArmHarnessMcpReservation { .. }
14158 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14159 | NodeRequest::ActivateHarnessMcpReservation { .. }
14160 | NodeRequest::AbortHarnessMcpReservation { .. }
14161 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14162 | NodeRequest::RejectHarnessMcpCall { .. }
14163 | NodeRequest::Snapshot
14164 | NodeRequest::Resync { .. }
14165 | NodeRequest::BeginDeliveryStage { .. }
14166 | NodeRequest::PutDeliveryBlobChunk { .. }
14167 | NodeRequest::CommitDeliveryStage { .. }
14168 | NodeRequest::AbortDeliveryStage { .. }
14169 | NodeRequest::BrowseHostDirectories { .. }
14170 | NodeRequest::InspectWorkspace { .. }
14171 | NodeRequest::ReadWorkspaceFile { .. }
14172 | NodeRequest::WriteWorkspaceFile { .. }
14173 | NodeRequest::CreateWorkspaceFile { .. }
14174 | NodeRequest::CreateWorkspaceDirectory { .. }
14175 | NodeRequest::ReadGitHistory { .. }
14176 | NodeRequest::ReadGitDiff { .. }
14177 | NodeRequest::AcquireController { .. }
14178 | NodeRequest::ReleaseController
14179 | NodeRequest::Spawn { .. }
14180 | NodeRequest::SpawnSpec { .. }
14181 | NodeRequest::RegisterWorkspace { .. }
14182 | NodeRequest::CreateStandaloneWorkspace { .. }
14183 | NodeRequest::UnregisterWorkspace { .. }
14184 | NodeRequest::CreateWorktree { .. }
14185 | NodeRequest::RemoveWorktree { .. }
14186 | NodeRequest::SpawnManagedWorktree { .. }
14187 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14188 | NodeRequest::CleanupManagedWorktree { .. }
14189 | NodeRequest::IndexProviderSession { .. }
14190 | NodeRequest::IndexNativeSession { .. }
14191 | NodeRequest::CatalogNativeSessions { .. }
14192 | NodeRequest::PageNativeSessions { .. }
14193 | NodeRequest::PreviewNativeSession { .. }
14194 | NodeRequest::PreviewSessionRecord { .. }
14195 | NodeRequest::DiscoverHistory { .. }
14196 | NodeRequest::LoadHistory { .. }
14197 | NodeRequest::ExportContextPackForSessionRecord { .. }
14198 | NodeRequest::ExportContextPack { .. }
14199 | NodeRequest::ForgetContextPack { .. }
14200 | NodeRequest::ResolveDurableContextPack { .. }
14201 | NodeRequest::ReadContextPack { .. }
14202 | NodeRequest::Shutdown => false,
14203 }
14204}
14205
14206fn request_requires_open_provider_ids_with(
14207 request: &NodeRequest,
14208 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14209 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14210) -> bool {
14211 match request {
14212 NodeRequest::Spawn { provider, .. } => !provider_id_is_legacy(provider),
14213 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14214 NodeRequest::CatalogNativeSessions { route, .. }
14215 | NodeRequest::PageNativeSessions { route, .. } => {
14216 !provider_id_is_legacy(&route.provider)
14217 }
14218 NodeRequest::PreviewNativeSession { selection, .. }
14219 | NodeRequest::IndexNativeSession { selection, .. } => {
14220 !provider_id_is_legacy(&selection.route.provider)
14221 }
14222 NodeRequest::SpawnSpec { .. }
14223 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14224 NodeRequest::SpawnManagedWorktree { .. }
14225 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14226 NodeRequest::Resume { session, .. }
14227 | NodeRequest::Prompt { session, .. }
14228 | NodeRequest::Paste { session, .. }
14229 | NodeRequest::Input { session, .. }
14230 | NodeRequest::TerminalBytes { session, .. }
14231 | NodeRequest::TerminalControl { session, .. }
14232 | NodeRequest::Resize { session, .. }
14233 | NodeRequest::Interrupt { session }
14234 | NodeRequest::Stop { session, .. }
14235 | NodeRequest::Remove { session }
14236 | NodeRequest::ResolveInteraction { session, .. }
14237 | NodeRequest::SetSessionMode { session, .. }
14238 | NodeRequest::SetSessionConfigOption { session, .. }
14239 | NodeRequest::SetSessionModel { session, .. }
14240 | NodeRequest::DiscoverHistory { session, .. }
14241 | NodeRequest::LoadHistory { session, .. }
14242 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14243 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14244 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14245 NodeRequest::RenameSessionRecord { record_id, .. }
14246 | NodeRequest::SetSessionTask { record_id, .. }
14247 | NodeRequest::ResumeSessionRecord { record_id, .. }
14248 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14249 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14250 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14251 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14252 NodeRequest::ArmHarnessMcpReservation { .. }
14253 | NodeRequest::ActivateHarnessMcpReservation { .. }
14254 | NodeRequest::AbortHarnessMcpReservation { .. }
14255 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14256 | NodeRequest::RejectHarnessMcpCall { .. }
14257 | NodeRequest::Snapshot
14258 | NodeRequest::Resync { .. }
14259 | NodeRequest::BeginDeliveryStage { .. }
14260 | NodeRequest::PutDeliveryBlobChunk { .. }
14261 | NodeRequest::CommitDeliveryStage { .. }
14262 | NodeRequest::AbortDeliveryStage { .. }
14263 | NodeRequest::BrowseHostDirectories { .. }
14264 | NodeRequest::InspectWorkspace { .. }
14265 | NodeRequest::ReadWorkspaceFile { .. }
14266 | NodeRequest::WriteWorkspaceFile { .. }
14267 | NodeRequest::CreateWorkspaceFile { .. }
14268 | NodeRequest::CreateWorkspaceDirectory { .. }
14269 | NodeRequest::ReadGitHistory { .. }
14270 | NodeRequest::ReadGitDiff { .. }
14271 | NodeRequest::AcquireController { .. }
14272 | NodeRequest::ReleaseController
14273 | NodeRequest::RegisterWorkspace { .. }
14274 | NodeRequest::CreateStandaloneWorkspace { .. }
14275 | NodeRequest::UnregisterWorkspace { .. }
14276 | NodeRequest::CreateWorktree { .. }
14277 | NodeRequest::RemoveWorktree { .. }
14278 | NodeRequest::CleanupManagedWorktree { .. }
14279 | NodeRequest::Shutdown => false,
14280 NodeRequest::ForgetContextPack { .. } => true,
14281 NodeRequest::ResolveDurableContextPack { .. }
14287 | NodeRequest::ReadContextPack { .. } => true,
14288 }
14289}
14290
14291fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14292 snapshot.enabled_providers.retain(provider_id_is_legacy);
14293 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14294 snapshot
14295 .provider_runtime_statuses
14296 .as_slice()
14297 .iter()
14298 .filter(|status| provider_id_is_legacy(status.provider()))
14299 .cloned(),
14300 )
14301 .expect("a filtered provider runtime projection remains valid");
14302 snapshot
14303 .session_records
14304 .retain(|record| provider_id_is_legacy(&record.provider));
14305 for workspace in &mut snapshot.workspaces {
14306 workspace
14307 .sessions
14308 .retain(|session| provider_id_is_legacy(&session.agent_id));
14309 }
14310}
14311
14312fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14313 for record in &mut snapshot.session_records {
14314 record.environment_profile = None;
14315 }
14316 if let Some(profiles) = snapshot
14317 .launch_inventory
14318 .as_mut()
14319 .and_then(|inventory| inventory.spawn_profiles.as_mut())
14320 {
14321 for profile in profiles {
14322 profile.environment_profile = None;
14323 }
14324 }
14325}
14326
14327fn project_event_without_child_environment_profile(
14328 mut envelope: NodeEventEnvelope,
14329) -> NodeEventEnvelope {
14330 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14331 record.environment_profile = None;
14332 }
14333 envelope
14334}
14335
14336fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14337 let contains_environment_profile = match reply.result.as_ref() {
14338 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14339 receipt.environment_profile.is_some()
14340 }
14341 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14342 receipt.spawn.environment_profile.is_some()
14343 }
14344 _ => false,
14345 };
14346 if contains_environment_profile {
14347 reply.result = Err(failure(
14348 NodeFailureCode::UnsupportedCapability,
14349 "child environment profiles were not negotiated",
14350 ));
14351 return;
14352 }
14353 let Ok(response) = reply.result.as_mut() else {
14354 return;
14355 };
14356 match response {
14357 NodeResponse::Snapshot { snapshot, .. } => {
14358 clear_snapshot_child_environment_profiles(snapshot);
14359 }
14360 NodeResponse::Resync { snapshot, events, .. } => {
14361 clear_snapshot_child_environment_profiles(snapshot);
14362 for event in events {
14363 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14364 record.environment_profile = None;
14365 }
14366 }
14367 }
14368 NodeResponse::SessionRecordUpdated { record }
14369 | NodeResponse::ProviderSessionIndexed { record }
14370 | NodeResponse::NativeSessionIndexed { record, .. }
14371 | NodeResponse::SessionRecordResumed { record, .. } => {
14372 record.environment_profile = None;
14373 }
14374 NodeResponse::Armed { .. }
14375 | NodeResponse::Spawned { .. }
14376 | NodeResponse::Activated { .. }
14377 | NodeResponse::Aborted { .. }
14378 | NodeResponse::ReplyChunkAccepted { .. }
14379 | NodeResponse::CallRejected { .. }
14380 | NodeResponse::DeliveryStageBegun { .. }
14381 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14382 | NodeResponse::DeliveryCommitted { .. }
14383 | NodeResponse::DeliveryStageAborted { .. }
14384 | NodeResponse::WorkspaceInspected { .. }
14385 | NodeResponse::HostDirectoriesBrowsed { .. }
14386 | NodeResponse::WorkspaceFileRead { .. }
14387 | NodeResponse::WorkspaceFileWritten { .. }
14388 | NodeResponse::WorkspaceFileCreated { .. }
14389 | NodeResponse::WorkspaceDirectoryCreated { .. }
14390 | NodeResponse::GitHistoryRead { .. }
14391 | NodeResponse::GitDiffRead { .. }
14392 | NodeResponse::Controller { .. }
14393 | NodeResponse::SpawnAccepted { .. }
14394 | NodeResponse::SpawnSpecAccepted { .. }
14395 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14396 | NodeResponse::ManagedWorktreeCleanup { .. }
14397 | NodeResponse::NativeSessionsCataloged { .. }
14398 | NodeResponse::NativeSessionsPaged { .. }
14399 | NodeResponse::NativeSessionPreviewed { .. }
14400 | NodeResponse::SessionRecordPreviewed { .. }
14401 | NodeResponse::HistoryDiscovered { .. }
14402 | NodeResponse::HistoryLoaded { .. }
14403 | NodeResponse::ContextPackForSessionRecordExported { .. }
14404 | NodeResponse::ContextPackExported { .. }
14405 | NodeResponse::ContextPackForgotten { .. }
14406 | NodeResponse::DurableContextPackResolved { .. }
14407 | NodeResponse::ContextPackBytesRead { .. }
14408 | NodeResponse::SessionRecordForgotten { .. }
14409 | NodeResponse::WorkspaceRegistered { .. }
14410 | NodeResponse::StandaloneWorkspaceCreated { .. }
14411 | NodeResponse::WorkspaceUnregistered { .. }
14412 | NodeResponse::WorktreeCreated { .. }
14413 | NodeResponse::WorktreeRemoved { .. }
14414 | NodeResponse::Accepted
14415 | NodeResponse::ShuttingDown => {}
14416 }
14417}
14418
14419fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14420 for record in &mut snapshot.session_records {
14421 record.bundle = None;
14422 }
14423}
14424
14425fn project_event_without_session_bundle(
14426 mut envelope: NodeEventEnvelope,
14427) -> NodeEventEnvelope {
14428 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14429 record.bundle = None;
14430 }
14431 envelope
14432}
14433
14434fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14435 let contains_bundle = match reply.result.as_ref() {
14436 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14437 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14438 receipt.spawn.bundle.is_some()
14439 }
14440 _ => false,
14441 };
14442 if contains_bundle {
14443 reply.result = Err(failure(
14444 NodeFailureCode::UnsupportedCapability,
14445 "session bundles were not negotiated",
14446 ));
14447 return;
14448 }
14449 let Ok(response) = reply.result.as_mut() else {
14450 return;
14451 };
14452 match response {
14453 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14454 NodeResponse::Resync { snapshot, events, .. } => {
14455 clear_snapshot_session_bundles(snapshot);
14456 for event in events {
14457 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14458 record.bundle = None;
14459 }
14460 }
14461 }
14462 NodeResponse::SessionRecordUpdated { record }
14463 | NodeResponse::ProviderSessionIndexed { record }
14464 | NodeResponse::NativeSessionIndexed { record, .. }
14465 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14466 NodeResponse::Armed { .. }
14467 | NodeResponse::Spawned { .. }
14468 | NodeResponse::Activated { .. }
14469 | NodeResponse::Aborted { .. }
14470 | NodeResponse::ReplyChunkAccepted { .. }
14471 | NodeResponse::CallRejected { .. }
14472 | NodeResponse::DeliveryStageBegun { .. }
14473 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14474 | NodeResponse::DeliveryCommitted { .. }
14475 | NodeResponse::DeliveryStageAborted { .. }
14476 | NodeResponse::WorkspaceInspected { .. }
14477 | NodeResponse::HostDirectoriesBrowsed { .. }
14478 | NodeResponse::WorkspaceFileRead { .. }
14479 | NodeResponse::WorkspaceFileWritten { .. }
14480 | NodeResponse::WorkspaceFileCreated { .. }
14481 | NodeResponse::WorkspaceDirectoryCreated { .. }
14482 | NodeResponse::GitHistoryRead { .. }
14483 | NodeResponse::GitDiffRead { .. }
14484 | NodeResponse::Controller { .. }
14485 | NodeResponse::SpawnAccepted { .. }
14486 | NodeResponse::SpawnSpecAccepted { .. }
14487 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14488 | NodeResponse::ManagedWorktreeCleanup { .. }
14489 | NodeResponse::NativeSessionsCataloged { .. }
14490 | NodeResponse::NativeSessionsPaged { .. }
14491 | NodeResponse::NativeSessionPreviewed { .. }
14492 | NodeResponse::SessionRecordPreviewed { .. }
14493 | NodeResponse::HistoryDiscovered { .. }
14494 | NodeResponse::HistoryLoaded { .. }
14495 | NodeResponse::ContextPackForSessionRecordExported { .. }
14496 | NodeResponse::ContextPackExported { .. }
14497 | NodeResponse::ContextPackForgotten { .. }
14498 | NodeResponse::DurableContextPackResolved { .. }
14499 | NodeResponse::ContextPackBytesRead { .. }
14500 | NodeResponse::SessionRecordForgotten { .. }
14501 | NodeResponse::WorkspaceRegistered { .. }
14502 | NodeResponse::StandaloneWorkspaceCreated { .. }
14503 | NodeResponse::WorkspaceUnregistered { .. }
14504 | NodeResponse::WorktreeCreated { .. }
14505 | NodeResponse::WorktreeRemoved { .. }
14506 | NodeResponse::Accepted
14507 | NodeResponse::ShuttingDown => {}
14508 }
14509}
14510
14511fn project_snapshot_history_for_wire(
14512 snapshot: &mut NodeSnapshot,
14513 include_history_metadata: bool,
14514) {
14515 for workspace in &mut snapshot.workspaces {
14516 for session in &mut workspace.sessions {
14517 if include_history_metadata {
14518 session.history.loaded = None;
14519 } else {
14520 session.history = Default::default();
14521 }
14522 }
14523 }
14524}
14525
14526fn project_event_history_for_wire(
14527 mut envelope: NodeEventEnvelope,
14528 include_history_metadata: bool,
14529) -> NodeEventEnvelope {
14530 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14531 for session in &mut workspace.sessions {
14532 if include_history_metadata {
14533 session.history.loaded = None;
14534 } else {
14535 session.history = Default::default();
14536 }
14537 }
14538 }
14539 envelope
14540}
14541
14542fn project_response_history_for_wire(
14543 reply: &mut ResponseEnvelope,
14544 include_history_metadata: bool,
14545) {
14546 let Ok(response) = reply.result.as_mut() else {
14547 return;
14548 };
14549 match response {
14550 NodeResponse::Snapshot { snapshot, .. } => {
14551 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14552 }
14553 NodeResponse::Resync {
14554 snapshot, events, ..
14555 } => {
14556 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14557 for event in events {
14558 *event = project_event_history_for_wire(
14559 event.clone(),
14560 include_history_metadata,
14561 );
14562 }
14563 }
14564 NodeResponse::WorkspaceRegistered { workspace }
14565 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14566 | NodeResponse::WorktreeCreated { workspace, .. } => {
14567 for session in &mut workspace.sessions {
14568 if include_history_metadata {
14569 session.history.loaded = None;
14570 } else {
14571 session.history = Default::default();
14572 }
14573 }
14574 }
14575 _ => {}
14576 }
14577}
14578
14579fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14580 for record in &mut snapshot.session_records {
14581 record.context_id = None;
14582 record.context = None;
14583 record.exported_context = None;
14584 }
14585}
14586
14587fn project_event_without_context_pack(
14588 mut envelope: NodeEventEnvelope,
14589) -> NodeEventEnvelope {
14590 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14591 record.context_id = None;
14592 record.context = None;
14593 record.exported_context = None;
14594 }
14595 envelope
14596}
14597
14598fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14599 let contains_context = match reply.result.as_ref() {
14600 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14601 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14602 receipt.spawn.context.is_some()
14603 }
14604 Ok(NodeResponse::HistoryDiscovered { .. })
14605 | Ok(NodeResponse::HistoryLoaded { .. })
14606 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14607 | Ok(NodeResponse::ContextPackExported { .. })
14608 | Ok(NodeResponse::ContextPackForgotten { .. })
14609 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14610 _ => false,
14611 };
14612 if contains_context {
14613 reply.result = Err(failure(
14614 NodeFailureCode::UnsupportedCapability,
14615 "history context packs were not negotiated",
14616 ));
14617 return;
14618 }
14619 let Ok(response) = reply.result.as_mut() else {
14620 return;
14621 };
14622 match response {
14623 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
14624 NodeResponse::Resync {
14625 snapshot, events, ..
14626 } => {
14627 clear_snapshot_context_packs(snapshot);
14628 for event in events {
14629 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14630 record.context_id = None;
14631 record.context = None;
14632 record.exported_context = None;
14633 }
14634 }
14635 }
14636 NodeResponse::SessionRecordUpdated { record }
14637 | NodeResponse::ProviderSessionIndexed { record }
14638 | NodeResponse::NativeSessionIndexed { record, .. }
14639 | NodeResponse::SessionRecordResumed { record, .. } => {
14640 record.context_id = None;
14641 record.context = None;
14642 record.exported_context = None;
14643 }
14644 _ => {}
14645 }
14646}
14647
14648fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
14649 let contains_open_record = match reply.result.as_ref() {
14650 Ok(NodeResponse::SessionRecordUpdated { record })
14651 | Ok(NodeResponse::ProviderSessionIndexed { record })
14652 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
14653 !provider_id_is_legacy(&record.provider)
14654 }
14655 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
14656 !provider_id_is_legacy(&selection.route.provider)
14657 || !provider_id_is_legacy(&record.provider)
14658 }
14659 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14660 !provider_id_is_legacy(&receipt.provider)
14661 }
14662 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14663 !provider_id_is_legacy(&receipt.spawn.provider)
14664 }
14665 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
14666 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
14667 !provider_id_is_legacy(&route.provider)
14668 }
14669 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
14670 !provider_id_is_legacy(&selection.route.provider)
14671 }
14672 _ => false,
14673 };
14674 if contains_open_record {
14675 reply.result = Err(failure(
14676 NodeFailureCode::UnsupportedCapability,
14677 "open provider IDs were not negotiated",
14678 ));
14679 return;
14680 }
14681 let Ok(response) = reply.result.as_mut() else {
14682 return;
14683 };
14684 match response {
14685 NodeResponse::Snapshot { snapshot, .. } => {
14686 project_snapshot_legacy_provider_ids(snapshot);
14687 }
14688 NodeResponse::Resync { snapshot, events, .. } => {
14689 project_snapshot_legacy_provider_ids(snapshot);
14690 *events = std::mem::take(events)
14691 .into_iter()
14692 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
14693 .collect();
14694 }
14695 NodeResponse::WorkspaceRegistered { workspace }
14696 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14697 | NodeResponse::WorktreeCreated { workspace, .. } => {
14698 workspace
14699 .sessions
14700 .retain(|session| provider_id_is_legacy(&session.agent_id));
14701 }
14702 NodeResponse::Armed { .. }
14703 | NodeResponse::Spawned { .. }
14704 | NodeResponse::Activated { .. }
14705 | NodeResponse::Aborted { .. }
14706 | NodeResponse::ReplyChunkAccepted { .. }
14707 | NodeResponse::CallRejected { .. }
14708 | NodeResponse::DeliveryStageBegun { .. }
14709 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14710 | NodeResponse::DeliveryCommitted { .. }
14711 | NodeResponse::DeliveryStageAborted { .. }
14712 | NodeResponse::WorkspaceInspected { .. }
14713 | NodeResponse::HostDirectoriesBrowsed { .. }
14714 | NodeResponse::WorkspaceFileRead { .. }
14715 | NodeResponse::WorkspaceFileWritten { .. }
14716 | NodeResponse::WorkspaceFileCreated { .. }
14717 | NodeResponse::WorkspaceDirectoryCreated { .. }
14718 | NodeResponse::GitHistoryRead { .. }
14719 | NodeResponse::GitDiffRead { .. }
14720 | NodeResponse::Controller { .. }
14721 | NodeResponse::SpawnAccepted { .. }
14722 | NodeResponse::SpawnSpecAccepted { .. }
14723 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14724 | NodeResponse::ManagedWorktreeCleanup { .. }
14725 | NodeResponse::NativeSessionsCataloged { .. }
14726 | NodeResponse::NativeSessionsPaged { .. }
14727 | NodeResponse::NativeSessionPreviewed { .. }
14728 | NodeResponse::SessionRecordPreviewed { .. }
14729 | NodeResponse::HistoryDiscovered { .. }
14730 | NodeResponse::HistoryLoaded { .. }
14731 | NodeResponse::ContextPackForSessionRecordExported { .. }
14732 | NodeResponse::ContextPackExported { .. }
14733 | NodeResponse::ContextPackForgotten { .. }
14734 | NodeResponse::DurableContextPackResolved { .. }
14735 | NodeResponse::ContextPackBytesRead { .. }
14736 | NodeResponse::SessionRecordUpdated { .. }
14737 | NodeResponse::ProviderSessionIndexed { .. }
14738 | NodeResponse::NativeSessionIndexed { .. }
14739 | NodeResponse::SessionRecordResumed { .. }
14740 | NodeResponse::SessionRecordForgotten { .. }
14741 | NodeResponse::WorkspaceUnregistered { .. }
14742 | NodeResponse::WorktreeRemoved { .. }
14743 | NodeResponse::Accepted
14744 | NodeResponse::ShuttingDown => {}
14745 }
14746}
14747
14748fn project_event_legacy_provider_ids(
14749 shared: &NodeShared,
14750 mut envelope: NodeEventEnvelope,
14751) -> Option<NodeEventEnvelope> {
14752 let include = match &mut envelope.event {
14753 NodeEvent::HarnessMcpReadCall { .. } => true,
14754 NodeEvent::Control { address, .. }
14755 | NodeEvent::AgentStream { address, .. }
14756 | NodeEvent::TerminalFrame { address, .. } => shared
14757 .validate_address(address)
14758 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
14759 NodeEvent::WorkspaceAdded { workspace } => {
14760 workspace
14761 .sessions
14762 .retain(|session| provider_id_is_legacy(&session.agent_id));
14763 true
14764 }
14765 NodeEvent::SessionRecordUpserted { record } => {
14766 provider_id_is_legacy(&record.provider)
14767 }
14768 NodeEvent::SessionRecordHistorySummarized { .. } => false,
14769 NodeEvent::SessionRecordRemoved { .. } => shared
14770 .history
14771 .lock()
14772 .unwrap_or_else(|poisoned| poisoned.into_inner())
14773 .removed_record_providers
14774 .get(&envelope.sequence)
14775 .is_some_and(provider_id_is_legacy),
14776 NodeEvent::ControllerChanged { .. }
14777 | NodeEvent::ManagedWorktreeUpserted { .. }
14778 | NodeEvent::ManagedWorktreeRemoved { .. }
14779 | NodeEvent::WorkspaceRemoved { .. }
14780 | NodeEvent::ResyncRequired { .. } => true,
14781 };
14782 include.then_some(envelope)
14783}
14784
14785fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
14786 let Ok(response) = reply.result.as_mut() else {
14787 return;
14788 };
14789 match response {
14790 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14791 snapshot.provider_runtime_statuses.clear();
14792 }
14793 NodeResponse::Armed { .. }
14794 | NodeResponse::Spawned { .. }
14795 | NodeResponse::Activated { .. }
14796 | NodeResponse::Aborted { .. }
14797 | NodeResponse::ReplyChunkAccepted { .. }
14798 | NodeResponse::CallRejected { .. }
14799 | NodeResponse::DeliveryStageBegun { .. }
14800 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14801 | NodeResponse::DeliveryCommitted { .. }
14802 | NodeResponse::DeliveryStageAborted { .. }
14803 | NodeResponse::WorkspaceInspected { .. }
14804 | NodeResponse::HostDirectoriesBrowsed { .. }
14805 | NodeResponse::WorkspaceFileRead { .. }
14806 | NodeResponse::WorkspaceFileWritten { .. }
14807 | NodeResponse::WorkspaceFileCreated { .. }
14808 | NodeResponse::WorkspaceDirectoryCreated { .. }
14809 | NodeResponse::GitHistoryRead { .. }
14810 | NodeResponse::GitDiffRead { .. }
14811 | NodeResponse::Controller { .. }
14812 | NodeResponse::SpawnAccepted { .. }
14813 | NodeResponse::SpawnSpecAccepted { .. }
14814 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14815 | NodeResponse::ManagedWorktreeCleanup { .. }
14816 | NodeResponse::NativeSessionsCataloged { .. }
14817 | NodeResponse::NativeSessionsPaged { .. }
14818 | NodeResponse::NativeSessionPreviewed { .. }
14819 | NodeResponse::SessionRecordPreviewed { .. }
14820 | NodeResponse::HistoryDiscovered { .. }
14821 | NodeResponse::HistoryLoaded { .. }
14822 | NodeResponse::ContextPackForSessionRecordExported { .. }
14823 | NodeResponse::ContextPackExported { .. }
14824 | NodeResponse::ContextPackForgotten { .. }
14825 | NodeResponse::DurableContextPackResolved { .. }
14826 | NodeResponse::ContextPackBytesRead { .. }
14827 | NodeResponse::SessionRecordUpdated { .. }
14828 | NodeResponse::ProviderSessionIndexed { .. }
14829 | NodeResponse::NativeSessionIndexed { .. }
14830 | NodeResponse::SessionRecordResumed { .. }
14831 | NodeResponse::SessionRecordForgotten { .. }
14832 | NodeResponse::WorkspaceRegistered { .. }
14833 | NodeResponse::StandaloneWorkspaceCreated { .. }
14834 | NodeResponse::WorkspaceUnregistered { .. }
14835 | NodeResponse::WorktreeCreated { .. }
14836 | NodeResponse::WorktreeRemoved { .. }
14837 | NodeResponse::Accepted
14838 | NodeResponse::ShuttingDown => {}
14839 }
14840}
14841
14842struct ControllerReleaseGuard {
14843 shared: Arc<NodeShared>,
14844 connection_id: u64,
14845}
14846
14847impl Drop for ControllerReleaseGuard {
14848 fn drop(&mut self) {
14849 self.shared.release_controller(self.connection_id);
14850 }
14851}
14852
14853struct AbortTaskOnDrop(AbortHandle);
14854
14855impl Drop for AbortTaskOnDrop {
14856 fn drop(&mut self) {
14857 self.0.abort();
14858 }
14859}
14860
14861fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
14866 std::env::var(name)
14867 .ok()
14868 .and_then(|value| value.parse::<u64>().ok())
14869 .filter(|value| *value > 0)
14870 .unwrap_or(default)
14871 .min(max)
14872}
14873
14874fn workspace_inspection_time_budget_ms() -> u64 {
14875 env_config_bounded(
14876 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
14877 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
14878 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
14879 )
14880}
14881
14882fn workspace_inspection_entry_cap() -> usize {
14883 env_config_bounded(
14884 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
14885 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
14886 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
14887 ) as usize
14888}
14889
14890struct WorkspaceWalkBudget {
14896 deadline: Instant,
14897 entry_cap: usize,
14898 entries_visited: u64,
14899 time_budget_exceeded: bool,
14900 entry_cap_exceeded: bool,
14901}
14902
14903impl WorkspaceWalkBudget {
14904 fn new(deadline: Instant, entry_cap: usize) -> Self {
14905 Self {
14906 deadline,
14907 entry_cap,
14908 entries_visited: 0,
14909 time_budget_exceeded: false,
14910 entry_cap_exceeded: false,
14911 }
14912 }
14913
14914 fn is_exhausted(&self) -> bool {
14915 self.time_budget_exceeded || self.entry_cap_exceeded
14916 }
14917
14918 fn record_visit(&mut self) -> bool {
14921 self.entries_visited += 1;
14922 if self.entries_visited as usize >= self.entry_cap {
14923 self.entry_cap_exceeded = true;
14924 }
14925 if Instant::now() >= self.deadline {
14926 self.time_budget_exceeded = true;
14927 }
14928 self.is_exhausted()
14929 }
14930}
14931
14932fn collect_workspace_entries(
14933 root: &Path,
14934 deadline: Instant,
14935 entry_cap: usize,
14936) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
14937 let mut entries = Vec::new();
14938 let mut truncated = false;
14939 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
14940 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
14941 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
14945 (entries, truncated, budget)
14946}
14947
14948fn workspace_inspection_truncation(
14952 walk_budget: &WorkspaceWalkBudget,
14953 git_time_budget_exceeded: bool,
14954 elapsed: Duration,
14955) -> Option<WorkspaceInspectionTruncationV1> {
14956 if !walk_budget.time_budget_exceeded
14957 && !walk_budget.entry_cap_exceeded
14958 && !git_time_budget_exceeded
14959 {
14960 return None;
14961 }
14962 Some(WorkspaceInspectionTruncationV1 {
14963 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
14964 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
14965 git_time_budget_exceeded,
14966 entries_visited: walk_budget.entries_visited,
14967 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
14968 })
14969}
14970
14971fn walk_workspace_directory(
14972 directory: &Path,
14973 relative_directory: Option<&RepositoryPath>,
14974 depth: usize,
14975 entries: &mut Vec<WorkspaceEntry>,
14976 truncated: &mut bool,
14977 budget: &mut WorkspaceWalkBudget,
14978) {
14979 if budget.is_exhausted() {
14980 *truncated = true;
14981 return;
14982 }
14983 let read_dir = match std::fs::read_dir(directory) {
14984 Ok(read_dir) => read_dir,
14985 Err(_) => {
14986 *truncated = true;
14987 return;
14988 }
14989 };
14990 let mut children = Vec::new();
14991 for child in read_dir {
14992 match child {
14993 Ok(child) => children.push(child),
14994 Err(_) => *truncated = true,
14995 }
14996 }
14997 children.sort_by_key(|child| child.file_name());
14998 for child in children {
14999 if budget.record_visit() {
15000 *truncated = true;
15001 return;
15002 }
15003 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15004 *truncated = true;
15005 return;
15006 }
15007 let file_type = match child.file_type() {
15008 Ok(file_type) => file_type,
15009 Err(_) => {
15010 *truncated = true;
15011 continue;
15012 }
15013 };
15014 if file_type.is_symlink() {
15015 continue;
15016 }
15017 let name = match child.file_name().into_string() {
15018 Ok(name) => name,
15019 Err(_) => {
15020 *truncated = true;
15021 continue;
15022 }
15023 };
15024 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15025 continue;
15026 }
15027 let relative_path = match relative_directory {
15028 None => windows_repository_path(name),
15029 Some(parent) => parent
15030 .as_utf8()
15031 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15032 };
15033 let Some(relative_path) = relative_path else {
15034 *truncated = true;
15035 continue;
15036 };
15037 let kind = if file_type.is_dir() {
15038 WorkspaceEntryKind::Directory
15039 } else if file_type.is_file() {
15040 WorkspaceEntryKind::File
15041 } else {
15042 continue;
15043 };
15044 entries.push(WorkspaceEntry {
15045 relative_path: relative_path.clone(),
15046 kind,
15047 });
15048 if file_type.is_dir() {
15049 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15050 walk_workspace_directory(
15051 &child.path(),
15052 Some(&relative_path),
15053 depth + 1,
15054 entries,
15055 truncated,
15056 budget,
15057 );
15058 } else {
15059 *truncated = true;
15060 }
15061 }
15062 }
15063}
15064
15065fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15066 if value.contains('\\') {
15067 return None;
15068 }
15069 RepositoryPath::utf8(value).ok()
15070}
15071
15072fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15073 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15074}
15075
15076fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15085 if name.eq_ignore_ascii_case(".git")
15086 || name.eq_ignore_ascii_case("target")
15087 || name.eq_ignore_ascii_case("node_modules")
15088 {
15089 return true;
15090 }
15091 path.join("CACHEDIR.TAG").is_file()
15092}
15093
15094fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15095 matches!(
15096 status,
15097 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15098 )
15099}
15100
15101#[derive(Clone, Debug, Eq, PartialEq)]
15102enum ContextPackRepositoryHead {
15103 NotRepository,
15104 Unborn,
15105 Commit(String),
15106}
15107
15108#[derive(Clone, Debug, Eq, PartialEq)]
15109struct ContextPackRepositoryObservation {
15110 head: ContextPackRepositoryHead,
15111 git: GitSnapshot,
15112 files: Vec<ContextPackRepositoryFileSource>,
15113}
15114
15115fn stable_context_pack_repository(
15116 first: ContextPackRepositoryObservation,
15117 second: ContextPackRepositoryObservation,
15118) -> Result<ContextPackRepository, NodeFailure> {
15119 if first != second {
15120 return Err(failure(
15121 NodeFailureCode::ContextPackMaterializationFailed,
15122 "context repository changed during bounded capture",
15123 ));
15124 }
15125 Ok(ContextPackRepository::from_sources(
15126 &second.git,
15127 second.files,
15128 ))
15129}
15130
15131fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15138 if Instant::now() < deadline {
15139 return false;
15140 }
15141 append_git_diagnostic(
15142 snapshot,
15143 &format!("{stage} skipped: workspace inspection time budget exhausted"),
15144 );
15145 true
15146}
15147
15148async fn run_git_bounded_with_deadline(
15157 root: &str,
15158 arguments: &[&str],
15159 output_limit: usize,
15160 deadline: Instant,
15161) -> io::Result<GitCommandOutput> {
15162 let remaining = deadline.checked_duration_since(Instant::now());
15163 let timeout_ms = remaining
15164 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15165 .unwrap_or(0)
15166 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15167 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15168}
15169
15170async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15177 let mut snapshot = GitSnapshot {
15178 is_repository: false,
15179 branch: None,
15180 status: Vec::new(),
15181 recent_commits: Vec::new(),
15182 worktrees: Vec::new(),
15183 managed_worktree: None,
15184 truncated: false,
15185 diagnostic: None,
15186 };
15187 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15188 return (snapshot, true);
15189 }
15190 let repository = match run_git_bounded_with_deadline(
15191 root,
15192 &["rev-parse", "--is-inside-work-tree"],
15193 4 * 1_024,
15194 deadline,
15195 )
15196 .await
15197 {
15198 Ok(output) => output,
15199 Err(error) => {
15200 append_git_diagnostic(
15201 &mut snapshot,
15202 &format!("git inspection unavailable: {error}"),
15203 );
15204 return (snapshot, false);
15205 }
15206 };
15207 snapshot.truncated |= repository.truncated;
15208 if repository.timed_out {
15209 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15210 return (snapshot, false);
15211 }
15212 if !repository.success
15213 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15214 {
15215 let stderr = String::from_utf8_lossy(&repository.stderr);
15216 if !stderr.to_ascii_lowercase().contains("not a git repository") {
15217 append_git_diagnostic(&mut snapshot, stderr.trim());
15218 }
15219 return (snapshot, false);
15220 }
15221 snapshot.is_repository = true;
15222
15223 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15224 return (snapshot, true);
15225 }
15226 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15227 Ok(output) => {
15228 snapshot.truncated |= output.truncated;
15229 if output.timed_out {
15230 append_git_diagnostic(&mut snapshot, "git branch query timed out");
15231 } else if output.success {
15232 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15233 if !branch.is_empty() {
15234 snapshot.branch = Some(truncate_git_field(
15235 &branch,
15236 MAX_REPOSITORY_PATH_BYTES,
15237 &mut snapshot.truncated,
15238 ));
15239 }
15240 }
15241 }
15242 Err(error) => append_git_diagnostic(
15243 &mut snapshot,
15244 &format!("git branch query failed: {error}"),
15245 ),
15246 }
15247 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15248 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15249 snapshot.truncated |= output.truncated;
15250 if output.success && !output.timed_out {
15251 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15252 if !head.is_empty() {
15253 snapshot.branch = Some(format!("detached:{head}"));
15254 }
15255 }
15256 }
15257 }
15258
15259 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15260 return (snapshot, true);
15261 }
15262 match run_git_bounded_with_deadline(
15263 root,
15264 &[
15265 "status",
15266 "--porcelain=v1",
15267 "-z",
15268 "--renames",
15269 "--untracked-files=normal",
15270 "--",
15271 ".",
15272 ],
15273 GIT_OUTPUT_MAX_BYTES,
15274 deadline,
15275 )
15276 .await
15277 {
15278 Ok(output) => {
15279 snapshot.truncated |= output.truncated;
15280 if output.timed_out {
15281 append_git_diagnostic(&mut snapshot, "git status timed out");
15282 } else if output.success {
15283 parse_git_status(&output.stdout, &mut snapshot);
15284 } else {
15285 append_git_diagnostic(
15286 &mut snapshot,
15287 String::from_utf8_lossy(&output.stderr).trim(),
15288 );
15289 }
15290 }
15291 Err(error) => append_git_diagnostic(
15292 &mut snapshot,
15293 &format!("git status failed: {error}"),
15294 ),
15295 }
15296
15297 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15298 return (snapshot, true);
15299 }
15300 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15301 match run_git_bounded_with_deadline(
15302 root,
15303 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15304 16 * 1_024,
15305 deadline,
15306 )
15307 .await
15308 {
15309 Ok(output) => {
15310 snapshot.truncated |= output.truncated;
15311 if output.timed_out {
15312 append_git_diagnostic(&mut snapshot, "git log timed out");
15313 } else if output.success {
15314 parse_git_commits(&output.stdout, &mut snapshot);
15315 } else {
15316 let stderr = String::from_utf8_lossy(&output.stderr);
15317 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15318 append_git_diagnostic(&mut snapshot, stderr.trim());
15319 }
15320 }
15321 }
15322 Err(error) => append_git_diagnostic(
15323 &mut snapshot,
15324 &format!("git log failed: {error}"),
15325 ),
15326 }
15327
15328 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15329 return (snapshot, true);
15330 }
15331 match list_git_worktrees_with_deadline(root, deadline).await {
15332 Ok(worktrees) => {
15333 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15334 }
15335 Err(error) => append_git_diagnostic(
15336 &mut snapshot,
15337 &format!("git worktree list failed: {}", error.message),
15338 ),
15339 }
15340 (snapshot, false)
15341}
15342
15343fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15344 let mut remaining = output;
15345 while !remaining.is_empty() {
15346 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15347 snapshot.truncated = true;
15348 break;
15349 }
15350 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15351 snapshot.truncated = true;
15352 break;
15353 };
15354 let record = &remaining[..record_end];
15355 remaining = &remaining[record_end + 1..];
15356 let has_previous_path = record
15357 .get(..2)
15358 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15359 let previous_path_bytes = if has_previous_path {
15360 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15361 snapshot.truncated = true;
15362 break;
15363 };
15364 let previous = &remaining[..previous_end];
15365 remaining = &remaining[previous_end + 1..];
15366 Some(previous)
15367 } else {
15368 None
15369 };
15370 if record.len() < 4
15371 || record[2] != b' '
15372 || !is_git_status_code(record[0])
15373 || !is_git_status_code(record[1])
15374 {
15375 snapshot.truncated = true;
15376 continue;
15377 }
15378 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15379 snapshot.truncated = true;
15380 continue;
15381 };
15382 let previous_path = match previous_path_bytes {
15383 Some(previous) => {
15384 let Some(previous) = windows_repository_path_from_bytes(previous) else {
15385 snapshot.truncated = true;
15386 continue;
15387 };
15388 Some(previous)
15389 }
15390 None => None,
15391 };
15392 snapshot.status.push(GitStatusEntry {
15393 index_status: (record[0] as char).to_string(),
15394 worktree_status: (record[1] as char).to_string(),
15395 path,
15396 previous_path,
15397 });
15398 }
15399}
15400
15401fn is_git_status_code(byte: u8) -> bool {
15402 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15403}
15404
15405fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15406 for line in String::from_utf8_lossy(output).lines() {
15407 let Some((id, summary)) = line.split_once('\u{1f}') else {
15408 continue;
15409 };
15410 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15411 snapshot.truncated = true;
15412 break;
15413 }
15414 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15415 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15416 snapshot.recent_commits.push(GitCommitSummary { id, summary });
15417 }
15418}
15419
15420fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15421 if value.len() <= max_bytes {
15422 return value.to_owned();
15423 }
15424 *truncated = true;
15425 let mut boundary = max_bytes;
15426 while boundary > 0 && !value.is_char_boundary(boundary) {
15427 boundary -= 1;
15428 }
15429 value[..boundary].to_owned()
15430}
15431
15432fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15433 if message.is_empty() {
15434 return;
15435 }
15436 let normalized = message
15437 .chars()
15438 .map(|character| if character.is_control() { ' ' } else { character })
15439 .collect::<String>();
15440 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15441 if !diagnostic.is_empty() {
15442 diagnostic.push_str("; ");
15443 }
15444 diagnostic.push_str(normalized.trim());
15445 snapshot.diagnostic = Some(truncate_git_field(
15446 &diagnostic,
15447 GIT_DIAGNOSTIC_MAX_BYTES,
15448 &mut snapshot.truncated,
15449 ));
15450}
15451
15452async fn run_git_bounded(
15453 root: &str,
15454 arguments: &[&str],
15455 output_limit: usize,
15456) -> io::Result<GitCommandOutput> {
15457 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15458}
15459
15460async fn read_git_history_bounded(
15461 root: &str,
15462 path: Option<&RepositoryPath>,
15463 before: Option<&GitObjectId>,
15464 limit: u16,
15465) -> Result<GitHistoryPage, NodeFailure> {
15466 let path = path
15467 .map(|path| {
15468 path.as_utf8().ok_or_else(|| {
15469 failure(
15470 NodeFailureCode::InvalidRepositoryPath,
15471 "git history path must use UTF-8",
15472 )
15473 })
15474 })
15475 .transpose()?
15476 .unwrap_or(".");
15477 let count = (usize::from(limit) + 1).to_string();
15478 let start = before
15479 .map(|revision| format!("{}^", revision.as_str()))
15480 .unwrap_or_else(|| "HEAD".to_owned());
15481 let arguments = [
15482 "--no-pager",
15483 "log",
15484 "--no-decorate",
15485 "--date=iso-strict",
15486 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15487 "-n",
15488 count.as_str(),
15489 start.as_str(),
15490 "--",
15491 path,
15492 ];
15493 let output = run_git_bounded(root, &arguments, 256 * 1_024)
15494 .await
15495 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15496 if output.timed_out {
15497 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15498 }
15499 if !output.success {
15500 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15501 }
15502 let mut commits = parse_git_history(&output.stdout);
15503 let has_more = commits.len() > usize::from(limit) || output.truncated;
15504 commits.truncate(usize::from(limit));
15505 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15506 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15507}
15508
15509fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15510 String::from_utf8_lossy(output)
15511 .split('\u{1e}')
15512 .filter_map(|record| {
15513 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15514 if fields.len() != 9 {
15515 return None;
15516 }
15517 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15518 let parents = if fields[1].is_empty() {
15519 Vec::new()
15520 } else {
15521 fields[1]
15522 .split(' ')
15523 .map(|value| GitObjectId::new(value.to_owned()))
15524 .collect::<Result<Vec<_>, _>>()
15525 .ok()?
15526 };
15527 Some(GitCommitDetails {
15528 id,
15529 parents,
15530 subject: bounded_git_text(fields[2], 512),
15531 author_name: bounded_git_text(fields[3], 256),
15532 author_email: bounded_git_text(fields[4], 320),
15533 authored_at: bounded_git_text(fields[5], 64),
15534 committer_name: bounded_git_text(fields[6], 256),
15535 committer_email: bounded_git_text(fields[7], 320),
15536 committed_at: bounded_git_text(fields[8], 64),
15537 signature_status: GitSignatureStatus::NoSignature,
15538 signer: None,
15539 })
15540 })
15541 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15542 .collect()
15543}
15544
15545fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15546 let normalized = value
15547 .chars()
15548 .map(|character| if character.is_control() { ' ' } else { character })
15549 .collect::<String>();
15550 if normalized.len() <= max_bytes {
15551 return normalized;
15552 }
15553 let mut boundary = max_bytes;
15554 while boundary > 0 && !normalized.is_char_boundary(boundary) {
15555 boundary -= 1;
15556 }
15557 normalized[..boundary].to_owned()
15558}
15559
15560async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15561 let path = request.path.as_ref().map(|path| {
15562 path.as_utf8()
15563 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15564 }).transpose()?;
15565 let mut arguments = match &request.mode {
15566 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15567 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15568 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15569 };
15570 if let GitDiffMode::Commit { revision } = &request.mode {
15571 arguments.push(revision.as_str());
15572 }
15573 arguments.push("--");
15574 if let Some(path) = path {
15575 arguments.push(path);
15576 }
15577 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15578 .await
15579 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15580 if output.timed_out {
15581 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15582 }
15583 if !output.success {
15584 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15585 }
15586 Ok(GitDiff {
15587 mode: request.mode,
15588 path: request.path,
15589 text: String::from_utf8_lossy(&output.stdout).into_owned(),
15590 truncated: output.truncated,
15591 })
15592}
15593
15594async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15595 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15596 ResponseEnvelope { request_id: envelope.request_id, result }
15597}
15598
15599async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15600 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15601 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15602 }
15603 let read_only = matches!(
15604 &request,
15605 NodeRequest::Snapshot
15606 | NodeRequest::Resync { .. }
15607 | NodeRequest::BrowseHostDirectories { .. }
15608 | NodeRequest::InspectWorkspace { .. }
15609 | NodeRequest::ReadWorkspaceFile { .. }
15610 | NodeRequest::ReadGitHistory { .. }
15611 | NodeRequest::ReadGitDiff { .. }
15612 | NodeRequest::CatalogNativeSessions { .. }
15613 | NodeRequest::PageNativeSessions { .. }
15614 | NodeRequest::PreviewNativeSession { .. }
15615 | NodeRequest::PreviewSessionRecord { .. }
15616 );
15617 let _mutation_guard = if read_only {
15618 None
15619 } else {
15620 let guard = shared.mutation_gate.lock().await;
15621 if shared.shutdown.load(Ordering::Acquire) {
15622 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
15623 }
15624 Some(guard)
15625 };
15626 match request {
15627 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
15628 event_sequence: shared.current_sequence(),
15629 controller: shared.controller_state(),
15630 snapshot: shared.snapshot(),
15631 }),
15632 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
15633 NodeRequest::ArmHarnessMcpReservation {
15634 reservation_id,
15635 activation_digest,
15636 spawn_spec,
15637 launch,
15638 expires_at_unix_ms,
15639 } => {
15640 shared.require_controller(connection_id, role)?;
15641 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15642 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15643 })?;
15644 let expires_at_unix_ms = registry
15645 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
15646 .await
15647 .map_err(harness_mcp_failure)?;
15648 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
15649 }
15650 NodeRequest::SpawnSpecWithHarnessMcp {
15651 reservation_id,
15652 activation_digest,
15653 spec,
15654 deadline_unix_ms,
15655 } => {
15656 shared.require_controller(connection_id, role)?;
15657 let receipt = shared
15658 .spawn_from_spec_with_harness_mcp(
15659 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
15660 )
15661 .await?;
15662 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
15663 }
15664 NodeRequest::ActivateHarnessMcpReservation {
15665 reservation_id,
15666 activation_digest,
15667 record_id,
15668 session,
15669 } => {
15670 shared.require_controller(connection_id, role)?;
15671 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
15672 current.instance_id == session.session.instance_id
15673 && current.generation == session.session.generation
15674 }).and_then(|current| current.process_id).ok_or_else(|| {
15675 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
15676 })?;
15677 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15678 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15679 })?.activate(
15680 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
15681 ).map_err(harness_mcp_failure)?;
15682 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
15683 }
15684 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
15685 shared.require_controller(connection_id, role)?;
15686 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15687 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15688 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
15689 if let Some(instance_id) = instance_id {
15690 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
15691 }
15692 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
15693 }
15694 NodeRequest::PutHarnessMcpReplyChunk {
15695 reservation_id, activation_digest, record_id, session, call_id,
15696 offset, final_chunk, chunk_hex,
15697 } => {
15698 shared.require_controller(connection_id, role)?;
15699 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15700 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15701 })?.put_reply_chunk(
15702 &reservation_id, &activation_digest, &record_id, &session, &call_id,
15703 offset, final_chunk, &chunk_hex,
15704 ).map_err(harness_mcp_failure)?;
15705 Ok(NodeResponse::ReplyChunkAccepted {
15706 reservation_id, activation_digest, record_id, session, call_id,
15707 next_offset, completed,
15708 })
15709 }
15710 NodeRequest::RejectHarnessMcpCall {
15711 reservation_id, activation_digest, record_id, session, call_id, reason,
15712 } => {
15713 shared.require_controller(connection_id, role)?;
15714 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15715 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15716 })?.reject_call(
15717 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
15718 ).map_err(harness_mcp_failure)?;
15719 Ok(NodeResponse::CallRejected {
15720 reservation_id, activation_digest, record_id, session, call_id,
15721 })
15722 }
15723 NodeRequest::BeginDeliveryStage { manifest } => {
15724 shared.require_controller(connection_id, role)?;
15725 shared.begin_delivery_stage(manifest)
15726 }
15727 NodeRequest::PutDeliveryBlobChunk {
15728 stage_id,
15729 blob_digest,
15730 offset,
15731 chunk_hex,
15732 } => {
15733 shared.require_controller(connection_id, role)?;
15734 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
15735 }
15736 NodeRequest::CommitDeliveryStage { stage_id } => {
15737 shared.require_controller(connection_id, role)?;
15738 let receipt = shared.commit_delivery_stage(stage_id)?;
15739 Ok(NodeResponse::DeliveryCommitted { receipt })
15740 }
15741 NodeRequest::AbortDeliveryStage { stage_id } => {
15742 shared.require_controller(connection_id, role)?;
15743 shared.abort_delivery_stage(&stage_id)?;
15744 Ok(NodeResponse::DeliveryStageAborted { stage_id })
15745 }
15746 NodeRequest::BrowseHostDirectories { directory, after } => {
15747 let listing = shared.browse_host_directories(directory, after).await?;
15748 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
15749 }
15750 NodeRequest::InspectWorkspace { workspace_id } => {
15751 let inspection = shared.inspect_workspace(workspace_id).await?;
15752 Ok(NodeResponse::WorkspaceInspected { inspection })
15753 }
15754 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
15755 let file = shared.read_workspace_file(workspace_id, path).await?;
15756 Ok(NodeResponse::WorkspaceFileRead { file })
15757 }
15758 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
15759 shared.require_controller(connection_id, role)?;
15760 let file = shared
15761 .write_workspace_file(workspace_id, path, expected_revision, text)
15762 .await?;
15763 Ok(NodeResponse::WorkspaceFileWritten { file })
15764 }
15765 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
15766 shared.require_controller(connection_id, role)?;
15767 let file = shared.create_workspace_file(workspace_id, path).await?;
15768 Ok(NodeResponse::WorkspaceFileCreated { file })
15769 }
15770 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
15771 shared.require_controller(connection_id, role)?;
15772 let entry = shared
15773 .create_workspace_directory(workspace_id.clone(), path)
15774 .await?;
15775 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
15776 }
15777 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
15778 if role != ClientRole::Operator {
15779 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
15780 }
15781 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
15782 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
15783 }
15784 let page = shared
15785 .read_git_history(workspace_id.clone(), path, before, limit)
15786 .await?;
15787 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
15788 }
15789 NodeRequest::ReadGitDiff { workspace_id, request } => {
15790 if role != ClientRole::Operator {
15791 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
15792 }
15793 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
15794 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
15795 }
15796 NodeRequest::CatalogNativeSessions { route, limit } => {
15797 if role != ClientRole::Operator {
15798 return Err(failure(
15799 NodeFailureCode::ObserverReadOnly,
15800 "native session catalog requires an operator connection",
15801 ));
15802 }
15803 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
15804 return Err(failure(
15805 NodeFailureCode::InvalidRequest,
15806 "native session catalog limit is invalid",
15807 ));
15808 }
15809 let (entries, summary) = shared
15810 .catalog_native_sessions(route.clone(), limit)
15811 .await?;
15812 Ok(NodeResponse::NativeSessionsCataloged {
15813 route,
15814 entries,
15815 summary: Some(summary),
15816 })
15817 }
15818 NodeRequest::PageNativeSessions {
15819 route,
15820 window,
15821 catalog_revision,
15822 recent_cutoff_unix_ms,
15823 after_selection_id,
15824 limit,
15825 } => {
15826 if role != ClientRole::Operator {
15827 return Err(failure(
15828 NodeFailureCode::ObserverReadOnly,
15829 "native session catalog paging requires an operator connection",
15830 ));
15831 }
15832 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
15833 || after_selection_id
15834 .as_deref()
15835 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
15836 {
15837 return Err(failure(
15838 NodeFailureCode::InvalidRequest,
15839 "native session catalog page request is invalid",
15840 ));
15841 }
15842 let page = shared
15843 .page_native_sessions(
15844 route.clone(),
15845 window,
15846 catalog_revision,
15847 recent_cutoff_unix_ms,
15848 after_selection_id,
15849 limit,
15850 )
15851 .await?;
15852 Ok(NodeResponse::NativeSessionsPaged {
15853 route,
15854 page,
15855 })
15856 }
15857 NodeRequest::PreviewNativeSession {
15858 selection,
15859 message_limit,
15860 } => {
15861 if role != ClientRole::Operator {
15862 return Err(failure(
15863 NodeFailureCode::ObserverReadOnly,
15864 "native session preview requires an operator connection",
15865 ));
15866 }
15867 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15868 .contains(&message_limit)
15869 || selection.validate().is_err()
15870 {
15871 return Err(failure(
15872 NodeFailureCode::InvalidRequest,
15873 "native session preview request is invalid",
15874 ));
15875 }
15876 let preview = shared
15877 .preview_native_session(selection.clone(), message_limit)
15878 .await?;
15879 Ok(NodeResponse::NativeSessionPreviewed {
15880 selection,
15881 preview,
15882 })
15883 }
15884 NodeRequest::IndexNativeSession {
15885 selection,
15886 display_name,
15887 } => {
15888 shared.require_controller(connection_id, role)?;
15889 let record = shared
15890 .index_native_session(selection.clone(), display_name)
15891 .await?;
15892 Ok(NodeResponse::NativeSessionIndexed { selection, record })
15893 }
15894 NodeRequest::PreviewSessionRecord {
15895 record_id,
15896 message_limit,
15897 } => {
15898 if role != ClientRole::Operator {
15899 return Err(failure(
15900 NodeFailureCode::ObserverReadOnly,
15901 "native session preview requires an operator connection",
15902 ));
15903 }
15904 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15905 .contains(&message_limit)
15906 {
15907 return Err(failure(
15908 NodeFailureCode::InvalidRequest,
15909 "native session preview request is invalid",
15910 ));
15911 }
15912 let record = shared.record(&record_id)?;
15913 let working_directory = shared.workspace_root(&record.workspace_id)?;
15914 if !platform::roots_equal(
15915 &working_directory,
15916 windows_path_text(&record.canonical_root),
15917 ) {
15918 return Err(failure(
15919 NodeFailureCode::SessionRecordConflict,
15920 "managed session workspace root changed; refusing to preview another directory",
15921 ));
15922 }
15923 let identity = record.provider_session.clone().ok_or_else(|| {
15924 failure(
15925 NodeFailureCode::BackendOperationFailed,
15926 "native session preview is unavailable for this session record",
15927 )
15928 })?;
15929 if identity.key != ProviderSessionKey::SessionId {
15930 return Err(failure(
15931 NodeFailureCode::BackendOperationFailed,
15932 "native session preview is unavailable for this session identity",
15933 ));
15934 }
15935 let preview = shared
15936 .preview_native_session_by_session_id(
15937 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
15938 )
15939 .await?;
15940 if preview.session_id != identity.id {
15941 return Err(failure(
15942 NodeFailureCode::SessionRecordConflict,
15943 "native session preview identity changed while loading",
15944 ));
15945 }
15946 shared.revalidate_session_record_preview(&record, &identity)?;
15947 let preview: gate4agent_types::SessionRecordPreview = preview.into();
15948 shared.publish(NodeEvent::SessionRecordHistorySummarized {
15949 record_id: record_id.clone(),
15950 summary: SessionHistorySummaryV1::from(&preview),
15951 });
15952 Ok(NodeResponse::SessionRecordPreviewed {
15953 record_id,
15954 preview,
15955 })
15956 }
15957 NodeRequest::AcquireController { lease_ms } => {
15958 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
15959 Ok(NodeResponse::Controller { controller: Some(controller) })
15960 }
15961 NodeRequest::ReleaseController => {
15962 shared.release_controller(connection_id);
15963 Ok(NodeResponse::Controller { controller: shared.controller_state() })
15964 }
15965 NodeRequest::RegisterWorkspace { workspace_id, root } => {
15966 shared.require_controller(connection_id, role)?;
15967 let root = require_windows_path(root)?;
15968 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
15969 let workspace = shared.register_workspace(workspace_id, root).await?;
15970 Ok(NodeResponse::WorkspaceRegistered { workspace })
15971 }
15972 NodeRequest::CreateStandaloneWorkspace {
15973 workspace_id,
15974 root,
15975 initial_branch,
15976 } => {
15977 shared.require_controller(connection_id, role)?;
15978 let root = require_windows_path(root)?;
15979 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
15980 let workspace = shared
15981 .create_standalone_workspace(workspace_id, root, initial_branch)
15982 .await?;
15983 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
15984 }
15985 NodeRequest::UnregisterWorkspace { workspace_id } => {
15986 shared.require_controller(connection_id, role)?;
15987 shared.reject_managed_reservation(Some(&workspace_id), None)?;
15988 shared.unregister_workspace(&workspace_id)?;
15989 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
15990 }
15991 NodeRequest::CreateWorktree {
15992 source_workspace_id,
15993 workspace_id,
15994 target_root,
15995 branch,
15996 base,
15997 } => {
15998 shared.require_controller(connection_id, role)?;
15999 let target_root = require_windows_path(target_root)?;
16000 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16001 let (worktree, workspace) = shared
16002 .create_worktree(
16003 source_workspace_id,
16004 workspace_id,
16005 target_root,
16006 branch,
16007 base,
16008 )
16009 .await?;
16010 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16011 }
16012 NodeRequest::RemoveWorktree {
16013 source_workspace_id,
16014 target_root,
16015 } => {
16016 shared.require_controller(connection_id, role)?;
16017 let native_target_root = require_windows_path(target_root.clone())?;
16018 shared.reject_managed_reservation(None, Some(&native_target_root))?;
16019 let workspace_id = shared
16020 .remove_worktree(source_workspace_id, native_target_root)
16021 .await?;
16022 Ok(NodeResponse::WorktreeRemoved {
16023 target_root,
16024 workspace_id,
16025 })
16026 }
16027 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16028 shared.require_controller(connection_id, role)?;
16029 if !terminal_size.is_valid() {
16030 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16031 }
16032 if let Some(prompt) = initial_prompt.as_deref() {
16033 validate_node_text("spawn initial prompt", prompt)?;
16034 }
16035 let session = shared
16036 .spawn_session(workspace_id, provider, mode, terminal_size, initial_prompt)
16037 .await?;
16038 Ok(NodeResponse::SpawnAccepted { session })
16039 }
16040 NodeRequest::SpawnSpec { spec } => {
16041 shared.require_controller(connection_id, role)?;
16042 let receipt = shared.spawn_from_spec(spec).await?;
16043 Ok(NodeResponse::SpawnSpecAccepted { receipt })
16044 }
16045 NodeRequest::SpawnManagedWorktree { request } => {
16046 shared.require_controller(connection_id, role)?;
16047 let receipt = shared.spawn_managed_worktree(request).await?;
16048 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16049 }
16050 NodeRequest::SpawnManagedWorktreeV2 { request } => {
16051 shared.require_controller(connection_id, role)?;
16052 let receipt = shared.spawn_managed_worktree_v2(request).await?;
16053 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16054 }
16055 NodeRequest::CleanupManagedWorktree { lease_id } => {
16056 shared.require_controller(connection_id, role)?;
16057 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16058 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16059 }
16060 NodeRequest::DiscoverHistory { session, limit } => {
16061 controlled_session(shared, connection_id, role, &session)?;
16062 let candidates = shared.discover_history(&session, limit).await?;
16063 Ok(NodeResponse::HistoryDiscovered {
16064 session,
16065 candidates,
16066 })
16067 }
16068 NodeRequest::LoadHistory {
16069 session,
16070 candidate_id,
16071 } => {
16072 controlled_session(shared, connection_id, role, &session)?;
16073 let loaded = shared
16074 .load_history(&session, candidate_id)
16075 .await?;
16076 Ok(NodeResponse::HistoryLoaded {
16077 session,
16078 session_id: loaded.session_id,
16079 message_count: loaded.message_count,
16080 completed_turn_count: loaded.completed_turn_count,
16081 })
16082 }
16083 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16084 shared.require_controller(connection_id, role)?;
16085 let context = shared
16086 .export_context_pack_for_session_record(&record_id, &session, false)
16087 .await?;
16088 Ok(NodeResponse::ContextPackForSessionRecordExported {
16089 record_id,
16090 session,
16091 context,
16092 })
16093 }
16094 NodeRequest::ExportContextPack { session } => {
16095 controlled_session(shared, connection_id, role, &session)?;
16096 let context = shared.export_context_pack(&session).await?;
16097 Ok(NodeResponse::ContextPackExported { context })
16098 }
16099 NodeRequest::ForgetContextPack { context_id } => {
16100 shared.require_controller(connection_id, role)?;
16101 shared.forget_context_pack(&context_id)?;
16102 Ok(NodeResponse::ContextPackForgotten { context_id })
16103 }
16104 NodeRequest::ResolveDurableContextPack { context_id } => {
16105 let context = shared
16109 .context_catalog
16110 .read()
16111 .unwrap_or_else(|poisoned| poisoned.into_inner())
16112 .get(&context_id)
16113 .map(|pack| pack.receipt().clone())
16114 .ok_or_else(|| {
16115 failure(
16116 NodeFailureCode::UnknownContextPack,
16117 "context pack is not present",
16118 )
16119 })?;
16120 Ok(NodeResponse::DurableContextPackResolved { context })
16121 }
16122 NodeRequest::ReadContextPack { digest } => {
16123 let (id, byte_len, bytes) = shared
16128 .context_catalog
16129 .read()
16130 .unwrap_or_else(|poisoned| poisoned.into_inner())
16131 .get_by_digest(&digest)
16132 .map(|pack| {
16133 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16134 })
16135 .ok_or_else(|| {
16136 failure(
16137 NodeFailureCode::UnknownContextPack,
16138 &format!("context pack {digest} not on this node"),
16139 )
16140 })?;
16141 tracing::debug!(
16142 digest = digest.as_str(),
16143 byte_len,
16144 "read context pack bytes",
16145 );
16146 Ok(NodeResponse::ContextPackBytesRead {
16147 pack: ContextPackBytesRead {
16148 digest,
16149 id,
16150 byte_len,
16151 bytes,
16152 },
16153 })
16154 }
16155 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16156 let provider = controlled_session(shared, connection_id, role, &session)?;
16157 if let Some(prompt) = initial_prompt.as_deref() {
16158 validate_node_text("resume initial prompt", prompt)?;
16159 }
16160 let runtime_requirement = if initial_prompt.is_some() {
16161 ProviderRuntimeRequirement::ResumeWithPrompt
16162 } else {
16163 ProviderRuntimeRequirement::Resume
16164 };
16165 shared.require_session_runtime_policy(&session, runtime_requirement)?;
16166 let runtime_policy = shared
16167 .admit_provider_runtime(&provider, runtime_requirement)
16168 .await?;
16169 let working_directory = shared.bound_workspace_root(&session)?;
16170 let request = ResumeLaunchRequest {
16171 working_directory,
16172 terminal_size,
16173 initial_prompt,
16174 };
16175 request
16176 .validate()
16177 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16178 let command = shared.prepare_command(ControlCommand::Resume {
16179 instance_id: session.session.instance_id,
16180 target: ResumeTarget::CurrentProvider,
16181 runtime_policy,
16182 request,
16183 });
16184 shared.arm_resume(&session, command.id, runtime_policy)?;
16185 let dispatch = shared.dispatch_envelope(command);
16186 if let Err(error) = dispatch {
16187 shared.clear_armed_resume(&session);
16188 return Err(error);
16189 }
16190 Ok(NodeResponse::Accepted)
16191 }
16192 NodeRequest::RenameSessionRecord {
16193 record_id,
16194 display_name,
16195 } => {
16196 shared.require_controller(connection_id, role)?;
16197 let record = shared.rename_session_record(&record_id, display_name)?;
16198 Ok(NodeResponse::SessionRecordUpdated { record })
16199 }
16200 NodeRequest::SetSessionTask {
16201 record_id,
16202 expected_revision,
16203 target,
16204 } => {
16205 shared.require_controller(connection_id, role)?;
16206 let record = shared.set_session_task(&record_id, expected_revision, target)?;
16207 Ok(NodeResponse::SessionRecordUpdated { record })
16208 }
16209 NodeRequest::IndexProviderSession {
16210 workspace_id,
16211 provider,
16212 identity,
16213 display_name,
16214 } => {
16215 shared.require_controller(connection_id, role)?;
16216 let record = shared.index_provider_session(
16217 workspace_id,
16218 provider,
16219 identity,
16220 display_name,
16221 )?;
16222 Ok(NodeResponse::ProviderSessionIndexed { record })
16223 }
16224 NodeRequest::ResumeSessionRecord {
16225 record_id,
16226 terminal_size,
16227 initial_prompt,
16228 } => {
16229 shared.require_controller(connection_id, role)?;
16230 if let Some(prompt) = initial_prompt.as_deref() {
16231 validate_node_text("managed resume initial prompt", prompt)?;
16232 }
16233 let (record, session) = shared
16234 .resume_session_record(
16235 &record_id,
16236 terminal_size,
16237 initial_prompt,
16238 )
16239 .await?;
16240 Ok(NodeResponse::SessionRecordResumed { record, session })
16241 }
16242 NodeRequest::ForgetSessionRecord { record_id } => {
16243 shared.require_controller(connection_id, role)?;
16244 shared.forget_session_record(&record_id).await?;
16245 Ok(NodeResponse::SessionRecordForgotten { record_id })
16246 }
16247 NodeRequest::Prompt { session, text } => {
16248 let agent_id = controlled_session(shared, connection_id, role, &session)?;
16249 validate_node_text("prompt", &text)?;
16250 shared.require_session_runtime_policy(
16251 &session,
16252 ProviderRuntimeRequirement::SemanticPrompt,
16253 )?;
16254 let framing = prompt_framing(&agent_id);
16255 shared
16256 .dispatch_input_bounded(
16257 &session,
16258 InputAction::SubmitPrompt(PromptPayload { text, framing }),
16259 )
16260 .await?;
16261 Ok(NodeResponse::Accepted)
16262 }
16263 NodeRequest::Paste { session, text } => {
16264 controlled_session(shared, connection_id, role, &session)?;
16265 validate_node_text("paste", &text)?;
16266 shared.require_session_runtime_policy(
16267 &session,
16268 ProviderRuntimeRequirement::SemanticPrompt,
16269 )?;
16270 shared
16271 .dispatch_input_bounded(
16272 &session,
16273 InputAction::InsertDraft(PromptPayload {
16274 text,
16275 framing: PromptFraming::BracketedPaste,
16276 }),
16277 )
16278 .await?;
16279 Ok(NodeResponse::Accepted)
16280 }
16281 NodeRequest::Input { session, text } => {
16282 controlled_session(shared, connection_id, role, &session)?;
16283 validate_node_text("terminal input", &text)?;
16284 shared
16285 .dispatch_input_bounded(
16286 &session,
16287 InputAction::TerminalText(TerminalText { text }),
16288 )
16289 .await?;
16290 Ok(NodeResponse::Accepted)
16291 }
16292 NodeRequest::TerminalBytes { session, bytes } => {
16293 controlled_session(shared, connection_id, role, &session)?;
16294 let action = terminal_bytes_action(bytes)?;
16295 shared
16296 .dispatch_input_bounded(&session, action)
16297 .await?;
16298 Ok(NodeResponse::Accepted)
16299 }
16300 NodeRequest::TerminalControl { session, control } => {
16301 controlled_session(shared, connection_id, role, &session)?;
16302 shared
16303 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16304 .await?;
16305 Ok(NodeResponse::Accepted)
16306 }
16307 NodeRequest::Resize { session, size } => {
16308 controlled_session(shared, connection_id, role, &session)?;
16309 if !size.is_valid() {
16310 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16311 }
16312 shared.dispatch_resize_bounded(&session, size).await?;
16313 Ok(NodeResponse::Accepted)
16314 }
16315 NodeRequest::Interrupt { session } => {
16316 controlled_session(shared, connection_id, role, &session)?;
16317 shared
16318 .dispatch_input_bounded(
16319 &session,
16320 InputAction::TerminalControl(TerminalControl::Interrupt),
16321 )
16322 .await?;
16323 Ok(NodeResponse::Accepted)
16324 }
16325 NodeRequest::Stop { session, force } => {
16326 controlled_session(shared, connection_id, role, &session)?;
16327 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16328 Ok(NodeResponse::Accepted)
16329 }
16330 NodeRequest::Remove { session } => {
16331 controlled_session(shared, connection_id, role, &session)?;
16332 shared.remove_session(&session).await?;
16333 Ok(NodeResponse::Accepted)
16334 }
16335 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16336 shared.require_controller(connection_id, role)?;
16337 let snapshot = shared.require_acp_session(&session)?;
16338 let interaction = snapshot
16339 .provider
16340 .interactions
16341 .iter()
16342 .find(|interaction| {
16343 interaction_correlation(
16344 session.session.instance_id,
16345 session.session.generation,
16346 interaction.id.0,
16347 ) == correlation_id
16348 })
16349 .ok_or_else(|| failure(
16350 NodeFailureCode::InvalidRequest,
16351 "unknown interaction correlation",
16352 ))?;
16353 if interaction.status != ProviderInteractionStatus::Pending {
16354 return Err(failure(
16355 NodeFailureCode::BackendOperationFailed,
16356 "interaction has already been resolved",
16357 ));
16358 }
16359 response
16360 .validate_for(interaction.interaction_kind)
16361 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16362 let interaction_id = interaction.id;
16363 let response_kind = response.kind();
16364 let response_outcome = response.outcome();
16365 shared.dispatch(ControlCommand::ResolveInteraction {
16366 instance_id: session.session.instance_id,
16367 generation: session.session.generation,
16368 interaction_id,
16369 response,
16370 })?;
16371 tracing::info!(
16377 node_id = %shared.node_id,
16378 session = ?session.session,
16379 correlation_id = %correlation_id,
16380 interaction_id = interaction_id.0,
16381 response_kind = ?response_kind,
16382 outcome = ?response_outcome,
16383 "interaction resolved",
16384 );
16385 Ok(NodeResponse::Accepted)
16386 }
16387 NodeRequest::SetSessionMode { session, mode_id } => {
16388 shared.require_controller(connection_id, role)?;
16389 shared.require_acp_session(&session)?;
16390 shared.dispatch(ControlCommand::SetSessionMode {
16391 instance_id: session.session.instance_id,
16392 mode_id,
16393 })?;
16394 Ok(NodeResponse::Accepted)
16395 }
16396 NodeRequest::SetSessionConfigOption {
16397 session,
16398 option_id,
16399 value_json,
16400 } => {
16401 shared.require_controller(connection_id, role)?;
16402 shared.require_acp_session(&session)?;
16403 shared.dispatch(ControlCommand::SetSessionConfigOption {
16404 instance_id: session.session.instance_id,
16405 option_id,
16406 value_json,
16407 })?;
16408 Ok(NodeResponse::Accepted)
16409 }
16410 NodeRequest::SetSessionModel { session, model_id } => {
16411 shared.require_controller(connection_id, role)?;
16412 shared.require_acp_session(&session)?;
16413 shared.dispatch(ControlCommand::SetSessionModel {
16414 instance_id: session.session.instance_id,
16415 model_id,
16416 })?;
16417 Ok(NodeResponse::Accepted)
16418 }
16419 NodeRequest::Shutdown => {
16420 shared.require_controller(connection_id, role)?;
16421 shared.begin_shutdown_locked().await?;
16422 Ok(NodeResponse::ShuttingDown)
16423 }
16424 }
16425}
16426
16427fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16428 shared.require_controller(connection_id, role)?;
16429 shared.validate_address(session)
16430}
16431
16432fn select_history_candidate(
16438 candidates: &[HistoryCandidateSummary],
16439 provider_session_id: Option<&str>,
16440) -> Result<String, NodeFailure> {
16441 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16442 Some(id) => candidate.session_id_hint == id,
16443 None => true,
16444 });
16445 let candidate = matches.next().ok_or_else(|| {
16446 failure(
16447 NodeFailureCode::SessionRecordConflict,
16448 if provider_session_id.is_some() {
16449 "no history candidate matches the managed provider session"
16450 } else {
16451 "no history candidate exists for the managed session"
16452 },
16453 )
16454 })?;
16455 if matches.next().is_some() {
16456 return Err(failure(
16457 NodeFailureCode::SessionRecordConflict,
16458 if provider_session_id.is_some() {
16459 "history identity is ambiguous for the managed provider session"
16460 } else {
16461 "history is ambiguous without a verified provider session identity"
16462 },
16463 ));
16464 }
16465 Ok(candidate.id.clone())
16466}
16467
16468fn resolved_spawn_profile_summary(
16469 profile: &SpawnProfileDefaults,
16470 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16471) -> Option<SpawnProfileSummary> {
16472 let environment_profile = match profile.environment_profile_id.as_ref() {
16473 None => None,
16474 Some(profile_id) => {
16475 let binding = environment_profiles.get(profile_id)?;
16476 if binding.provider != profile.provider
16477 || binding.native_profile_id(profile.mode).is_none()
16478 {
16479 return None;
16480 }
16481 Some(ResolvedEnvironmentProfileReceipt {
16482 profile_id: binding.id.clone(),
16483 profile_revision: binding.revision.clone(),
16484 network_allowlist: None,
16485 browser_profile_id: None,
16486 })
16487 }
16488 };
16489 Some(SpawnProfileSummary {
16490 id: profile.profile_id.clone(),
16491 revision: profile.revision.clone(),
16492 environment_profile,
16493 })
16494}
16495
16496fn session_record_export_target_matches(
16515 expected: &ManagedSessionRecord,
16516 current: &ManagedSessionRecord,
16517 session: &SessionAddress,
16518 allow_clean_detachment: bool,
16519) -> bool {
16520 if current == expected && current.active_session.as_ref() == Some(session) {
16521 return current.state == ManagedSessionState::Live;
16522 }
16523 allow_clean_detachment
16524 && current.state == ManagedSessionState::Dormant
16525 && current.active_session.is_none()
16526 && current.record_id == expected.record_id
16527 && current.display_name == expected.display_name
16528 && current.provider == expected.provider
16529 && current.mode == expected.mode
16530 && current.workspace_id == expected.workspace_id
16531 && current.canonical_root == expected.canonical_root
16532 && current.provider_session == expected.provider_session
16533 && current.environment_profile == expected.environment_profile
16534 && current.bundle == expected.bundle
16535 && current.context_id == expected.context_id
16536 && current.context == expected.context
16537 && current.exported_context == expected.exported_context
16538 && current.task_binding == expected.task_binding
16539 && current.created_at_unix_ms == expected.created_at_unix_ms
16540 && current.last_error == expected.last_error
16541}
16542
16543fn session_record_context_export_binding_is_exact(
16544 expected: &ManagedSessionRecord,
16545 current: &ManagedSessionRecord,
16546 identity: Option<&ProviderSessionIdentity>,
16547 session: &SessionAddress,
16548 binding: &SessionBinding,
16549 provider: &AgentId,
16550 current_root: &str,
16551 allow_clean_detachment: bool,
16552) -> bool {
16553 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
16554 && current.provider_session.as_ref() == identity
16555 && current.workspace_id == session.workspace_id
16556 && provider == ¤t.provider
16557 && binding.workspace_id == session.workspace_id
16558 && binding.generation == session.session.generation
16559 && binding.record_id.as_ref() == Some(¤t.record_id)
16560 && platform::roots_equal(
16561 current_root,
16562 windows_path_text(¤t.canonical_root),
16563 )
16564}
16565
16566fn session_record_context_export_source_is_usable(
16567 record: &ManagedSessionRecord,
16568 status: &SessionStatus,
16569 allow_clean_detachment: bool,
16570) -> bool {
16571 (record.state == ManagedSessionState::Live
16572 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
16573 && context_pack_source_status_is_usable(status)
16574}
16575
16576fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
16577 PromptFraming::BracketedPaste
16578}
16579
16580fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
16581 if text.len() > MAX_NODE_TEXT_BYTES {
16582 return Err(failure(
16583 NodeFailureCode::InvalidRequest,
16584 &format!(
16585 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
16586 text.len(),
16587 ),
16588 ));
16589 }
16590 Ok(())
16591}
16592
16593fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
16594 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
16595 return Err(failure(
16596 NodeFailureCode::InvalidRequest,
16597 &format!(
16598 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
16599 bytes.len(),
16600 ),
16601 ));
16602 }
16603 Ok(())
16604}
16605
16606fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
16607 validate_terminal_bytes(&bytes)?;
16608 Ok(InputAction::TerminalBytes(bytes))
16609}
16610
16611fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
16612 if root.is_empty()
16613 || root.len() > MAX_WORKSPACE_ROOT_BYTES
16614 || root.chars().any(char::is_control)
16615 {
16616 return Err(failure(
16617 NodeFailureCode::InvalidWorkspaceRoot,
16618 &format!(
16619 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
16620 ),
16621 ));
16622 }
16623 Ok(())
16624}
16625
16626fn canonical_standalone_workspace_candidate(
16627 workspace_id: &WorkspaceId,
16628 root: &str,
16629) -> Result<String, NodeServerError> {
16630 let path = Path::new(root);
16631 match std::fs::symlink_metadata(path) {
16632 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
16633 .map(|workspace| workspace.canonical_root),
16634 Err(error) if error.kind() == io::ErrorKind::NotFound => {
16635 if !path.is_absolute() {
16636 return Err(NodeServerError::InvalidWorkspaceRoot {
16637 workspace_id: workspace_id.clone(),
16638 path: root.to_owned(),
16639 message: "path is not absolute".to_owned(),
16640 });
16641 }
16642 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16643 workspace_id: workspace_id.clone(),
16644 path: root.to_owned(),
16645 message: "path has no standalone directory name".to_owned(),
16646 })?;
16647 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16648 workspace_id: workspace_id.clone(),
16649 path: root.to_owned(),
16650 message: "path has no parent directory".to_owned(),
16651 })?;
16652 let canonical = std::fs::canonicalize(parent)
16653 .map(|parent| parent.join(name))
16654 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
16655 workspace_id: workspace_id.clone(),
16656 path: root.to_owned(),
16657 message: error.to_string(),
16658 })?;
16659 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
16660 NodeServerError::InvalidWorkspaceRoot {
16661 workspace_id: workspace_id.clone(),
16662 path: path.to_string_lossy().into_owned(),
16663 message: "canonical path is not valid Unicode".to_owned(),
16664 }
16665 })?;
16666 let canonical_root = platform::normalize_canonical_root(canonical_root);
16667 validate_workspace_root(workspace_id, &canonical_root)?;
16668 Ok(canonical_root)
16669 }
16670 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
16671 workspace_id: workspace_id.clone(),
16672 path: root.to_owned(),
16673 message: error.to_string(),
16674 }),
16675 }
16676}
16677
16678fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
16679 let Some(revision) = revision else {
16680 return Ok(());
16681 };
16682 if revision.is_empty()
16683 || revision.len() > MAX_REPOSITORY_PATH_BYTES
16684 || revision.chars().any(char::is_control)
16685 {
16686 return Err(failure(
16687 NodeFailureCode::InvalidRequest,
16688 &format!(
16689 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
16690 ),
16691 ));
16692 }
16693 Ok(())
16694}
16695
16696fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16697 let code = match error.kind {
16698 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
16699 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
16700 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
16701 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
16702 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
16703 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
16704 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
16705 };
16706 failure(code, &error.message)
16707}
16708
16709fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
16710 let code = match error.kind {
16711 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
16712 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
16713 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
16714 NodeFailureCode::BackendOperationFailed
16715 }
16716 StandaloneWorkspaceErrorKind::RecoveryRequired => {
16717 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
16718 }
16719 };
16720 failure(code, error.message)
16721}
16722
16723fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16724 let (code, message) = match error.kind {
16725 GitWorktreeErrorKind::Invalid => (
16726 NodeFailureCode::InvalidRequest,
16727 "managed Git input is invalid",
16728 ),
16729 GitWorktreeErrorKind::NotRepository => (
16730 NodeFailureCode::NotGitRepository,
16731 "managed source is not a Git repository",
16732 ),
16733 GitWorktreeErrorKind::Conflict => (
16734 NodeFailureCode::WorktreeConflict,
16735 "managed Git identity conflicts with existing repository state",
16736 ),
16737 GitWorktreeErrorKind::Protected => (
16738 NodeFailureCode::ManagedWorktreeOwnershipConflict,
16739 "managed worktree ownership validation failed",
16740 ),
16741 GitWorktreeErrorKind::Dirty => (
16742 NodeFailureCode::WorktreeDirty,
16743 "managed worktree contains uncommitted changes",
16744 ),
16745 GitWorktreeErrorKind::Locked => (
16746 NodeFailureCode::WorktreeLocked,
16747 "managed worktree is locked",
16748 ),
16749 GitWorktreeErrorKind::Failed => (
16750 NodeFailureCode::BackendOperationFailed,
16751 "managed Git operation failed",
16752 ),
16753 };
16754 NodeFailure { code, message: message.to_owned() }
16755}
16756
16757fn managed_spawn_receipt(
16758 resolved: &ResolvedSpawnSpec,
16759 incarnation_id: NodeIncarnationId,
16760 session: SessionAddress,
16761 lease: ManagedWorktreeLeaseSnapshot,
16762 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
16763 bundle: Option<ResolvedBundleReceipt>,
16764 context: Option<ResolvedContextPackReceipt>,
16765) -> ManagedWorktreeSpawnReceipt {
16766 let mut managed = resolved.clone();
16767 managed.target.worktree_id = Some(lease.workspace_id.clone());
16768 ManagedWorktreeSpawnReceipt {
16769 spawn: managed.receipt_with_materialization(
16770 incarnation_id,
16771 session,
16772 environment_profile,
16773 bundle,
16774 context,
16775 ),
16776 lease,
16777 }
16778}
16779
16780fn validate_selected_worktree(
16781 source_root: &str,
16782 selected_root: &str,
16783 worktrees: &[NativeGitWorktreeSnapshot],
16784) -> Result<(), NodeFailure> {
16785 if !worktrees
16786 .iter()
16787 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
16788 {
16789 return Err(failure(
16790 NodeFailureCode::WorktreeProtected,
16791 "source workspace is not an authoritative non-bare Git worktree root",
16792 ));
16793 }
16794 if !worktrees.iter().any(|worktree| {
16795 worktree_paths_equal(&worktree.path, selected_root)
16796 && !worktree.is_main
16797 && !worktree.is_bare
16798 }) {
16799 return Err(failure(
16800 NodeFailureCode::WorktreeProtected,
16801 "selected workspace is not an eligible Git worktree in the source repository",
16802 ));
16803 }
16804 Ok(())
16805}
16806
16807fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
16808 let code = match error.kind() {
16809 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16810 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16811 NodeFailureCode::RepositoryPathUnsafe
16812 }
16813 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16814 WorkspaceFileReadErrorKind::NotRegularFile => {
16815 NodeFailureCode::RepositoryFileNotRegular
16816 }
16817 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16818 NodeFailureCode::RepositoryFileReadFailed
16819 }
16820 WorkspaceFileReadErrorKind::RevisionConflict => {
16821 NodeFailureCode::RepositoryFileRevisionConflict
16822 }
16823 WorkspaceFileReadErrorKind::AlreadyExists
16824 | WorkspaceFileReadErrorKind::ParentNotFound
16825 | WorkspaceFileReadErrorKind::ParentNotDirectory
16826 | WorkspaceFileReadErrorKind::Canceled => {
16827 NodeFailureCode::RepositoryFileReadFailed
16828 }
16829 };
16830 failure(code, "repository file read failed")
16831}
16832
16833fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
16834 let code = match error.kind() {
16835 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16836 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16837 NodeFailureCode::RepositoryPathUnsafe
16838 }
16839 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16840 WorkspaceFileReadErrorKind::NotRegularFile => {
16841 NodeFailureCode::RepositoryFileNotRegular
16842 }
16843 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16844 NodeFailureCode::RepositoryFileWriteFailed
16845 }
16846 WorkspaceFileReadErrorKind::RevisionConflict => {
16847 NodeFailureCode::RepositoryFileRevisionConflict
16848 }
16849 WorkspaceFileReadErrorKind::AlreadyExists
16850 | WorkspaceFileReadErrorKind::ParentNotFound
16851 | WorkspaceFileReadErrorKind::ParentNotDirectory
16852 | WorkspaceFileReadErrorKind::Canceled => {
16853 NodeFailureCode::RepositoryFileWriteFailed
16854 }
16855 };
16856 failure(code, "repository file write failed")
16857}
16858
16859async fn settle_workspace_entry_create<T>(
16860 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
16861 commit_state: Arc<AtomicU8>,
16862 deadline: Duration,
16863 timeout_message: &'static str,
16864 task_failure_message: &'static str,
16865) -> Result<T, NodeFailure> {
16866 let joined = match timeout(deadline, &mut task).await {
16867 Ok(joined) => joined,
16868 Err(_) => {
16869 if commit_state
16870 .compare_exchange(
16871 WORKSPACE_ENTRY_CREATE_PENDING,
16872 WORKSPACE_ENTRY_CREATE_CANCELED,
16873 Ordering::AcqRel,
16874 Ordering::Acquire,
16875 )
16876 .is_ok()
16877 {
16878 return Err(failure(
16879 NodeFailureCode::RepositoryEntryCreateTimedOut,
16880 timeout_message,
16881 ));
16882 }
16883 debug_assert_eq!(
16884 commit_state.load(Ordering::Acquire),
16885 WORKSPACE_ENTRY_CREATE_COMMITTING,
16886 );
16887 task.await
16888 }
16889 };
16890 joined
16891 .map_err(|_| {
16892 failure(
16893 NodeFailureCode::RepositoryEntryCreateFailed,
16894 task_failure_message,
16895 )
16896 })?
16897 .map_err(workspace_entry_create_failure)
16898}
16899
16900fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
16901 let code = match error.kind() {
16902 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16903 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16904 NodeFailureCode::RepositoryPathUnsafe
16905 }
16906 WorkspaceFileReadErrorKind::AlreadyExists => {
16907 NodeFailureCode::RepositoryEntryAlreadyExists
16908 }
16909 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
16910 NodeFailureCode::RepositoryParentNotFound
16911 }
16912 WorkspaceFileReadErrorKind::ParentNotDirectory
16913 | WorkspaceFileReadErrorKind::NotRegularFile => {
16914 NodeFailureCode::RepositoryParentNotDirectory
16915 }
16916 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16917 NodeFailureCode::RepositoryEntryCreateFailed
16918 }
16919 WorkspaceFileReadErrorKind::RevisionConflict => {
16920 NodeFailureCode::RepositoryEntryCreateFailed
16921 }
16922 WorkspaceFileReadErrorKind::Canceled => {
16923 NodeFailureCode::RepositoryEntryCreateTimedOut
16924 }
16925 };
16926 failure(code, "repository entry create failed")
16927}
16928
16929fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
16930 let value = digest(&SHA256, bytes)
16931 .as_ref()
16932 .iter()
16933 .map(|byte| format!("{byte:02x}"))
16934 .collect();
16935 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
16936}
16937
16938fn managed_spawn_request_digest_v2(
16939 request: &ManagedWorktreeSpawnRequestV2,
16940) -> Result<String, NodeFailure> {
16941 let encoded = serde_json::to_vec(request).map_err(|_| {
16942 failure(
16943 NodeFailureCode::InvalidRequest,
16944 "managed worktree V2 request could not be canonicalized",
16945 )
16946 })?;
16947 let mut value = String::with_capacity(71);
16948 value.push_str("sha256:");
16949 for byte in digest(&SHA256, &encoded).as_ref() {
16950 use std::fmt::Write as _;
16951 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
16952 }
16953 Ok(value)
16954}
16955
16956fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
16957 let code = match error {
16958 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
16959 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
16960 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
16961 NativeSessionPreviewError::UnsupportedProvider
16962 | NativeSessionPreviewError::PreviewUnavailable
16963 | NativeSessionPreviewError::SessionNotFound
16964 | NativeSessionPreviewError::AmbiguousSession => {
16965 NodeFailureCode::BackendOperationFailed
16966 }
16967 };
16968 failure(code, "native session preview failed")
16969}
16970
16971fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
16972 let code = match error.kind() {
16973 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
16974 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
16975 };
16976 failure(code, "host directory browse failed")
16977}
16978
16979fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
16980 let code = match error {
16981 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
16982 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
16983 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
16984 NodeFailureCode::DeliveryStageConflict
16985 }
16986 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
16987 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
16988 NodeFailureCode::DeliveryChunkOutOfOrder
16989 }
16990 DeliveryStoreError::BlobDigestMismatch => {
16991 NodeFailureCode::DeliveryBlobDigestMismatch
16992 }
16993 DeliveryStoreError::BundleDigestMismatch => {
16994 NodeFailureCode::DeliveryBundleDigestMismatch
16995 }
16996 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
16997 DeliveryStoreError::Unavailable
16998 | DeliveryStoreError::Corrupt
16999 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17000 };
17001 failure(code, "delivery operation failed")
17002}
17003
17004fn validate_workspace_root(
17005 workspace_id: &WorkspaceId,
17006 canonical_root: &str,
17007) -> Result<(), NodeServerError> {
17008 #[cfg(windows)]
17009 if canonical_root.starts_with(r"\\") {
17010 return Err(NodeServerError::InvalidWorkspaceRoot {
17011 workspace_id: workspace_id.clone(),
17012 path: canonical_root.to_owned(),
17013 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17014 });
17015 }
17016 if !platform::workspace_root_supported(canonical_root)
17017 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17018 {
17019 return Err(NodeServerError::InvalidWorkspaceRoot {
17020 workspace_id: workspace_id.clone(),
17021 path: canonical_root.to_owned(),
17022 message: "canonical path must be absolute, bounded, and free of control characters"
17023 .to_owned(),
17024 });
17025 }
17026 Ok(())
17027}
17028
17029fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17030 NodeFailure {
17031 code,
17032 message: node_failure_category(code).to_owned(),
17033 }
17034}
17035
17036fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17037 let code = match error {
17038 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17039 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17040 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17041 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17042 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17043 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17044 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17045 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17046 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17047 };
17048 failure(code, "harness MCP proxy operation failed")
17049}
17050
17051static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17055
17056fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17063 let trace = launch.trace.as_ref()?;
17064 let trace_dir = std::env::var(&trace.dir_env).ok()?;
17065 if trace_dir.is_empty() {
17066 return None;
17067 }
17068 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17069 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17070 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17071}
17072
17073fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17074 let remaining = deadline.saturating_duration_since(Instant::now());
17075 if remaining.is_zero() {
17076 Err(failure(
17077 NodeFailureCode::SpawnDeadlineExceeded,
17078 "spawn deadline elapsed",
17079 ))
17080 } else {
17081 Ok(remaining)
17082 }
17083}
17084
17085fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17086 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17087 match deadline {
17088 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17089 None => Ok(bounded),
17090 }
17091}
17092
17093fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17094 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17095 failure(
17096 NodeFailureCode::SpawnDeadlineExceeded,
17097 "spawn dispatch exceeded the spawn deadline",
17098 )
17099 } else {
17100 error
17101 }
17102}
17103
17104fn spawn_runtime_capabilities(
17105 capabilities: &SpawnRequiredCapabilities,
17106) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17107 capabilities
17108 .iter()
17109 .map(|capability| match capability.as_str() {
17110 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17111 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17112 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17113 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17114 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17115 }
17116 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17117 _ => Err(failure(
17118 NodeFailureCode::UnsupportedSpawnCapability,
17119 "spawn required capability is unknown",
17120 )),
17121 })
17122 .collect()
17123}
17124
17125fn persistence_failure(_error: io::Error) -> NodeFailure {
17126 NodeFailure {
17127 code: NodeFailureCode::BackendOperationFailed,
17128 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17129 }
17130}
17131
17132fn node_failure_category(code: NodeFailureCode) -> &'static str {
17133 match code {
17134 NodeFailureCode::InvalidRequest => "invalid-request",
17135 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17136 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17137 NodeFailureCode::ReservationNotFound => "reservation-not-found",
17138 NodeFailureCode::ReservationConflict => "reservation-conflict",
17139 NodeFailureCode::ReservationExpired => "reservation-expired",
17140 NodeFailureCode::BindingMismatch => "binding-mismatch",
17141 NodeFailureCode::NotActivated => "not-activated",
17142 NodeFailureCode::CallNotFound => "call-not-found",
17143 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17144 NodeFailureCode::ResponseTooLarge => "response-too-large",
17145 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17146 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17147 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17148 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17149 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17150 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17151 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17152 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17153 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17154 NodeFailureCode::Unauthorized => "unauthorized",
17155 NodeFailureCode::ObserverReadOnly => "observer-read-only",
17156 NodeFailureCode::ControllerBusy => "controller-busy",
17157 NodeFailureCode::ControllerRequired => "controller-required",
17158 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17159 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17160 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17161 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17162 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17163 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17164 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17165 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17166 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17167 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17168 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17169 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17170 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17171 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17172 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17173 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17174 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17175 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17176 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17177 NodeFailureCode::GitReadFailed => "git-read-failed",
17178 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17179 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17180 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17181 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17182 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17183 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17184 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17185 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17186 NodeFailureCode::TurnInFlight => "turn-in-flight",
17187 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17188 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17189 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17190 "unsupported-network-allowlist-mapping"
17191 }
17192 NodeFailureCode::BrowserStationProbeUnavailable => {
17193 "browser-station-probe-unavailable"
17194 }
17195 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17196 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17197 NodeFailureCode::EnvironmentProfileBindingMismatch => {
17198 "environment-profile-binding-mismatch"
17199 }
17200 NodeFailureCode::UnknownBundle => "unknown-bundle",
17201 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17202 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17203 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17204 NodeFailureCode::ContextPackBusy => "context-pack-busy",
17205 NodeFailureCode::ContextPackMaterializationFailed => {
17206 "context-pack-materialization-failed"
17207 }
17208 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17209 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17210 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17211 NodeFailureCode::WorkspaceBusy => "workspace-busy",
17212 NodeFailureCode::LastWorkspace => "last-workspace",
17213 NodeFailureCode::NotGitRepository => "not-git-repository",
17214 NodeFailureCode::WorktreeConflict => "worktree-conflict",
17215 NodeFailureCode::WorktreeProtected => "worktree-protected",
17216 NodeFailureCode::WorktreeDirty => "worktree-dirty",
17217 NodeFailureCode::WorktreeLocked => "worktree-locked",
17218 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17219 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17220 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17221 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17222 "managed-worktree-profile-revision-mismatch"
17223 }
17224 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17225 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17226 "standalone-workspace-recovery-required"
17227 }
17228 NodeFailureCode::UnknownSession => "unknown-session",
17229 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17230 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17231 NodeFailureCode::SessionRecordBusy => "session-record-busy",
17232 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17233 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17234 NodeFailureCode::WorkspaceRegistrationRequired => {
17235 "workspace-registration-required"
17236 }
17237 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17238 NodeFailureCode::StaleGeneration => "stale-generation",
17239 NodeFailureCode::BackendBusy => "backend-busy",
17240 NodeFailureCode::BackendDisconnected => "backend-disconnected",
17241 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17242 NodeFailureCode::ShuttingDown => "shutting-down",
17243 }
17244}
17245
17246fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17247 NodeServerError::DurableState(io::Error::new(error.kind(), category))
17248}
17249
17250fn durable_state_load_error(error: io::Error) -> NodeServerError {
17251 let category = match session_registry::state_load_refusal(&error) {
17252 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17253 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17254 }
17255 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17256 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17257 }
17258 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17259 DURABLE_STATE_CONFLICT_ERROR
17260 }
17261 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17262 };
17263 durable_state_server_error(error, category)
17264}
17265
17266fn unix_time_ms() -> u64 {
17267 std::time::SystemTime::now()
17268 .duration_since(std::time::UNIX_EPOCH)
17269 .unwrap_or_default()
17270 .as_millis()
17271 .min(u64::MAX as u128) as u64
17272}
17273
17274fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17275 if record.provider_session.is_some() {
17276 ManagedSessionState::Dormant
17277 } else {
17278 ManagedSessionState::Unavailable
17279 }
17280}
17281
17282fn active_record_state(
17283 identity_admitted: bool,
17284 provider_session_is_known: bool,
17285) -> ManagedSessionState {
17286 if identity_admitted && !provider_session_is_known {
17287 ManagedSessionState::IdentityPending
17288 } else {
17289 ManagedSessionState::Live
17290 }
17291}
17292
17293fn managed_worktree_record_holders(
17294 records: &[ManagedSessionRecord],
17295) -> Vec<(SessionRecordId, WorkspaceId)> {
17296 records
17297 .iter()
17298 .filter(|record| record.provider_session.is_some())
17299 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17300 .collect()
17301}
17302
17303fn managed_spawn_record_ownership(
17304 binding: &SessionBinding,
17305 effective_runtime_policy: ProviderRuntimePolicy,
17306) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17307 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17308 if effective_runtime_policy.provider_session_identity {
17309 (binding.record_id.clone(), None)
17310 } else {
17311 (None, binding.record_id.clone())
17312 }
17313}
17314
17315#[derive(Debug, Error)]
17316pub enum NodeServerError {
17317 #[error("node endpoint must be a bounded absolute local transport path")]
17318 InvalidEndpoint,
17319 #[error("node access token must contain 1..=4096 bytes")]
17320 InvalidAccessToken,
17321 #[error("node HTTP observer must listen on a loopback address: {0}")]
17322 InvalidApiListen(std::net::SocketAddr),
17323 #[error("node browser bridge must listen on a loopback address: {0}")]
17324 InvalidBridgeListen(std::net::SocketAddr),
17325 #[error("node browser bridge token must contain 1..=4096 bytes")]
17326 InvalidBridgeToken,
17327 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17328 BridgeUnderlayRequiresBridgeListen,
17329 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17330 BridgeUnderlayRequiresBridgeToken,
17331 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17332 InvalidBridgeUnderlayToken,
17333 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17334 InvalidBridgeUnderlayKey,
17335 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17336 InvalidCallHome(std::net::SocketAddr),
17337 #[error("node could not reach its call-home relay: {0}")]
17338 CallHomeTransport(#[source] std::io::Error),
17339 #[error("node durable state path must be an absolute file path: {0}")]
17340 InvalidStatePath(String),
17341 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17342 InvalidHarnessMcpHelper,
17343 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17344 InvalidNetworkAllowlistCatalogPath(String),
17345 #[error("network allowlist catalog could not be read: {0}")]
17346 NetworkAllowlistCatalogIo(#[source] std::io::Error),
17347 #[error("network allowlist catalog line {line}: {message}")]
17348 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17349 #[error("network allowlist catalog: {0}")]
17350 InvalidNetworkAllowlistCatalog(String),
17351 #[error("network allowlist catalog exceeds the {max}-entry limit")]
17352 NetworkAllowlistCatalogCapacity { max: usize },
17353 #[error("the local state directory is unavailable; supply an explicit state path")]
17354 LocalStateDirectoryUnavailable,
17355 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17356 LocalRuntimeDirectoryUnavailable,
17357 #[error("node durable state failed: {0}")]
17358 DurableState(io::Error),
17359 #[error("node requires at least one configured workspace")]
17360 NoWorkspaces,
17361 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17362 InvalidWorkspaceRoot {
17363 workspace_id: WorkspaceId,
17364 path: String,
17365 message: String,
17366 },
17367 #[error("duplicate workspace ID: {0}")]
17368 DuplicateWorkspaceId(WorkspaceId),
17369 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17370 DuplicateWorkspaceRoot {
17371 first: WorkspaceId,
17372 second: WorkspaceId,
17373 root: String,
17374 },
17375 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17376 ManagedWorktreeProfileRequired(WorkspaceId),
17377 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17378 ManagedWorktreeProfileModeMismatch(WorkspaceId),
17379 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17380 DuplicateManagedWorktreeProfile {
17381 workspace_id: WorkspaceId,
17382 profile_id: WorktreeProfileId,
17383 },
17384 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17385 ManagedWorktreeProfileCapacity {
17386 workspace_id: WorkspaceId,
17387 max: usize,
17388 },
17389 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17390 InvalidManagedWorktreeProfile {
17391 workspace_id: WorkspaceId,
17392 profile_id: WorktreeProfileId,
17393 message: String,
17394 },
17395 #[error("duplicate node environment profile: {0}")]
17396 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17397 #[error("node environment profile provider is unavailable: {0}")]
17398 EnvironmentProfileProviderUnavailable(AgentId),
17399 #[error("node environment profile capacity is {max}")]
17400 EnvironmentProfileCapacity { max: usize },
17401 #[error("node environment profiles reuse native profile ID '{0}'")]
17402 DuplicateNativeEnvironmentProfile(String),
17403 #[error("node native environment profile is invalid: {0}")]
17404 NativeEnvironmentProfile(String),
17405 #[error("node session-environment materialization root must be absolute")]
17406 InvalidSessionEnvironmentRoot,
17407 #[error("node session-environment materializer is unavailable")]
17408 SessionEnvironmentMaterializerRequired,
17409 #[error("node session-environment materializer failed to initialize")]
17410 SessionEnvironmentMaterializer,
17411 #[error("node bundle catalog is invalid: {0}")]
17412 BundleCatalog(String),
17413 #[error("node delivery store failed to initialize: {0}")]
17414 DeliveryStore(String),
17415 #[error("node context pack store failed to initialize")]
17416 ContextPackStore,
17417 #[error("active agent registry failed: {0}")]
17418 Registry(String),
17419 #[error("node provider contract manifest is invalid: {0}")]
17420 ProviderContractManifest(String),
17421 #[error("named pipe I/O failed: {0}")]
17422 Io(#[from] io::Error),
17423 #[error("node HTTP observer failed: {0}")]
17424 HttpApi(io::Error),
17425 #[error("node browser bridge failed: {0}")]
17426 BridgeApi(io::Error),
17427 #[error(transparent)]
17428 Frame(#[from] FrameError),
17429 #[error("node handshake failed: {0}")]
17430 Handshake(String),
17431 #[error("node authentication frame was not received before the bounded deadline")]
17432 AuthenticationTimedOut,
17433 #[error("node authentication primitive failed: {0}")]
17434 Authentication(String),
17435 #[error("node incarnation identity generation failed: {0}")]
17436 IncarnationIdentity(String),
17437 #[error("node pre-authentication limiter is closed")]
17438 PreauthClosed,
17439 #[error("node authenticated connection limit was reached")]
17440 AuthenticatedConnectionLimit,
17441 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17442 SignalStreamClosed,
17443 #[error("node stop-all dispatch failed: {0:?}")]
17444 ShutdownDispatch(NodeFailure),
17445 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17446 ShutdownTimedOut { active_native_sessions: usize },
17447}
17448
17449impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17450 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17451 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17452 match error {
17453 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17454 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17455 E::InvalidEntry { line, message } => {
17456 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17457 }
17458 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17459 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17460 }
17461 }
17462}
17463
17464#[cfg(test)]
17465mod agent_stream_blocked_tests {
17466 use super::*;
17467 use gate4agent_types::{
17468 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17469 ProviderSource,
17470 };
17471
17472 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17473 ProviderSource {
17474 family,
17475 binding: AdapterBinding::new(
17476 AdapterId::new(adapter).unwrap(),
17477 "fixture/v1",
17478 AdapterVerification::SyntheticFixture,
17479 )
17480 .unwrap(),
17481 }
17482 }
17483
17484 fn provider_control_event_at(
17485 family: AdapterFamily,
17486 adapter: &str,
17487 source_sequence: u64,
17488 provider_event: ProviderEvent,
17489 ) -> ControlEvent {
17490 ControlEvent {
17491 sequence: 41,
17492 command_id: None,
17493 instance_id: AgentInstanceId(7),
17494 generation: SessionGeneration(3),
17495 event: ControlEventKind::ProviderEvent {
17496 sequence: 8,
17497 source: provider_source(family, adapter),
17498 source_sequence,
17499 event: provider_event,
17500 },
17501 }
17502 }
17503
17504 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17505 provider_control_event_at(family, "fixture", 9, provider_event)
17506 }
17507
17508 fn denied_host_request(
17509 by: ProviderHostDecisionAuthority,
17510 reason: Option<String>,
17511 ) -> ControlEvent {
17512 provider_control_event_at(
17513 AdapterFamily::Acp,
17514 "claude-code",
17515 2,
17516 ProviderEvent::HostRequestObserved {
17517 method: "terminal/create".to_owned(),
17518 params_json: String::new(),
17519 decision: ProviderHostRequestDecision::Denied { by },
17520 outcome: ProviderHostRequestOutcome::Executed,
17521 reason,
17522 },
17523 )
17524 }
17525
17526 fn blocked(
17527 event: &ControlEvent,
17528 ) -> (
17529 Option<String>,
17530 String,
17531 BlockAuthorityV1,
17532 Option<String>,
17533 String,
17534 Option<String>,
17535 ) {
17536 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17537 chunk.validate().expect("minted chunk must validate");
17538 let AgentStreamChunkKindV1::Blocked {
17539 correlation_id,
17540 tool_class,
17541 authority,
17542 reason_kind,
17543 reason,
17544 help,
17545 } = chunk.kind
17546 else {
17547 panic!("expected AgentStreamChunkKindV1::Blocked");
17548 };
17549 (correlation_id, tool_class, authority, reason_kind, reason, help)
17550 }
17551
17552 #[test]
17556 fn the_enabled_provider_matrix_is_exact() {
17557 let registry = active_registry().unwrap();
17558 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
17559 assert_eq!(
17560 providers
17561 .iter()
17562 .map(|contract| contract.provider.as_str())
17563 .collect::<Vec<_>>(),
17564 vec!["claude", "codex", "grok", "kimi"],
17565 );
17566 assert!(adapters.iter().all(|contract| {
17567 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
17568 }));
17569 }
17570
17571 #[test]
17574 fn gate_denied_host_request_mints_a_blocked_chunk() {
17575 let gate_text =
17576 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
17577 let event = denied_host_request(
17578 ProviderHostDecisionAuthority::Gate,
17579 Some(gate_text.to_owned()),
17580 );
17581 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
17582 assert_eq!(chunk.source_sequence, 2);
17583 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
17584 assert_eq!(authority, BlockAuthorityV1::HostGate);
17585 assert_eq!(reason, gate_text);
17586 assert_eq!(correlation_id, None);
17587 assert_eq!(reason_kind, None);
17588 assert_eq!(help, None);
17589 assert_eq!(tool_class, "Write");
17590 }
17591
17592 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.";
17593
17594 #[test]
17600 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
17601 let event = provider_control_event(
17602 AdapterFamily::Acp,
17603 ProviderEvent::ToolCompleted {
17604 id: "toolu_9".to_owned(),
17605 output: CLASSIFIER_SAMPLE.to_owned(),
17606 is_error: true,
17607 duration_ms: None,
17608 agent_id: None,
17609 non_execution_kind: Some("permission-rule".to_owned()),
17610 },
17611 );
17612 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
17613 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
17614 assert_eq!(reason, "Blocked by classifier");
17615 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
17616 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
17617 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
17618 }
17619
17620 #[test]
17625 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
17626 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.";
17627 let event = provider_control_event(
17628 AdapterFamily::Acp,
17629 ProviderEvent::SessionEnded {
17630 result: "JSON-RPC error -32603: Internal error".to_owned(),
17631 cost_usd: None,
17632 is_error: true,
17633 stop_reason: Some(ProviderStopReason::ProviderError {
17634 code: -32603,
17635 message: message.to_owned(),
17636 vendor_code: Some("usageLimitExceeded".to_owned()),
17637 }),
17638 },
17639 );
17640 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
17641 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
17642 assert_eq!(reason, message);
17643 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
17644 assert_eq!(tool_class, "Session");
17645 }
17646
17647 #[test]
17650 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
17651 let event = provider_control_event(
17652 AdapterFamily::Acp,
17653 ProviderEvent::SessionEnded {
17654 result: "refusal".to_owned(),
17655 cost_usd: None,
17656 is_error: true,
17657 stop_reason: Some(ProviderStopReason::Refusal),
17658 },
17659 );
17660 let (_, _, authority, _, _, _) = blocked(&event);
17661 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
17662 }
17663
17664 #[test]
17668 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
17669 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
17670 let event = provider_control_event(
17671 AdapterFamily::Acp,
17672 ProviderEvent::ToolCompleted {
17673 id: "toolu_1".to_owned(),
17674 output: "command exited with status 1".to_owned(),
17675 is_error: true,
17676 duration_ms: Some(12),
17677 agent_id: None,
17678 non_execution_kind,
17679 },
17680 );
17681 assert!(agent_stream_chunk(&event).is_none());
17682 }
17683 let narrative = provider_control_event(
17684 AdapterFamily::Acp,
17685 ProviderEvent::ToolCompleted {
17686 id: "toolu_2".to_owned(),
17687 output: "the harness blocked the write, approval needed".to_owned(),
17688 is_error: false,
17689 duration_ms: Some(3),
17690 agent_id: None,
17691 non_execution_kind: None,
17692 },
17693 );
17694 assert!(agent_stream_chunk(&narrative).is_none());
17695 }
17696
17697 #[test]
17701 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
17702 for (decision, outcome) in [
17703 (
17704 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17705 ProviderHostRequestOutcome::Executed,
17706 ),
17707 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
17708 (
17709 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17710 ProviderHostRequestOutcome::Failed {
17711 error: "terminal/create spawn failed: os error 3".to_owned(),
17712 },
17713 ),
17714 ] {
17715 let event = provider_control_event_at(
17716 AdapterFamily::Acp,
17717 "claude-code",
17718 3,
17719 ProviderEvent::HostRequestObserved {
17720 method: "fs/read_text_file".to_owned(),
17721 params_json: String::new(),
17722 decision: decision.clone(),
17723 outcome,
17724 reason: None,
17725 },
17726 );
17727 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
17728 }
17729 }
17730
17731 #[test]
17735 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
17736 for (by, expected_authority, expected_reason) in [
17737 (
17738 ProviderHostDecisionAuthority::Operator,
17739 BlockAuthorityV1::Operator,
17740 "declined by the operator",
17741 ),
17742 (
17743 ProviderHostDecisionAuthority::DeadlinePolicy,
17744 BlockAuthorityV1::HostDeadline,
17745 "denied by host policy after the operator deadline expired",
17746 ),
17747 ] {
17748 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
17749 assert_eq!(authority, expected_authority);
17750 assert_eq!(reason, expected_reason);
17751 }
17752 }
17753
17754 #[test]
17758 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
17759 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
17760 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
17761 ProviderHostDecisionAuthority::Policy,
17762 Some(oversized),
17763 ));
17764 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
17765 }
17766}
17767
17768#[cfg(test)]
17769mod standalone_capability_platform_tests {
17770 use super::*;
17771
17772 #[test]
17773 fn standalone_workspace_capability_requires_windows_identity_guards() {
17774 let capabilities = baseline_capabilities().unwrap();
17775 let advertised = capabilities.iter().any(|capability| {
17776 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
17777 });
17778 assert_eq!(advertised, cfg!(windows));
17779
17780 let request = NodeRequest::CreateStandaloneWorkspace {
17781 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
17782 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
17783 initial_branch: None,
17784 };
17785 assert_eq!(
17786 request_uses_unnegotiated_capability(&request, &capabilities),
17787 !cfg!(windows),
17788 );
17789 }
17790}
17791
17792
17793#[cfg(test)]
17794mod station_knobs_resolve_tests {
17795 use super::*;
17796 use gate4agent_catalog::EnvMutation;
17797 use gate4agent_runtime_native::{
17798 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
17799 NativeLaunchProfileId,
17800 };
17801 use std::ffi::OsString;
17802 use std::sync::Arc;
17803
17804 struct EmptyEnvironmentResolver;
17805
17806 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
17807 fn resolve_child_environment(
17808 &self,
17809 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
17810 Ok(vec![EnvMutation {
17811 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
17812 value: None,
17813 }])
17814 }
17815 }
17816
17817 fn agent(value: &str) -> AgentId {
17818 AgentId::new(value).unwrap()
17819 }
17820
17821 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
17822 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
17823 let profile_revision =
17824 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
17825 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
17826 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
17827 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
17828 provider: agent("claude"),
17829 mode: SessionMode::Pty,
17830 terminal_size: gate4agent_types::TerminalSize {
17831 rows: 24,
17832 columns: 80,
17833 },
17834 prompt: None,
17835 bundle_id: None,
17836 context_id: None,
17837 environment_profile_id: Some(profile_id.clone()),
17838 };
17839 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
17840 let catalog = active_registry().unwrap();
17841 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
17842 let control = runtime.native_launch_profile_control();
17843 let node_profile = NodeEnvironmentProfile::new(
17844 profile_id.clone(),
17845 profile_revision,
17846 agent("claude"),
17847 [NativeLaunchProfile::new(
17848 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
17849 agent("claude"),
17850 TransportKind::Pty,
17851 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
17852 Arc::new(EmptyEnvironmentResolver),
17853 )
17854 .unwrap()],
17855 )
17856 .unwrap();
17857 let (binding, native_profiles, _materialization) = node_profile.into_parts();
17858 for native_profile in native_profiles {
17859 runtime.upsert_native_launch_profile(native_profile).unwrap();
17860 }
17861 let workspace = WorkspaceConfig::new(
17862 WorkspaceId::new("primary").unwrap(),
17863 std::env::current_dir().unwrap(),
17864 )
17865 .unwrap();
17866 let shared = NodeShared::new_with_incarnation(
17867 handle,
17868 "fixture-token".to_owned(),
17869 NodeId::new("node-station-knobs-test").unwrap(),
17870 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
17871 vec![workspace],
17872 vec![agent("claude")],
17873 ProviderRuntimeStatuses::default(),
17874 None,
17875 Vec::new(),
17876 Vec::new(),
17877 spawn_profiles,
17878 Some(control),
17879 None,
17880 None,
17881 None,
17882 Vec::new(),
17883 Vec::new(),
17884 Vec::new(),
17885 Vec::new(),
17886 None,
17887 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
17888 );
17889 shared
17890 .environment_profiles
17891 .write()
17892 .unwrap_or_else(|poisoned| poisoned.into_inner())
17893 .insert(binding.id.clone(), binding);
17894 (shared, spawn_defaults)
17895 }
17896
17897 #[test]
17900 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
17901 let (shared, defaults) = station_knobs_fixture();
17902 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
17903 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17904 let spec = crate::protocol::SpawnSpec {
17905 target: crate::protocol::SpawnTarget {
17906 node_id: shared.node_id.clone(),
17907 workspace_id: WorkspaceId::new("primary").unwrap(),
17908 worktree_id: None,
17909 },
17910 profile_id: defaults.profile_id.clone(),
17911 expected_profile_revision: defaults.revision.clone(),
17912 overrides: crate::protocol::SpawnOverrides {
17913 network_allowlist: Some(unknown),
17914 browser_profile_id: Some(browser),
17915 ..crate::protocol::SpawnOverrides::default()
17916 },
17917 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17918 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17919 "station-knobs-unknown-allowlist",
17920 )
17921 .unwrap(),
17922 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17923 };
17924 let resolved = spec.resolve(&defaults).unwrap();
17925 let failure = shared
17926 .resolve_environment_profile(&resolved)
17927 .expect_err("unknown allowlist must refuse");
17928 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
17929 }
17930
17931 #[test]
17937 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
17938 let (shared, defaults) = station_knobs_fixture();
17939 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
17940 #[cfg(not(feature = "dig2-station-probe"))]
17941 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17942 shared
17943 .network_allowlist_catalog
17944 .write()
17945 .unwrap_or_else(|poisoned| poisoned.into_inner())
17946 .insert_id(allowlist.clone())
17947 .unwrap();
17948 let spec = crate::protocol::SpawnSpec {
17949 target: crate::protocol::SpawnTarget {
17950 node_id: shared.node_id.clone(),
17951 workspace_id: WorkspaceId::new("primary").unwrap(),
17952 worktree_id: None,
17953 },
17954 profile_id: defaults.profile_id.clone(),
17955 expected_profile_revision: defaults.revision.clone(),
17956 overrides: crate::protocol::SpawnOverrides {
17957 network_allowlist: Some(allowlist.clone()),
17958 #[cfg(not(feature = "dig2-station-probe"))]
17959 browser_profile_id: Some(browser.clone()),
17960 ..crate::protocol::SpawnOverrides::default()
17961 },
17962 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17963 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
17964 .unwrap(),
17965 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17966 };
17967 let resolved = spec.resolve(&defaults).unwrap();
17968 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
17969 #[cfg(not(feature = "dig2-station-probe"))]
17970 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
17971 let receipt = shared
17972 .resolve_environment_profile(&resolved)
17973 .expect("resolve ok")
17974 .expect("env profile present");
17975 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
17976 #[cfg(not(feature = "dig2-station-probe"))]
17977 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
17978 #[cfg(feature = "dig2-station-probe")]
17979 assert_eq!(receipt.browser_profile_id, None);
17980 assert_eq!(receipt.profile_id.as_str(), "local-claude");
17981
17982 let omitted = crate::protocol::SpawnSpec {
17983 overrides: crate::protocol::SpawnOverrides::default(),
17984 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
17985 .unwrap(),
17986 ..spec
17987 };
17988 let resolved_omit = omitted.resolve(&defaults).unwrap();
17989 let bare = shared
17990 .resolve_environment_profile(&resolved_omit)
17991 .expect("resolve ok")
17992 .expect("env profile present");
17993 assert_eq!(bare.network_allowlist, None);
17994 assert_eq!(bare.browser_profile_id, None);
17995 }
17996
17997 #[cfg(feature = "dig2-station-probe")]
18002 #[test]
18003 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18004 let (shared, defaults) = station_knobs_fixture();
18005 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18006 let spec = crate::protocol::SpawnSpec {
18007 target: crate::protocol::SpawnTarget {
18008 node_id: shared.node_id.clone(),
18009 workspace_id: WorkspaceId::new("primary").unwrap(),
18010 worktree_id: None,
18011 },
18012 profile_id: defaults.profile_id.clone(),
18013 expected_profile_revision: defaults.revision.clone(),
18014 overrides: crate::protocol::SpawnOverrides {
18015 browser_profile_id: Some(browser),
18016 ..crate::protocol::SpawnOverrides::default()
18017 },
18018 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18019 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18020 "station-knobs-probe-refuse",
18021 )
18022 .unwrap(),
18023 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18024 };
18025 let resolved = spec.resolve(&defaults).unwrap();
18026 let failure = shared
18027 .resolve_environment_profile(&resolved)
18028 .expect_err("browser_profile with failing probe must refuse");
18029 #[cfg(windows)]
18030 {
18031 assert!(
18032 matches!(
18033 failure.code,
18034 NodeFailureCode::BrowserStationUnreachable
18035 | NodeFailureCode::BrowserStationProbeUnavailable
18036 ),
18037 "unexpected code {:?}",
18038 failure.code
18039 );
18040 }
18041 #[cfg(not(windows))]
18042 {
18043 assert_eq!(
18044 failure.code,
18045 NodeFailureCode::BrowserStationProbeUnavailable
18046 );
18047 }
18048 }
18049
18050 #[cfg(feature = "dig2-station-probe")]
18052 #[test]
18053 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18054 let (shared, defaults) = station_knobs_fixture();
18055 let spec = crate::protocol::SpawnSpec {
18056 target: crate::protocol::SpawnTarget {
18057 node_id: shared.node_id.clone(),
18058 workspace_id: WorkspaceId::new("primary").unwrap(),
18059 worktree_id: None,
18060 },
18061 profile_id: defaults.profile_id.clone(),
18062 expected_profile_revision: defaults.revision.clone(),
18063 overrides: crate::protocol::SpawnOverrides::default(),
18064 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18065 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18066 "station-knobs-probe-omit",
18067 )
18068 .unwrap(),
18069 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18070 };
18071 let resolved = spec.resolve(&defaults).unwrap();
18072 let receipt = shared
18073 .resolve_environment_profile(&resolved)
18074 .expect("omit browser_profile must not probe")
18075 .expect("env profile present");
18076 assert_eq!(receipt.browser_profile_id, None);
18077 }
18078
18079 #[cfg(feature = "dig2-station-probe")]
18083 #[test]
18084 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18085 let (shared, _defaults) = station_knobs_fixture();
18086 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18087 let workspace_id = WorkspaceId::new("primary").unwrap();
18088 let address_a = SessionAddress {
18089 workspace_id: workspace_id.clone(),
18090 session: SessionKey {
18091 instance_id: AgentInstanceId(101),
18092 generation: SessionGeneration(1),
18093 },
18094 };
18095 let address_b = SessionAddress {
18096 workspace_id: workspace_id.clone(),
18097 session: SessionKey {
18098 instance_id: AgentInstanceId(102),
18099 generation: SessionGeneration(1),
18100 },
18101 };
18102 let guard_a = crate::dig2_station_lease::acquire_lease(
18103 &shared.browser_station_leases,
18104 profile.clone(),
18105 address_a.session.instance_id,
18106 1,
18107 )
18108 .expect("first lease");
18109 let busy = crate::dig2_station_lease::acquire_lease(
18110 &shared.browser_station_leases,
18111 profile.clone(),
18112 address_b.session.instance_id,
18113 2,
18114 )
18115 .expect_err("second holder must refuse");
18116 assert!(matches!(
18117 busy,
18118 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18119 ));
18120 shared.bind_session_with_policy(
18122 &address_a,
18123 ProviderRuntimePolicy::raw_pty(),
18124 None,
18125 );
18126 guard_a.retain();
18127 assert!(shared.remove_binding(&address_a).is_some());
18128 crate::dig2_station_lease::acquire_lease(
18129 &shared.browser_station_leases,
18130 profile,
18131 address_b.session.instance_id,
18132 3,
18133 )
18134 .expect("reacquire after clear")
18135 .retain();
18136 assert!(
18137 crate::dig2_station_lease::release_lease_for_holder(
18138 &shared.browser_station_leases,
18139 address_b.session.instance_id,
18140 )
18141 );
18142 }
18143
18144 #[cfg(not(feature = "dig2-station-probe"))]
18147 #[test]
18148 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18149 let (shared, defaults) = station_knobs_fixture();
18150 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18151 let spec = crate::protocol::SpawnSpec {
18152 target: crate::protocol::SpawnTarget {
18153 node_id: shared.node_id.clone(),
18154 workspace_id: WorkspaceId::new("primary").unwrap(),
18155 worktree_id: None,
18156 },
18157 profile_id: defaults.profile_id.clone(),
18158 expected_profile_revision: defaults.revision.clone(),
18159 overrides: crate::protocol::SpawnOverrides {
18160 browser_profile_id: Some(browser.clone()),
18161 ..crate::protocol::SpawnOverrides::default()
18162 },
18163 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18164 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18165 .unwrap(),
18166 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18167 };
18168 let resolved = spec.resolve(&defaults).unwrap();
18169 let receipt = shared
18170 .resolve_environment_profile(&resolved)
18171 .expect("stub resolve")
18172 .expect("env profile");
18173 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18174 }
18175
18176
18177 #[test]
18180 fn network_allowlist_catalog_loads_from_temp_file() {
18181 let unique = std::time::SystemTime::now()
18182 .duration_since(std::time::UNIX_EPOCH)
18183 .unwrap()
18184 .as_nanos();
18185 let path = std::env::temp_dir().join(format!(
18186 "gate4agent-node-allowlist-config-{}-{unique}.txt",
18187 std::process::id(),
18188 ));
18189 std::fs::write(
18190 &path,
18191 "# opaque station allowlist ids\negress-default\nlab-egress\n",
18192 )
18193 .unwrap();
18194 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18195 .expect("temp catalog file must load");
18196 let _ = std::fs::remove_file(&path);
18197 assert_eq!(loaded.len(), 2);
18198 let (shared, defaults) = station_knobs_fixture();
18199 {
18200 let mut catalog = shared
18201 .network_allowlist_catalog
18202 .write()
18203 .unwrap_or_else(|poisoned| poisoned.into_inner());
18204 *catalog = loaded;
18205 }
18206 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18207 let spec = crate::protocol::SpawnSpec {
18208 target: crate::protocol::SpawnTarget {
18209 node_id: shared.node_id.clone(),
18210 workspace_id: WorkspaceId::new("primary").unwrap(),
18211 worktree_id: None,
18212 },
18213 profile_id: defaults.profile_id.clone(),
18214 expected_profile_revision: defaults.revision.clone(),
18215 overrides: crate::protocol::SpawnOverrides {
18216 network_allowlist: Some(allowlist.clone()),
18217 ..crate::protocol::SpawnOverrides::default()
18218 },
18219 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18220 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18221 "station-knobs-catalog-file",
18222 )
18223 .unwrap(),
18224 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18225 };
18226 let resolved = spec.resolve(&defaults).unwrap();
18227 let receipt = shared
18228 .resolve_environment_profile(&resolved)
18229 .expect("registered via file load")
18230 .expect("env profile present");
18231 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18232 }
18233
18234 #[test]
18237 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18238 let (shared, _defaults) = station_knobs_fixture();
18239 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18240 shared
18241 .network_allowlist_catalog
18242 .write()
18243 .unwrap_or_else(|poisoned| poisoned.into_inner())
18244 .insert_id(allowlist.clone())
18245 .unwrap();
18246 let snapshot = shared.snapshot();
18247 let inventory = snapshot
18248 .launch_inventory
18249 .expect("launch inventory present");
18250 let ids = inventory
18251 .network_allowlists
18252 .expect("non-empty catalog surfaces ids");
18253 assert_eq!(ids, vec![allowlist]);
18254 }
18255
18256 #[test]
18260 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18261 let (shared, defaults) = station_knobs_fixture();
18262 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18263 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18264 id: allowlist.clone(),
18265 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18266 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18267 peer: "127.0.0.1:443".to_owned(),
18268 }],
18269 provider_native: Some(
18270 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18271 codex_network_access: Some(true),
18272 },
18273 ),
18274 };
18275 shared
18276 .network_allowlist_catalog
18277 .write()
18278 .unwrap_or_else(|poisoned| poisoned.into_inner())
18279 .insert(entry)
18280 .unwrap();
18281 let spec = crate::protocol::SpawnSpec {
18282 target: crate::protocol::SpawnTarget {
18283 node_id: shared.node_id.clone(),
18284 workspace_id: WorkspaceId::new("primary").unwrap(),
18285 worktree_id: None,
18286 },
18287 profile_id: defaults.profile_id.clone(),
18288 expected_profile_revision: defaults.revision.clone(),
18289 overrides: crate::protocol::SpawnOverrides {
18290 network_allowlist: Some(allowlist),
18291 ..crate::protocol::SpawnOverrides::default()
18292 },
18293 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18294 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18295 "station-knobs-unsupported-mapping",
18296 )
18297 .unwrap(),
18298 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18299 };
18300 let resolved = spec.resolve(&defaults).unwrap();
18301 assert_eq!(resolved.provider.as_str(), "claude");
18302 let failure = shared
18303 .resolve_environment_profile(&resolved)
18304 .expect_err("claude + codex_network_access must refuse");
18305 assert_eq!(
18306 failure.code,
18307 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18308 );
18309 }
18310
18311 #[test]
18315 fn resolve_codex_network_access_overlay_moderate_only() {
18316 let (shared, defaults) = station_knobs_fixture();
18317 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18318 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18319 id: allowlist.clone(),
18320 permits: Vec::new(),
18321 provider_native: Some(
18322 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18323 codex_network_access: Some(true),
18324 },
18325 ),
18326 };
18327 shared
18328 .network_allowlist_catalog
18329 .write()
18330 .unwrap_or_else(|poisoned| poisoned.into_inner())
18331 .insert(entry)
18332 .unwrap();
18333
18334 let overlay = shared
18335 .resolve_codex_network_access_overlay_args(
18336 &agent("codex"),
18337 SessionMode::Pty,
18338 ApprovalLevel::Moderate,
18339 Some(&allowlist),
18340 )
18341 .expect("Moderate Codex maps network_access");
18342 assert_eq!(
18343 overlay,
18344 vec![
18345 std::ffi::OsString::from("-c"),
18346 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18347 ]
18348 );
18349
18350 let acp = shared
18351 .resolve_codex_network_access_overlay_args(
18352 &agent("codex"),
18353 SessionMode::Acp,
18354 ApprovalLevel::Moderate,
18355 Some(&allowlist),
18356 )
18357 .unwrap();
18358 assert_eq!(acp, overlay);
18359
18360 let inline = shared
18361 .resolve_codex_network_access_overlay_args(
18362 &agent("codex"),
18363 SessionMode::Inline,
18364 ApprovalLevel::Moderate,
18365 Some(&allowlist),
18366 )
18367 .unwrap_err();
18368 assert_eq!(
18369 inline.code,
18370 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18371 );
18372
18373 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18374 let spec = crate::protocol::SpawnSpec {
18375 target: crate::protocol::SpawnTarget {
18376 node_id: shared.node_id.clone(),
18377 workspace_id: WorkspaceId::new("primary").unwrap(),
18378 worktree_id: None,
18379 },
18380 profile_id: defaults.profile_id.clone(),
18381 expected_profile_revision: defaults.revision.clone(),
18382 overrides: crate::protocol::SpawnOverrides {
18383 provider: crate::protocol::SpawnOverride::Set {
18384 value: agent("codex"),
18385 },
18386 approval_level: Some(level),
18387 network_allowlist: Some(allowlist.clone()),
18388 ..crate::protocol::SpawnOverrides::default()
18389 },
18390 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18391 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18392 "codex-net-refuse-{}",
18393 match level {
18394 ApprovalLevel::ReadOnly => "ro",
18395 ApprovalLevel::FullAuto => "fa",
18396 _ => "other",
18397 }
18398 ))
18399 .unwrap(),
18400 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18401 };
18402 let resolved = spec.resolve(&defaults).unwrap();
18403 let failure = shared
18404 .resolve_environment_profile(&resolved)
18405 .expect_err("RO/FullAuto must refuse");
18406 assert_eq!(
18407 failure.code,
18408 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18409 );
18410 }
18411 }
18412
18413 #[test]
18417 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18418 let (shared, _defaults) = station_knobs_fixture();
18419 let allowlist =
18420 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18421 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18422 id: allowlist.clone(),
18423 permits: vec![
18424 crate::network_allowlist_catalog::NetworkPermitSketch {
18425 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18426 peer: "github.com:443".to_owned(),
18427 },
18428 crate::network_allowlist_catalog::NetworkPermitSketch {
18429 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18430 peer: "*.npmjs.org".to_owned(),
18431 },
18432 ],
18433 provider_native: None,
18434 };
18435 shared
18436 .network_allowlist_catalog
18437 .write()
18438 .unwrap_or_else(|poisoned| poisoned.into_inner())
18439 .insert(entry)
18440 .unwrap();
18441
18442 let unique = std::time::SystemTime::now()
18443 .duration_since(std::time::UNIX_EPOCH)
18444 .unwrap()
18445 .as_nanos();
18446 let provider_home = std::env::temp_dir().join(format!(
18447 "gate4agent-claude-provider-home-{}-{unique}",
18448 std::process::id(),
18449 ));
18450 std::fs::create_dir_all(&provider_home).unwrap();
18451
18452 let overlay = shared
18453 .resolve_network_allowlist_overlay_args(
18454 &agent("claude"),
18455 SessionMode::Pty,
18456 ApprovalLevel::Moderate,
18457 Some(&allowlist),
18458 Some(provider_home.as_path()),
18459 )
18460 .expect("Claude PTY settings overlay on unix");
18461 let settings_path = provider_home.join(
18462 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18463 );
18464 assert_eq!(
18465 overlay,
18466 vec![
18467 std::ffi::OsString::from("--settings"),
18468 std::ffi::OsString::from(settings_path.as_os_str()),
18469 ]
18470 );
18471 let body = std::fs::read_to_string(&settings_path).unwrap();
18472 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18473 assert_eq!(value["sandbox"]["enabled"], true);
18474 assert_eq!(
18475 value["sandbox"]["network"]["allowedDomains"],
18476 serde_json::json!(["github.com", "*.npmjs.org"])
18477 );
18478 assert!(settings_path.starts_with(&provider_home));
18479
18480 let acp = shared
18481 .resolve_network_allowlist_overlay_args(
18482 &agent("claude"),
18483 SessionMode::Acp,
18484 ApprovalLevel::Moderate,
18485 Some(&allowlist),
18486 Some(provider_home.as_path()),
18487 )
18488 .expect_err("Claude ACP must refuse settings overlay");
18489 assert_eq!(
18490 acp.code,
18491 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18492 );
18493
18494 let no_home = shared
18495 .resolve_network_allowlist_overlay_args(
18496 &agent("claude"),
18497 SessionMode::Pty,
18498 ApprovalLevel::Moderate,
18499 Some(&allowlist),
18500 None,
18501 )
18502 .expect_err("Claude overlay requires ProviderHome");
18503 assert_eq!(
18504 no_home.code,
18505 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18506 );
18507
18508 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18509 shared
18510 .network_allowlist_catalog
18511 .write()
18512 .unwrap_or_else(|poisoned| poisoned.into_inner())
18513 .insert_id(bare.clone())
18514 .unwrap();
18515 let empty = shared
18516 .resolve_network_allowlist_overlay_args(
18517 &agent("claude"),
18518 SessionMode::Pty,
18519 ApprovalLevel::Moderate,
18520 Some(&bare),
18521 Some(provider_home.as_path()),
18522 )
18523 .unwrap();
18524 assert!(empty.is_empty());
18525
18526 let _ = std::fs::remove_dir_all(&provider_home);
18527 }
18528
18529 #[test]
18531 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18532 let (shared, _defaults) = station_knobs_fixture();
18533 let allowlist =
18534 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18535 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18536 id: allowlist.clone(),
18537 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18538 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18539 peer: "example.com:443".to_owned(),
18540 }],
18541 provider_native: None,
18542 };
18543 shared
18544 .network_allowlist_catalog
18545 .write()
18546 .unwrap_or_else(|poisoned| poisoned.into_inner())
18547 .insert(entry)
18548 .unwrap();
18549 for provider in ["kimi", "grok"] {
18550 let overlay = shared
18551 .resolve_network_allowlist_overlay_args(
18552 &agent(provider),
18553 SessionMode::Pty,
18554 ApprovalLevel::Moderate,
18555 Some(&allowlist),
18556 None,
18557 )
18558 .unwrap();
18559 assert!(
18560 overlay.is_empty(),
18561 "{provider} must not invent network argv from permits"
18562 );
18563 }
18564 }
18565
18566 #[test]
18568 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
18569 let unique = std::time::SystemTime::now()
18570 .duration_since(std::time::UNIX_EPOCH)
18571 .unwrap()
18572 .as_nanos();
18573 let path = std::env::temp_dir().join(format!(
18574 "gate4agent-node-allowlist-json-{}-{unique}.json",
18575 std::process::id(),
18576 ));
18577 std::fs::write(
18578 &path,
18579 r#"{
18580 "schema_version": 2,
18581 "entries": [
18582 {
18583 "id": "egress-default",
18584 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
18585 },
18586 { "id": "lab-egress" }
18587 ]
18588 }"#,
18589 )
18590 .unwrap();
18591 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18592 .expect("json v2 catalog must load");
18593 let _ = std::fs::remove_file(&path);
18594 assert_eq!(loaded.len(), 2);
18595 let entry = loaded
18596 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
18597 .unwrap();
18598 assert_eq!(entry.permits.len(), 1);
18599 let (shared, defaults) = station_knobs_fixture();
18600 {
18601 let mut catalog = shared
18602 .network_allowlist_catalog
18603 .write()
18604 .unwrap_or_else(|poisoned| poisoned.into_inner());
18605 *catalog = loaded;
18606 }
18607 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18608 let spec = crate::protocol::SpawnSpec {
18609 target: crate::protocol::SpawnTarget {
18610 node_id: shared.node_id.clone(),
18611 workspace_id: WorkspaceId::new("primary").unwrap(),
18612 worktree_id: None,
18613 },
18614 profile_id: defaults.profile_id.clone(),
18615 expected_profile_revision: defaults.revision.clone(),
18616 overrides: crate::protocol::SpawnOverrides {
18617 network_allowlist: Some(allowlist.clone()),
18618 ..crate::protocol::SpawnOverrides::default()
18619 },
18620 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18621 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18622 "station-knobs-catalog-json",
18623 )
18624 .unwrap(),
18625 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18626 };
18627 let resolved = spec.resolve(&defaults).unwrap();
18628 let receipt = shared
18629 .resolve_environment_profile(&resolved)
18630 .expect("permits-only entry ok for claude")
18631 .expect("env profile present");
18632 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18633 let inventory = shared
18634 .snapshot()
18635 .launch_inventory
18636 .expect("launch inventory present");
18637 let ids = inventory.network_allowlists.expect("ids surface");
18638 assert_eq!(
18639 ids,
18640 vec![
18641 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
18642 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
18643 ]
18644 );
18645 }
18646
18647
18648}
18649
18650
18651#[cfg(test)]
18652mod workspace_environment_collision_tests {
18653 use super::*;
18654 use crate::session_environment::{
18655 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
18656 };
18657 use gate4agent_catalog::EnvMutation;
18658 use gate4agent_runtime_native::{
18659 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18660 NativeLaunchProfileId,
18661 };
18662 use std::ffi::OsString;
18663 use std::sync::Arc;
18664
18665 struct EmptyEnvironmentResolver;
18666
18667 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18668 fn resolve_child_environment(
18669 &self,
18670 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18671 Ok(vec![EnvMutation {
18672 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18673 value: None,
18674 }])
18675 }
18676 }
18677
18678 fn agent(value: &str) -> AgentId {
18679 AgentId::new(value).unwrap()
18680 }
18681
18682 fn claude_config_dir_collision_fixture() -> (
18684 NodeShared,
18685 ResolvedEnvironmentProfileReceipt,
18686 ResolvedEnvironmentProfileReceipt,
18687 ) {
18688 let catalog = active_registry().unwrap();
18689 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18690 let control = runtime.native_launch_profile_control();
18691 let workspace = WorkspaceConfig::new(
18692 WorkspaceId::new("primary").unwrap(),
18693 std::env::current_dir().unwrap(),
18694 )
18695 .unwrap();
18696 let shared = NodeShared::new_with_incarnation(
18697 handle,
18698 "fixture-token".to_owned(),
18699 NodeId::new("node-env-collision-test").unwrap(),
18700 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18701 vec![workspace],
18702 vec![agent("claude")],
18703 ProviderRuntimeStatuses::default(),
18704 None,
18705 Vec::new(),
18706 Vec::new(),
18707 SpawnProfileRegistry::default(),
18708 Some(control),
18709 None,
18710 None,
18711 None,
18712 Vec::new(),
18713 Vec::new(),
18714 Vec::new(),
18715 Vec::new(),
18716 None,
18717 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18718 );
18719
18720 let mut receipts = Vec::new();
18721 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
18722 let profile_id =
18723 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
18724 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
18725 "claude-config-dir-{suffix}-r1"
18726 ))
18727 .unwrap();
18728 let materialization = NodeSessionMaterializationProfile::new(
18729 Vec::new(),
18730 vec![NodeSessionPathBinding::new(
18731 "CLAUDE_CONFIG_DIR",
18732 NodeSessionPathClass::ProviderHome,
18733 )
18734 .unwrap()],
18735 Vec::new(),
18736 )
18737 .unwrap();
18738 let node_profile = NodeEnvironmentProfile::new_with_materialization(
18739 profile_id.clone(),
18740 profile_revision.clone(),
18741 agent("claude"),
18742 [NativeLaunchProfile::new(
18743 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
18744 .unwrap(),
18745 agent("claude"),
18746 TransportKind::Pty,
18747 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18748 Arc::new(EmptyEnvironmentResolver),
18749 )
18750 .unwrap()],
18751 Some(materialization),
18752 )
18753 .unwrap();
18754 let (binding, native_profiles, materialization) = node_profile.into_parts();
18755 for native_profile in native_profiles {
18756 runtime.upsert_native_launch_profile(native_profile).unwrap();
18757 }
18758 shared
18759 .environment_profiles
18760 .write()
18761 .unwrap_or_else(|poisoned| poisoned.into_inner())
18762 .insert(binding.id.clone(), binding);
18763 shared
18764 .environment_materialization_profiles
18765 .write()
18766 .unwrap_or_else(|poisoned| poisoned.into_inner())
18767 .insert(profile_id.clone(), materialization.unwrap());
18768 receipts.push(ResolvedEnvironmentProfileReceipt {
18769 profile_id,
18770 profile_revision,
18771 network_allowlist: None,
18772 browser_profile_id: None,
18773 });
18774 }
18775 let profile_b = receipts.pop().unwrap();
18776 let profile_a = receipts.pop().unwrap();
18777 (shared, profile_a, profile_b)
18778 }
18779
18780 #[test]
18781 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
18782 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18783 let primary = WorkspaceId::new("primary").unwrap();
18784 let worktree = WorkspaceId::new("worktree-b").unwrap();
18785 let address = SessionAddress {
18786 workspace_id: primary.clone(),
18787 session: SessionKey {
18788 instance_id: AgentInstanceId(41),
18789 generation: SessionGeneration(1),
18790 },
18791 };
18792 shared.bind_session_with_policy(
18793 &address,
18794 ProviderRuntimePolicy::raw_pty(),
18795 Some(profile_a.clone()),
18796 );
18797
18798 let refused = shared
18799 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
18800 .unwrap_err();
18801 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
18802
18803 let refused_none = shared
18805 .ensure_workspace_environment_profile_compatible(&primary, None)
18806 .unwrap_err();
18807 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
18808
18809 shared
18811 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
18812 .unwrap();
18813
18814 shared
18816 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
18817 .unwrap();
18818 }
18819
18820 #[tokio::test]
18821 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
18822 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18823 let primary = WorkspaceId::new("primary").unwrap();
18824 let address = SessionAddress {
18825 workspace_id: primary.clone(),
18826 session: SessionKey {
18827 instance_id: AgentInstanceId(41),
18828 generation: SessionGeneration(1),
18829 },
18830 };
18831 shared.bind_session_with_policy(
18832 &address,
18833 ProviderRuntimePolicy::raw_pty(),
18834 Some(profile_a),
18835 );
18836
18837 let failure = shared
18838 .spawn_session_with_deadline(
18839 primary,
18840 agent("claude"),
18841 SessionMode::Pty,
18842 gate4agent_types::TerminalSize {
18843 rows: 24,
18844 columns: 80,
18845 },
18846 None,
18847 Some(profile_b),
18848 None,
18849 None,
18850 None,
18851 None,
18852 SpawnRecordPolicy::ProviderIdentityOnly,
18853 Some(Instant::now() + Duration::from_secs(30)),
18854 &[],
18855 None,
18856 ApprovalLevel::default(),
18857 None,
18858 None,
18859 )
18860 .await
18861 .unwrap_err();
18862 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
18863 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
18865 assert!(shared.handle.snapshot().sessions.is_empty());
18866 }
18867}
18868
18869#[cfg(test)]
18870mod spawn_characterization_tests {
18871 use super::*;
18872 use crate::session_environment::{
18873 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
18874 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
18875 NodeSessionPathClass,
18876 };
18877 use gate4agent_catalog::EnvMutation;
18878 use gate4agent_runtime_native::{
18879 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
18880 NativeLaunchProfile, NativeLaunchProfileId,
18881 };
18882 use std::ffi::OsString;
18883 use std::sync::Arc;
18884
18885 struct EmptyEnvironmentResolver;
18886
18887 struct FixtureSecretResolver {
18888 deny: Arc<AtomicBool>,
18889 }
18890
18891 impl NodeSecretResolver for FixtureSecretResolver {
18892 fn resolve(
18893 &self,
18894 _reference: &NodeSecretReference,
18895 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
18896 if self.deny.load(Ordering::Acquire) {
18897 Err(NodeSecretResolveError::Denied)
18898 } else {
18899 NodeSecretValue::text("fixture-secret")
18900 .map_err(|_| NodeSecretResolveError::Unavailable)
18901 }
18902 }
18903 }
18904
18905 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18906 fn resolve_child_environment(
18907 &self,
18908 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18909 Ok(vec![EnvMutation {
18910 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18911 value: None,
18912 }])
18913 }
18914 }
18915
18916 fn agent(value: &str) -> AgentId {
18917 AgentId::new(value).unwrap()
18918 }
18919
18920 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
18921 let unique = std::time::SystemTime::now()
18922 .duration_since(std::time::UNIX_EPOCH)
18923 .unwrap()
18924 .as_nanos();
18925 std::env::temp_dir().join(format!(
18926 "gate4agent-node-{label}-{}-{unique}",
18927 std::process::id(),
18928 ))
18929 }
18930
18931 fn materialization_test_shared(
18932 deny_secret: bool,
18933 ) -> (
18934 NodeShared,
18935 NativeRuntime,
18936 ResolvedEnvironmentProfileReceipt,
18937 PathBuf,
18938 Arc<AtomicBool>,
18939 ) {
18940 let workspace = WorkspaceConfig::new(
18941 WorkspaceId::new("primary").unwrap(),
18942 std::env::current_dir().unwrap(),
18943 )
18944 .unwrap();
18945 materialization_test_shared_with_workspace(deny_secret, workspace)
18946 }
18947
18948 fn materialization_test_shared_with_workspace(
18949 deny_secret: bool,
18950 workspace: WorkspaceConfig,
18951 ) -> (
18952 NodeShared,
18953 NativeRuntime,
18954 ResolvedEnvironmentProfileReceipt,
18955 PathBuf,
18956 Arc<AtomicBool>,
18957 ) {
18958 let root = temporary_workspace_root(if deny_secret {
18959 "materialization-denied"
18960 } else {
18961 "materialization-ready"
18962 });
18963 std::fs::create_dir_all(&root).unwrap();
18964 let materialization_root = root.join("session-environments");
18965 let deny = Arc::new(AtomicBool::new(deny_secret));
18966 let resolver = Arc::new(FixtureSecretResolver {
18967 deny: Arc::clone(&deny),
18968 });
18969 let materializer = SessionEnvironmentMaterializer::new(
18970 materialization_root,
18971 resolver,
18972 )
18973 .unwrap();
18974 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
18975 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
18976 "materialized-claude-r1",
18977 )
18978 .unwrap();
18979 let materialization = NodeSessionMaterializationProfile::new(
18980 vec![NodeSessionEnvironmentMutation::SetSecret {
18981 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
18982 reference: NodeSecretReference::new("fixture-token").unwrap(),
18983 }],
18984 vec![NodeSessionPathBinding::new(
18985 "GATE4AGENT_PROVIDER_HOME",
18986 NodeSessionPathClass::ProviderHome,
18987 )
18988 .unwrap()],
18989 vec![NodeSessionFile::secret(
18990 NodeSessionPathClass::Config,
18991 "auth/token",
18992 NodeSecretReference::new("fixture-token").unwrap(),
18993 )
18994 .unwrap()],
18995 )
18996 .unwrap();
18997 let catalog = active_registry().unwrap();
18998 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18999 let control = runtime.native_launch_profile_control();
19000 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19001 profile_id.clone(),
19002 profile_revision.clone(),
19003 agent("claude"),
19004 [NativeLaunchProfile::new(
19005 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19006 agent("claude"),
19007 TransportKind::Pty,
19008 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19009 Arc::new(EmptyEnvironmentResolver),
19010 )
19011 .unwrap()],
19012 Some(materialization),
19013 )
19014 .unwrap();
19015 let (binding, native_profiles, materialization) = node_profile.into_parts();
19016 for native_profile in native_profiles {
19017 runtime.upsert_native_launch_profile(native_profile).unwrap();
19018 }
19019 let shared = NodeShared::new_with_incarnation(
19020 handle,
19021 "fixture-token".to_owned(),
19022 NodeId::new("node-materialization-test").unwrap(),
19023 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19024 vec![workspace],
19025 vec![agent("claude")],
19026 ProviderRuntimeStatuses::default(),
19027 None,
19028 Vec::new(),
19029 Vec::new(),
19030 SpawnProfileRegistry::default(),
19031 Some(control),
19032 None,
19033 Some(materializer),
19034 None,
19035 Vec::new(),
19036 Vec::new(),
19037 Vec::new(),
19038 Vec::new(),
19039 None,
19040 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19041 );
19042 shared
19043 .environment_profiles
19044 .write()
19045 .unwrap_or_else(|poisoned| poisoned.into_inner())
19046 .insert(binding.id.clone(), binding);
19047 shared
19048 .environment_materialization_profiles
19049 .write()
19050 .unwrap_or_else(|poisoned| poisoned.into_inner())
19051 .insert(profile_id.clone(), materialization.unwrap());
19052 (
19053 shared,
19054 runtime,
19055 ResolvedEnvironmentProfileReceipt {
19056 profile_id,
19057 profile_revision,
19058 network_allowlist: None,
19059 browser_profile_id: None,
19060 },
19061 root,
19062 deny,
19063 )
19064 }
19065
19066
19067 async fn characterization_spawn(
19070 shared: &NodeShared,
19071 mode: SessionMode,
19072 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19073 required: &[ProviderRuntimeCapability],
19074 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19075 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19076 shared
19077 .spawn_session_with_deadline(
19078 WorkspaceId::new("primary").unwrap(),
19079 agent("claude"),
19080 mode,
19081 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19082 None,
19083 environment_profile,
19084 None,
19085 None,
19086 None,
19087 Some(ProviderRuntimePolicy::raw_pty()),
19088 SpawnRecordPolicy::ProviderIdentityOnly,
19089 Some(Instant::now() + Duration::from_secs(30)),
19090 required,
19091 harness_mcp,
19092 ApprovalLevel::default(),
19093 None,
19094 None,
19095 )
19096 .await
19097 }
19098
19099 fn assert_no_session_state(shared: &NodeShared) {
19100 assert!(shared.handle.snapshot().sessions.is_empty());
19101 assert!(shared
19102 .session_bindings
19103 .lock()
19104 .unwrap_or_else(|poisoned| poisoned.into_inner())
19105 .is_empty());
19106 assert!(shared
19107 .materializations
19108 .lock()
19109 .unwrap_or_else(|poisoned| poisoned.into_inner())
19110 .is_empty());
19111 }
19112
19113 #[tokio::test]
19114 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19115 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19116 let error = characterization_spawn(
19117 &shared,
19118 SessionMode::Pty,
19119 None,
19120 &[ProviderRuntimeCapability::SemanticReadiness],
19121 None,
19122 )
19123 .await
19124 .unwrap_err();
19125 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19126 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19127 assert_no_session_state(&shared);
19128 drop(shared);
19129 drop(runtime);
19130 std::fs::remove_dir_all(root).unwrap();
19131 }
19132
19133 #[tokio::test]
19134 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19135 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19136 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19137 .await
19138 .unwrap_err();
19139 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19140 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19141 assert_no_session_state(&shared);
19142 drop(shared);
19143 drop(runtime);
19144 std::fs::remove_dir_all(root).unwrap();
19145 }
19146
19147 #[test]
19148 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19149 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19150 let address = SessionAddress {
19151 workspace_id: WorkspaceId::new("primary").unwrap(),
19152 session: SessionKey {
19153 instance_id: AgentInstanceId(77),
19154 generation: SessionGeneration(1),
19155 },
19156 };
19157 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19158 let removed = shared.remove_binding(&address).expect("binding removed");
19159 assert!(removed.environment_profile.is_some());
19160 assert!(shared.remove_binding(&address).is_none());
19161 assert_no_session_state(&shared);
19162 drop(shared);
19163 drop(runtime);
19164 std::fs::remove_dir_all(root).unwrap();
19165 }
19166
19167 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19168 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19169 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19170 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19171 .expect("review current test executable as fixture helper");
19172 let registry = HarnessMcpProxyRegistry::new(helper);
19173 let spec = crate::protocol::SpawnSpec {
19174 target: crate::protocol::SpawnTarget {
19175 node_id: NodeId::new("node-characterization").unwrap(),
19176 workspace_id: WorkspaceId::new("primary").unwrap(),
19177 worktree_id: None,
19178 },
19179 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19180 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19181 overrides: crate::protocol::SpawnOverrides::default(),
19182 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19183 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19184 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19185 };
19186 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19187 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19188 let launch = HarnessMcpLaunchV1 {
19189 server_name: "fixture-harness".to_owned(),
19190 args: vec!["--session-proxy".to_owned()],
19191 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19192 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19193 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19194 trace: None,
19195 scrub_env: Vec::new(),
19196 };
19197 let expires = unix_time_ms().saturating_add(30_000);
19198 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19199 registry
19200 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19201 .unwrap()
19202 }
19203
19204 #[tokio::test]
19205 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19206 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19207 let prepared = characterization_prepared_harness("claude").await;
19208 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19209 .await
19210 .unwrap_err();
19211 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19213 let mismatched = characterization_prepared_harness("codex").await;
19214 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19215 .await
19216 .unwrap_err();
19217 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19218 assert_no_session_state(&shared);
19219 drop(shared);
19220 drop(runtime);
19221 std::fs::remove_dir_all(root).unwrap();
19222 }
19223
19224 #[tokio::test]
19225 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19226 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19227 shared.native_launch_profile_control = None;
19228 let prepared = characterization_prepared_harness("claude").await;
19229 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19230 .await
19231 .unwrap_err();
19232 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19233 assert_no_session_state(&shared);
19234 drop(shared);
19235 drop(runtime);
19236 std::fs::remove_dir_all(root).unwrap();
19237 }
19238}
19239
19240#[cfg(all(test, windows))]
19241mod tests {
19242 use super::*;
19243 use gate4agent_types::ProviderActivity;
19244 use crate::session_environment::{
19245 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19246 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19247 NodeSessionPathClass,
19248 };
19249 use gate4agent_catalog::EnvMutation;
19250 use gate4agent_runtime_native::{
19251 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19252 NativeLaunchProfile, NativeLaunchProfileId,
19253 };
19254 use std::ffi::OsString;
19255 use std::sync::Arc;
19256
19257 struct EmptyEnvironmentResolver;
19258
19259 struct FixtureSecretResolver {
19260 deny: Arc<AtomicBool>,
19261 }
19262
19263 impl NodeSecretResolver for FixtureSecretResolver {
19264 fn resolve(
19265 &self,
19266 _reference: &NodeSecretReference,
19267 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19268 if self.deny.load(Ordering::Acquire) {
19269 Err(NodeSecretResolveError::Denied)
19270 } else {
19271 NodeSecretValue::text("fixture-secret")
19272 .map_err(|_| NodeSecretResolveError::Unavailable)
19273 }
19274 }
19275 }
19276
19277 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19278 fn resolve_child_environment(
19279 &self,
19280 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19281 Ok(vec![EnvMutation {
19282 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19283 value: None,
19284 }])
19285 }
19286 }
19287
19288 fn agent(value: &str) -> AgentId {
19289 AgentId::new(value).unwrap()
19290 }
19291
19292 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19293 let catalog = active_registry().unwrap();
19294 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19295 let workspace = WorkspaceConfig::new(
19296 WorkspaceId::new("primary").unwrap(),
19297 std::env::current_dir().unwrap(),
19298 )
19299 .unwrap()
19300 .with_worktree_service_mode(mode);
19301 NodeShared::new(
19302 handle,
19303 "fixture-token".to_owned(),
19304 NodeId::new("node-terminal-test").unwrap(),
19305 vec![workspace],
19306 vec![agent("claude")],
19307 )
19308 }
19309
19310 fn terminal_test_shared() -> NodeShared {
19311 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19312 }
19313
19314 #[cfg(feature = "fixture")]
19315 #[test]
19316 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19317 let probe = SpawnManagedWorktreeV2FailureProbe::default();
19318 assert_eq!(probe.latest_code(), None);
19319
19320 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19321 assert_eq!(
19322 probe.latest_code(),
19323 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19324 );
19325
19326 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19327 assert_eq!(
19328 probe.latest_code(),
19329 Some(NodeFailureCode::SpawnDeadlineExceeded),
19330 );
19331
19332 probe.record(None);
19333 assert_eq!(probe.latest_code(), None);
19334 }
19335
19336 #[test]
19337 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19338 let source = temporary_workspace_root("launch-inventory-source");
19339 let allocation = temporary_workspace_root("launch-inventory-allocation");
19340 std::fs::create_dir_all(&source).unwrap();
19341 std::fs::create_dir_all(&allocation).unwrap();
19342 let workspace_id = WorkspaceId::new("primary").unwrap();
19343 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19344 .unwrap()
19345 .with_worktree_service_mode(WorktreeServiceMode::Managed);
19346 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19347 workspace = workspace
19348 .with_managed_worktree_profile(
19349 ManagedWorktreeProfile::new(
19350 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19351 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19352 &allocation,
19353 "gate4agent",
19354 "HEAD",
19355 ManagedWorktreeRetention::RemoveWhenReleased,
19356 )
19357 .unwrap(),
19358 )
19359 .unwrap();
19360 }
19361 let overflow = workspace.clone().with_managed_worktree_profile(
19362 ManagedWorktreeProfile::new(
19363 WorktreeProfileId::new("overflow").unwrap(),
19364 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19365 &allocation,
19366 "gate4agent",
19367 "HEAD",
19368 ManagedWorktreeRetention::Retain,
19369 )
19370 .unwrap(),
19371 );
19372 assert!(matches!(
19373 overflow,
19374 Err(NodeServerError::ManagedWorktreeProfileCapacity {
19375 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19376 ..
19377 }),
19378 ));
19379
19380 let catalog = active_registry().unwrap();
19381 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19382 let shared = NodeShared::new(
19383 handle,
19384 "fixture-token".to_owned(),
19385 NodeId::new("node-launch-inventory").unwrap(),
19386 vec![workspace],
19387 vec![agent("claude")],
19388 );
19389 let snapshot = shared.snapshot();
19390 let launch = snapshot.launch_inventory.as_ref().unwrap();
19391 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19392 assert!(launch.bundles.as_ref().unwrap().is_empty());
19393 let profiles = snapshot.workspaces[0]
19394 .managed_worktree_profiles
19395 .as_ref()
19396 .unwrap();
19397 assert_eq!(
19398 snapshot.workspaces[0].worktree_service_mode,
19399 Some(WorktreeServiceMode::Managed),
19400 );
19401 assert_eq!(
19402 profiles.profiles.len(),
19403 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19404 );
19405 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19406 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19407 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19408 }
19409 let legacy = snapshot_for_wire(
19410 &shared,
19411 true,
19412 true,
19413 false,
19414 false,
19415 true,
19416 false,
19417 true,
19418 false,
19419 true,
19420 );
19421 assert!(legacy.launch_inventory.is_none());
19422 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19423
19424 drop(shared);
19425 std::fs::remove_dir_all(source).unwrap();
19426 std::fs::remove_dir_all(allocation).unwrap();
19427 }
19428
19429 #[test]
19430 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19431 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19432 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19433 }));
19434 let source = gate4agent_types::ProviderSource {
19435 family: AdapterFamily::PtySemantic,
19436 binding: AdapterBinding::new(
19437 gate4agent_types::AdapterId::new("claude").unwrap(),
19438 "fixture/v1",
19439 gate4agent_types::AdapterVerification::SyntheticFixture,
19440 )
19441 .unwrap(),
19442 };
19443 let provider = ProviderSnapshot {
19444 sequence: 17,
19445 session: Some(ProviderSessionIdentity {
19446 key: ProviderSessionKey::SessionId,
19447 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19448 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
19449 }),
19450 model: Some("PRIVATE_MODEL".to_owned()),
19451 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
19452 completed_turns: 3,
19453 usage: gate4agent_types::TokenUsage {
19454 input_tokens: 10,
19455 output_tokens: 20,
19456 cache_read_tokens: 30,
19457 cache_write_tokens: 40,
19458 reasoning_tokens: 50,
19459 context_window: Some(200_000),
19460 },
19461 lead_activity: ProviderActivity::WaitingForInput,
19462 activity: ProviderActivity::WaitingForInput,
19463 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
19464 active_tools: (0..9)
19465 .map(|index| gate4agent_types::ActiveProviderTool {
19466 id: format!("private-tool-id-{index}"),
19467 name: if index == 0 {
19468 format!(" Read\u{0000}{}", "x".repeat(80))
19469 } else {
19470 format!("Tool-{index}")
19471 },
19472 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
19473 })
19474 .collect(),
19475 interactions: vec![gate4agent_types::ProviderInteraction {
19476 id: gate4agent_types::ProviderInteractionId(17),
19477 source: source.clone(),
19478 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
19479 interaction_kind: ProviderInteractionKind::Approval,
19480 tool_name: " Approve\u{0007}Tool ".to_owned(),
19481 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
19482 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
19483 resume_lead_activity: Some(ProviderActivity::Working),
19484 status: ProviderInteractionStatus::Pending,
19485 }],
19486 subagents: vec![gate4agent_types::ProviderSubagent {
19487 source: source.clone(),
19488 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
19489 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
19490 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
19491 }],
19492 sources: vec![gate4agent_types::ProviderSourceCursor {
19493 source,
19494 sequence: 17,
19495 gap_count: 2,
19496 stale: true,
19497 }],
19498 last_event: Some(ProviderEvent::Error {
19499 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
19500 }),
19501 gap_count: 2,
19502 stale: true,
19503 };
19504 let address = terminal_address(3);
19505 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
19506 assert_eq!(entry.address, address);
19507 assert_eq!(entry.progress.provider_sequence, 17);
19508 assert_eq!(entry.progress.active_tool_count, 9);
19509 assert_eq!(
19510 entry.progress.active_tool_labels.len(),
19511 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
19512 );
19513 assert_eq!(entry.progress.subagent_count, 1);
19514 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
19515 assert!(entry.progress.truncated);
19516 let encoded = serde_json::to_string(&entry).unwrap();
19517 for forbidden in [
19518 "PRIVATE_PROVIDER_SESSION",
19519 "PRIVATE_PROVIDER_PATH",
19520 "PRIVATE_MODEL",
19521 "PRIVATE_TOOL_INVENTORY",
19522 "PRIVATE_CURRENT_PROMPT",
19523 "PRIVATE_TOOL_INPUT_JSON",
19524 "PRIVATE_PROVIDER_REQUEST",
19525 "PRIVATE_INTERACTION_PROMPT",
19526 "PRIVATE_INTERACTION_AGENT",
19527 "PRIVATE_SUBAGENT_ID",
19528 "PRIVATE_SUBAGENT_TYPE",
19529 "PRIVATE_SUBAGENT_DESCRIPTION",
19530 "PRIVATE_PROVIDER_ERROR",
19531 "input_json",
19532 "current_prompt",
19533 "session_id",
19534 ] {
19535 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
19536 }
19537
19538 let snapshot = NodeSnapshot {
19539 node_id: NodeId::new("node-progress-test").unwrap(),
19540 enabled_providers: vec![agent("claude")],
19541 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19542 workspaces: Vec::new(),
19543 session_records: Vec::new(),
19544 managed_worktrees: Vec::new(),
19545 launch_inventory: None,
19546 agent_progress: vec![entry],
19547 };
19548 let mut legacy_reply = ResponseEnvelope {
19549 request_id: 1,
19550 result: Ok(NodeResponse::Snapshot {
19551 event_sequence: 0,
19552 controller: None,
19553 snapshot: snapshot.clone(),
19554 }),
19555 };
19556 project_response_without_agent_progress(&mut legacy_reply);
19557 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
19558 panic!("agent progress projection changed response kind");
19559 };
19560 assert!(legacy.agent_progress.is_empty());
19561 assert_eq!(snapshot.agent_progress.len(), 1);
19562 }
19563
19564 fn terminal_address(generation: u64) -> SessionAddress {
19565 SessionAddress {
19566 workspace_id: WorkspaceId::new("primary").unwrap(),
19567 session: SessionKey {
19568 instance_id: AgentInstanceId(41),
19569 generation: SessionGeneration(generation),
19570 },
19571 }
19572 }
19573
19574 fn spawn_spec_fixture() -> SpawnSpec {
19575 SpawnSpec {
19576 target: crate::protocol::SpawnTarget {
19577 node_id: NodeId::new("node-terminal-test").unwrap(),
19578 workspace_id: WorkspaceId::new("primary").unwrap(),
19579 worktree_id: None,
19580 },
19581 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19582 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
19583 .unwrap(),
19584 overrides: crate::protocol::SpawnOverrides::default(),
19585 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19586 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
19587 required_capabilities: SpawnRequiredCapabilities::default(),
19588 }
19589 }
19590
19591 fn environment_profile_test_shared(
19592 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
19593 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
19594 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19595 "local-claude-r1",
19596 )
19597 .unwrap();
19598 let spawn_profiles = SpawnProfileRegistry::new([
19599 crate::protocol::SpawnProfileDefaults {
19600 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19601 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
19602 provider: agent("claude"),
19603 mode: SessionMode::Pty,
19604 terminal_size: gate4agent_types::TerminalSize {
19605 rows: 24,
19606 columns: 80,
19607 },
19608 prompt: None,
19609 bundle_id: None,
19610 context_id: None,
19611 environment_profile_id: Some(profile_id.clone()),
19612 },
19613 ])
19614 .unwrap();
19615 let catalog = active_registry().unwrap();
19616 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19617 let control = runtime.native_launch_profile_control();
19618 let node_profile = NodeEnvironmentProfile::new(
19619 profile_id.clone(),
19620 profile_revision.clone(),
19621 agent("claude"),
19622 [NativeLaunchProfile::new(
19623 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
19624 agent("claude"),
19625 TransportKind::Pty,
19626 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19627 Arc::new(EmptyEnvironmentResolver),
19628 )
19629 .unwrap()],
19630 )
19631 .unwrap();
19632 let (binding, native_profiles, _materialization) = node_profile.into_parts();
19633 for native_profile in native_profiles {
19634 runtime.upsert_native_launch_profile(native_profile).unwrap();
19635 }
19636 let workspace = WorkspaceConfig::new(
19637 WorkspaceId::new("primary").unwrap(),
19638 std::env::current_dir().unwrap(),
19639 )
19640 .unwrap();
19641 let shared = NodeShared::new_with_incarnation(
19642 handle,
19643 "fixture-token".to_owned(),
19644 NodeId::new("node-terminal-test").unwrap(),
19645 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19646 vec![workspace],
19647 vec![agent("claude")],
19648 ProviderRuntimeStatuses::default(),
19649 None,
19650 Vec::new(),
19651 Vec::new(),
19652 spawn_profiles,
19653 Some(control),
19654 None,
19655 None,
19656 None,
19657 Vec::new(),
19658 Vec::new(),
19659 Vec::new(),
19660 Vec::new(),
19661 None,
19662 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19663 );
19664 shared
19665 .environment_profiles
19666 .write()
19667 .unwrap_or_else(|poisoned| poisoned.into_inner())
19668 .insert(binding.id.clone(), binding);
19669 (
19670 shared,
19671 runtime,
19672 ResolvedEnvironmentProfileReceipt {
19673 profile_id,
19674 profile_revision,
19675 network_allowlist: None,
19676 browser_profile_id: None,
19677 },
19678 )
19679 }
19680
19681 fn materialization_test_shared(
19682 deny_secret: bool,
19683 ) -> (
19684 NodeShared,
19685 NativeRuntime,
19686 ResolvedEnvironmentProfileReceipt,
19687 PathBuf,
19688 Arc<AtomicBool>,
19689 ) {
19690 let workspace = WorkspaceConfig::new(
19691 WorkspaceId::new("primary").unwrap(),
19692 std::env::current_dir().unwrap(),
19693 )
19694 .unwrap();
19695 materialization_test_shared_with_workspace(deny_secret, workspace)
19696 }
19697
19698 fn materialization_test_shared_with_workspace(
19699 deny_secret: bool,
19700 workspace: WorkspaceConfig,
19701 ) -> (
19702 NodeShared,
19703 NativeRuntime,
19704 ResolvedEnvironmentProfileReceipt,
19705 PathBuf,
19706 Arc<AtomicBool>,
19707 ) {
19708 let root = temporary_workspace_root(if deny_secret {
19709 "materialization-denied"
19710 } else {
19711 "materialization-ready"
19712 });
19713 std::fs::create_dir_all(&root).unwrap();
19714 let materialization_root = root.join("session-environments");
19715 let deny = Arc::new(AtomicBool::new(deny_secret));
19716 let resolver = Arc::new(FixtureSecretResolver {
19717 deny: Arc::clone(&deny),
19718 });
19719 let materializer = SessionEnvironmentMaterializer::new(
19720 materialization_root,
19721 resolver,
19722 )
19723 .unwrap();
19724 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19725 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19726 "materialized-claude-r1",
19727 )
19728 .unwrap();
19729 let materialization = NodeSessionMaterializationProfile::new(
19730 vec![NodeSessionEnvironmentMutation::SetSecret {
19731 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19732 reference: NodeSecretReference::new("fixture-token").unwrap(),
19733 }],
19734 vec![NodeSessionPathBinding::new(
19735 "GATE4AGENT_PROVIDER_HOME",
19736 NodeSessionPathClass::ProviderHome,
19737 )
19738 .unwrap()],
19739 vec![NodeSessionFile::secret(
19740 NodeSessionPathClass::Config,
19741 "auth/token",
19742 NodeSecretReference::new("fixture-token").unwrap(),
19743 )
19744 .unwrap()],
19745 )
19746 .unwrap();
19747 let catalog = active_registry().unwrap();
19748 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19749 let control = runtime.native_launch_profile_control();
19750 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19751 profile_id.clone(),
19752 profile_revision.clone(),
19753 agent("claude"),
19754 [NativeLaunchProfile::new(
19755 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19756 agent("claude"),
19757 TransportKind::Pty,
19758 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19759 Arc::new(EmptyEnvironmentResolver),
19760 )
19761 .unwrap()],
19762 Some(materialization),
19763 )
19764 .unwrap();
19765 let (binding, native_profiles, materialization) = node_profile.into_parts();
19766 for native_profile in native_profiles {
19767 runtime.upsert_native_launch_profile(native_profile).unwrap();
19768 }
19769 let shared = NodeShared::new_with_incarnation(
19770 handle,
19771 "fixture-token".to_owned(),
19772 NodeId::new("node-materialization-test").unwrap(),
19773 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19774 vec![workspace],
19775 vec![agent("claude")],
19776 ProviderRuntimeStatuses::default(),
19777 None,
19778 Vec::new(),
19779 Vec::new(),
19780 SpawnProfileRegistry::default(),
19781 Some(control),
19782 None,
19783 Some(materializer),
19784 None,
19785 Vec::new(),
19786 Vec::new(),
19787 Vec::new(),
19788 Vec::new(),
19789 None,
19790 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19791 );
19792 shared
19793 .environment_profiles
19794 .write()
19795 .unwrap_or_else(|poisoned| poisoned.into_inner())
19796 .insert(binding.id.clone(), binding);
19797 shared
19798 .environment_materialization_profiles
19799 .write()
19800 .unwrap_or_else(|poisoned| poisoned.into_inner())
19801 .insert(profile_id.clone(), materialization.unwrap());
19802 (
19803 shared,
19804 runtime,
19805 ResolvedEnvironmentProfileReceipt {
19806 profile_id,
19807 profile_revision,
19808 network_allowlist: None,
19809 browser_profile_id: None,
19810 },
19811 root,
19812 deny,
19813 )
19814 }
19815
19816 #[cfg(feature = "fixture")]
19817 #[derive(Clone, Copy)]
19818 enum CodexHomeProfile {
19819 Missing,
19820 WrongClass,
19821 Exact,
19822 }
19823
19824 #[cfg(feature = "fixture")]
19825 fn codex_bundle_test_shared(
19826 home_profile: CodexHomeProfile,
19827 ) -> (
19828 NodeShared,
19829 NativeRuntime,
19830 ResolvedEnvironmentProfileReceipt,
19831 NodeBundle,
19832 PathBuf,
19833 ) {
19834 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
19835 const CLAUDE_MANIFEST: &[u8] =
19836 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
19837 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
19838 const DIGEST: &str =
19839 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
19840
19841 let (shared, mut runtime, _claude_receipt, root, _deny) =
19842 materialization_test_shared(false);
19843 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
19844 CodexHomeProfile::Missing => "codex-home-missing",
19845 CodexHomeProfile::WrongClass => "codex-home-wrong",
19846 CodexHomeProfile::Exact => "codex-home-exact",
19847 })
19848 .unwrap();
19849 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19850 "codex-home-r1",
19851 )
19852 .unwrap();
19853 let materialization = match home_profile {
19854 CodexHomeProfile::Missing => None,
19855 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
19856 Some(NodeSessionMaterializationProfile::new(
19857 Vec::new(),
19858 vec![NodeSessionPathBinding::new(
19859 "CODEX_HOME",
19860 if matches!(home_profile, CodexHomeProfile::Exact) {
19861 NodeSessionPathClass::ProviderHome
19862 } else {
19863 NodeSessionPathClass::Config
19864 },
19865 )
19866 .unwrap()],
19867 Vec::new(),
19868 )
19869 .unwrap())
19870 }
19871 };
19872 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19873 profile_id.clone(),
19874 profile_revision.clone(),
19875 agent("codex"),
19876 [NativeLaunchProfile::new(
19877 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
19878 agent("codex"),
19879 TransportKind::Pty,
19880 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19881 Arc::new(EmptyEnvironmentResolver),
19882 )
19883 .unwrap()],
19884 materialization,
19885 )
19886 .unwrap();
19887 let (binding, native_profiles, materialization) = node_profile.into_parts();
19888 for native_profile in native_profiles {
19889 runtime.upsert_native_launch_profile(native_profile).unwrap();
19890 }
19891 shared
19892 .environment_profiles
19893 .write()
19894 .unwrap_or_else(|poisoned| poisoned.into_inner())
19895 .insert(binding.id.clone(), binding);
19896 if let Some(materialization) = materialization {
19897 shared
19898 .environment_materialization_profiles
19899 .write()
19900 .unwrap_or_else(|poisoned| poisoned.into_inner())
19901 .insert(profile_id.clone(), materialization);
19902 }
19903
19904 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
19905 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
19906 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
19907 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
19908 std::fs::write(
19909 bundle_source.join(".claude-plugin/plugin.json"),
19910 CLAUDE_MANIFEST,
19911 )
19912 .unwrap();
19913 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
19914 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
19915 let bundle = NodeBundle::new(
19916 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
19917 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
19918 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
19919 &bundle_source,
19920 )
19921 .unwrap();
19922 *shared
19923 .bundle_catalog
19924 .write()
19925 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
19926 BundleCatalog::new([bundle.clone()]).unwrap();
19927 (
19928 shared,
19929 runtime,
19930 ResolvedEnvironmentProfileReceipt {
19931 profile_id,
19932 profile_revision,
19933 network_allowlist: None,
19934 browser_profile_id: None,
19935 },
19936 bundle,
19937 root,
19938 )
19939 }
19940
19941 #[cfg(feature = "fixture")]
19942 #[test]
19943 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
19944 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
19945 let (shared, runtime, receipt, bundle, root) =
19946 codex_bundle_test_shared(home_profile);
19947 let error = shared
19948 .bundle_layout(
19949 &agent("codex"),
19950 SessionMode::Pty,
19951 Some(&receipt),
19952 &bundle,
19953 )
19954 .unwrap_err();
19955 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
19956 assert!(shared
19957 .materializations
19958 .lock()
19959 .unwrap_or_else(|poisoned| poisoned.into_inner())
19960 .is_empty());
19961 let materialization_root = root.join("session-environments");
19962 assert!(!std::fs::read_dir(&materialization_root)
19963 .unwrap()
19964 .filter_map(Result::ok)
19965 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
19966 drop(shared);
19967 drop(runtime);
19968 let _ = std::fs::remove_dir_all(root);
19969 }
19970 }
19971
19972 #[cfg(feature = "fixture")]
19973 #[test]
19974 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
19975 let (shared, runtime, receipt, bundle, root) =
19976 codex_bundle_test_shared(CodexHomeProfile::Exact);
19977 let address = SessionAddress {
19978 workspace_id: WorkspaceId::new("primary").unwrap(),
19979 session: SessionKey {
19980 instance_id: AgentInstanceId(310),
19981 generation: SessionGeneration(1),
19982 },
19983 };
19984 let (overlay, guard) = shared
19985 .prepare_session_materialization(
19986 &address,
19987 &agent("codex"),
19988 SessionMode::Pty,
19989 Some(&receipt),
19990 Some(&bundle.receipt()),
19991 None,
19992 None,
19993 )
19994 .unwrap()
19995 .unwrap();
19996 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
19997 let ownership = shared
19998 .materializations
19999 .lock()
20000 .unwrap_or_else(|poisoned| poisoned.into_inner())
20001 .get(guard.id().unwrap())
20002 .unwrap()
20003 .clone();
20004 let (profile, _, _) = shared
20005 .materialization_profile(
20006 &agent("codex"),
20007 SessionMode::Pty,
20008 Some(&receipt),
20009 Some(&bundle.receipt()),
20010 None,
20011 )
20012 .unwrap()
20013 .unwrap();
20014 let environment = shared
20015 .session_environment_materializer
20016 .as_ref()
20017 .unwrap()
20018 .resolve_environment(&ownership, &profile)
20019 .unwrap();
20020 assert!(environment.iter().any(|mutation| {
20021 mutation.key == OsString::from("CODEX_HOME")
20022 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20023 }));
20024 assert_eq!(
20025 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20026 .unwrap(),
20027 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20028 );
20029 assert!(std::fs::read_dir(ownership.bundle_root())
20030 .unwrap()
20031 .next()
20032 .is_none());
20033 drop(guard);
20034 drop(shared);
20035 drop(runtime);
20036 let _ = std::fs::remove_dir_all(root);
20037 }
20038
20039 #[cfg(feature = "fixture")]
20040 #[test]
20041 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20042 let (shared, runtime, receipt, bundle, root) =
20043 codex_bundle_test_shared(CodexHomeProfile::Exact);
20044 let bundle_receipt = bundle.receipt();
20045 let address = SessionAddress {
20046 workspace_id: WorkspaceId::new("primary").unwrap(),
20047 session: SessionKey {
20048 instance_id: AgentInstanceId(311),
20049 generation: SessionGeneration(1),
20050 },
20051 };
20052 let (_overlay, guard) = shared
20053 .prepare_session_materialization(
20054 &address,
20055 &agent("codex"),
20056 SessionMode::Pty,
20057 Some(&receipt),
20058 Some(&bundle_receipt),
20059 None,
20060 None,
20061 )
20062 .unwrap()
20063 .unwrap();
20064 let materialization_id = guard.id().unwrap().clone();
20065 let skill_path = shared
20066 .materializations
20067 .lock()
20068 .unwrap_or_else(|poisoned| poisoned.into_inner())
20069 .get(&materialization_id)
20070 .unwrap()
20071 .provider_home()
20072 .join("skills/review-code/SKILL.md");
20073 let record_id = shared
20074 .bind_spawn_session_with_materialization(
20075 &address,
20076 agent("codex"),
20077 SessionMode::Pty,
20078 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20079 SpawnRecordPolicy::ProviderIdentityOnly,
20080 Some(receipt.clone()),
20081 Some(bundle_receipt.clone()),
20082 None,
20083 Some(materialization_id.clone()),
20084 )
20085 .unwrap()
20086 .unwrap();
20087 guard.retain();
20088 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20089
20090 let error = match shared.resolve_record_materialization(
20091 &record_id,
20092 &agent("codex"),
20093 SessionMode::Pty,
20094 Some(&receipt),
20095 Some(&bundle_receipt),
20096 None,
20097 ) {
20098 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20099 Err(error) => error,
20100 };
20101 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20102 assert_eq!(
20103 shared
20104 .materializations
20105 .lock()
20106 .unwrap_or_else(|poisoned| poisoned.into_inner())
20107 .get(&materialization_id)
20108 .unwrap()
20109 .state(),
20110 MaterializationState::RecoveryRequired,
20111 );
20112 drop(shared);
20113 drop(runtime);
20114 let _ = std::fs::remove_dir_all(root);
20115 }
20116
20117 fn bind_record_owned_materialization_fixture(
20118 shared: &NodeShared,
20119 receipt: &ResolvedEnvironmentProfileReceipt,
20120 instance_id: u64,
20121 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20122 let address = SessionAddress {
20123 workspace_id: WorkspaceId::new("primary").unwrap(),
20124 session: SessionKey {
20125 instance_id: AgentInstanceId(instance_id),
20126 generation: SessionGeneration(1),
20127 },
20128 };
20129 let (overlay, materialization_guard) = shared
20130 .prepare_session_materialization(
20131 &address,
20132 &agent("claude"),
20133 SessionMode::Pty,
20134 Some(receipt),
20135 None,
20136 None,
20137 None,
20138 )
20139 .unwrap()
20140 .unwrap();
20141 let materialization_id = materialization_guard.id().unwrap().clone();
20142 let materialization_root = shared
20143 .materializations
20144 .lock()
20145 .unwrap_or_else(|poisoned| poisoned.into_inner())
20146 .get(&materialization_id)
20147 .unwrap()
20148 .root()
20149 .to_path_buf();
20150 let selection = shared
20151 .select_environment_profile(
20152 address.session.instance_id,
20153 &agent("claude"),
20154 SessionMode::Pty,
20155 Some(receipt),
20156 )
20157 .unwrap()
20158 .unwrap();
20159 assert!(shared
20160 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20161 .unwrap()
20162 .is_none());
20163 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20164 let record_id = shared
20165 .bind_spawn_session_with_materialization(
20166 &address,
20167 agent("claude"),
20168 SessionMode::Pty,
20169 policy,
20170 SpawnRecordPolicy::ProviderIdentityOnly,
20171 Some(receipt.clone()),
20172 None,
20173 None,
20174 Some(materialization_id.clone()),
20175 )
20176 .unwrap()
20177 .unwrap();
20178 selection.retain();
20179 materialization_guard.retain();
20180 (address, record_id, materialization_id, materialization_root)
20181 }
20182
20183 #[test]
20184 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20185 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20186 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20187 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20188 }));
20189 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20190 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20191 let mut spec = spawn_spec_fixture();
20192 spec.expected_profile_revision =
20193 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20194 assert!(matches!(
20195 &spec.overrides.environment_profile_id,
20196 crate::protocol::SpawnOverride::Inherit,
20197 ));
20198 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20199 .requires_child_environment_profile_capability());
20200 assert!(request_requires_child_environment_profile(
20201 &shared,
20202 &NodeRequest::SpawnSpec { spec: spec.clone() },
20203 ));
20204
20205 let address = terminal_address(1);
20206 shared.bind_session_with_policy(
20207 &address,
20208 ProviderRuntimePolicy::raw_pty(),
20209 Some(environment_profile.clone()),
20210 );
20211 assert!(request_requires_child_environment_profile(
20212 &shared,
20213 &NodeRequest::Input {
20214 session: address.clone(),
20215 text: "x".to_owned(),
20216 },
20217 ));
20218
20219 let mut record = record("claude", "profile-record");
20220 record.active_session = Some(address.clone());
20221 record.environment_profile = Some(environment_profile.clone());
20222 let record_id = record.record_id.clone();
20223 shared.insert_record(record.clone()).unwrap();
20224 assert!(request_requires_child_environment_profile(
20225 &shared,
20226 &NodeRequest::ResumeSessionRecord {
20227 record_id,
20228 terminal_size: gate4agent_types::TerminalSize {
20229 rows: 24,
20230 columns: 80,
20231 },
20232 initial_prompt: None,
20233 },
20234 ));
20235
20236 let snapshot = snapshot_for_wire(
20237 &shared, false, true, true, true, false, false, false, false, true,
20238 );
20239 assert_eq!(snapshot.session_records.len(), 1);
20240 assert!(snapshot.session_records[0].environment_profile.is_none());
20241 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20242 sequence: 1,
20243 event: NodeEvent::SessionRecordUpserted { record },
20244 });
20245 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20246 panic!("record projection changed the event kind");
20247 };
20248 assert!(record.environment_profile.is_none());
20249
20250 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20251 let mut reply = ResponseEnvelope {
20252 request_id: 1,
20253 result: Ok(NodeResponse::SpawnSpecAccepted {
20254 receipt: resolved.receipt_with_environment(
20255 shared.incarnation_id,
20256 address,
20257 Some(environment_profile),
20258 ),
20259 }),
20260 };
20261 project_response_without_child_environment_profile(&mut reply);
20262 assert_eq!(
20263 reply.result.unwrap_err().code,
20264 NodeFailureCode::UnsupportedCapability,
20265 );
20266 }
20267
20268 #[test]
20269 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20270 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20271 let snapshot = shared.snapshot();
20272 let profiles = snapshot
20273 .launch_inventory
20274 .as_ref()
20275 .and_then(|inventory| inventory.spawn_profiles.as_ref())
20276 .unwrap();
20277 assert_eq!(profiles.len(), 1);
20278 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20279 assert!(snapshot.requires_child_environment_profile_capability());
20280
20281 let mut legacy = snapshot.clone();
20282 clear_snapshot_child_environment_profiles(&mut legacy);
20283 assert!(legacy
20284 .launch_inventory
20285 .as_ref()
20286 .unwrap()
20287 .spawn_profiles
20288 .as_ref()
20289 .unwrap()[0]
20290 .environment_profile
20291 .is_none());
20292 assert!(!legacy.requires_child_environment_profile_capability());
20293
20294 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20295 let bindings = shared
20296 .environment_profiles
20297 .read()
20298 .unwrap_or_else(|poisoned| poisoned.into_inner())
20299 .clone();
20300 let mut provider_mismatch = bindings.clone();
20301 provider_mismatch
20302 .values_mut()
20303 .next()
20304 .unwrap()
20305 .provider = agent("codex");
20306 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20307 let mut mode_mismatch = profile;
20308 mode_mismatch.mode = SessionMode::Inline;
20309 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20310
20311 shared
20312 .environment_profiles
20313 .write()
20314 .unwrap_or_else(|poisoned| poisoned.into_inner())
20315 .clear();
20316 assert!(shared
20317 .snapshot()
20318 .launch_inventory
20319 .unwrap()
20320 .spawn_profiles
20321 .unwrap()
20322 .is_empty());
20323 }
20324
20325 #[test]
20326 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20327 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20328 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20329 }));
20330 let shared = terminal_test_shared();
20331 let bundle = ResolvedBundleReceipt {
20332 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20333 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20334 digest: crate::protocol::SpawnBundleDigest::new(format!(
20335 "sha256:{}",
20336 "0".repeat(64),
20337 ))
20338 .unwrap(),
20339 };
20340 let mut bundled = record("claude", "bundle-record");
20341 bundled.bundle = Some(bundle.clone());
20342 let record_id = bundled.record_id.clone();
20343 shared.insert_record(bundled.clone()).unwrap();
20344 assert!(request_requires_session_bundle(
20345 &shared,
20346 &NodeRequest::ForgetSessionRecord {
20347 record_id: record_id.clone(),
20348 },
20349 ));
20350
20351 let snapshot = snapshot_for_wire(
20352 &shared, false, true, true, true, true, false, false, false, true,
20353 );
20354 assert!(snapshot.session_records[0].bundle.is_none());
20355 let launch = snapshot.launch_inventory.as_ref().unwrap();
20356 assert!(launch.spawn_profiles.is_some());
20357 assert!(launch.bundles.is_none());
20358 let event = project_event_without_session_bundle(NodeEventEnvelope {
20359 sequence: 1,
20360 event: NodeEvent::SessionRecordUpserted { record: bundled },
20361 });
20362 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20363 panic!("record projection changed the event kind");
20364 };
20365 assert!(record.bundle.is_none());
20366
20367 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20368 let mut reply = ResponseEnvelope {
20369 request_id: 1,
20370 result: Ok(NodeResponse::SpawnSpecAccepted {
20371 receipt: resolved.receipt_with_materialization(
20372 shared.incarnation_id,
20373 terminal_address(17),
20374 None,
20375 Some(bundle),
20376 None,
20377 ),
20378 }),
20379 };
20380 project_response_without_session_bundle(&mut reply);
20381 assert_eq!(
20382 reply.result.unwrap_err().code,
20383 NodeFailureCode::UnsupportedCapability,
20384 );
20385 }
20386
20387 #[test]
20388 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20389 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20390 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20391 }));
20392 let context_id = SpawnContextId::new("context-review").unwrap();
20393 let context = ResolvedContextPackReceipt {
20394 id: context_id.clone(),
20395 digest: crate::protocol::SpawnContextDigest::new(format!(
20396 "sha256:{}",
20397 "c".repeat(64),
20398 ))
20399 .unwrap(),
20400 lineage: ContextPackLineageReceipt {
20401 source_node_id: NodeId::new("node-source").unwrap(),
20402 source_session: terminal_address(1),
20403 source_provider: agent("kimi"),
20404 },
20405 source_message_count: 2,
20406 retained_message_count: 2,
20407 byte_len: 256,
20408 truncated: false,
20409 };
20410 assert!(context.is_valid());
20411 let mut bound_record = record("codex", "context-record");
20412 bound_record.context_id = Some(context_id);
20413 bound_record.context = Some(context.clone());
20414 let session = gate4agent_types::SessionSnapshot {
20415 instance_id: AgentInstanceId(91),
20416 agent_id: agent("kimi"),
20417 transport: TransportKind::Pty,
20418 generation: SessionGeneration(3),
20419 status: SessionStatus::Running,
20420 pending_operation: None,
20421 pending_input: None,
20422 process_id: Some(9001),
20423 terminal_size: None,
20424 terminal_frame: None,
20425 terminal_stale: None,
20426 session_options: None,
20427 capabilities: gate4agent_types::CapabilitySnapshot::default(),
20428 history: gate4agent_types::HistorySnapshot {
20429 pending: None,
20430 candidates: vec![HistoryCandidateSummary {
20431 id: "candidate-opaque".to_owned(),
20432 session_id_hint: "session-hint".to_owned(),
20433 modified_at_unix_ms: Some(1),
20434 }],
20435 loaded_candidate_id: Some("candidate-opaque".to_owned()),
20436 loaded: Some(HistorySessionRecord {
20437 session_id: "private-vendor-session".to_owned(),
20438 title: Some("private title".to_owned()),
20439 cwd: Some(r"C:\private\history-root".to_owned()),
20440 model: Some("private-model".to_owned()),
20441 message_count: 2,
20442 completed_turn_count: None,
20443 total_tokens: 19,
20444 messages: vec![
20445 gate4agent_types::HistoryMessageRecord {
20446 role: gate4agent_types::HistoryMessageRole::User,
20447 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20448 },
20449 gate4agent_types::HistoryMessageRecord {
20450 role: gate4agent_types::HistoryMessageRole::Assistant,
20451 text: "private answer".to_owned(),
20452 },
20453 ],
20454 }),
20455 last_error: None,
20456 },
20457 resume: gate4agent_types::ResumeSnapshot::default(),
20458 foreground: gate4agent_types::ForegroundSnapshot::default(),
20459 provider: gate4agent_types::ProviderSnapshot::default(),
20460 screen_state: gate4agent_types::PtyScreenState::default(),
20461 };
20462 let original = NodeSnapshot {
20463 node_id: NodeId::new("node-terminal-test").unwrap(),
20464 enabled_providers: vec![agent("kimi"), agent("codex")],
20465 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20466 workspaces: vec![WorkspaceSnapshot {
20467 workspace_id: WorkspaceId::new("primary").unwrap(),
20468 canonical_root: opaque_windows_path(
20469 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20470 ),
20471 sessions: vec![session],
20472 worktree_service_mode: None,
20473 managed_worktree_profiles: None,
20474 }],
20475 session_records: vec![bound_record],
20476 managed_worktrees: Vec::new(),
20477 launch_inventory: None,
20478 agent_progress: Vec::new(),
20479 };
20480
20481 let mut negotiated = original.clone();
20482 project_snapshot_history_for_wire(&mut negotiated, true);
20483 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
20484 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
20485 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
20486 assert!(!serde_json::to_string(&negotiated)
20487 .unwrap()
20488 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
20489
20490 let mut legacy = original;
20491 project_snapshot_history_for_wire(&mut legacy, false);
20492 clear_snapshot_context_packs(&mut legacy);
20493 assert_eq!(
20494 legacy.workspaces[0].sessions[0].history,
20495 gate4agent_types::HistorySnapshot::default(),
20496 );
20497 assert!(legacy.session_records[0].context_id.is_none());
20498 assert!(legacy.session_records[0].context.is_none());
20499 let encoded = serde_json::to_string(&legacy).unwrap();
20500 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
20501 assert!(!encoded.contains("context-review"));
20502
20503 let mut reply = ResponseEnvelope {
20504 request_id: 7,
20505 result: Ok(NodeResponse::ContextPackExported { context }),
20506 };
20507 project_response_without_context_pack(&mut reply);
20508 assert_eq!(
20509 reply.result.unwrap_err().code,
20510 NodeFailureCode::UnsupportedCapability,
20511 );
20512 }
20513
20514 #[test]
20515 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
20516 let shared = terminal_test_shared();
20517 let history = HistorySessionRecord {
20518 session_id: "fixture-history".to_owned(),
20519 title: None,
20520 cwd: None,
20521 model: None,
20522 message_count: 1,
20523 completed_turn_count: None,
20524 total_tokens: 0,
20525 messages: vec![gate4agent_types::HistoryMessageRecord {
20526 role: gate4agent_types::HistoryMessageRole::User,
20527 text: "bounded handoff".to_owned(),
20528 }],
20529 };
20530 let pack = NodeContextPack::export(
20531 ContextPackLineageReceipt {
20532 source_node_id: shared.node_id.clone(),
20533 source_session: terminal_address(1),
20534 source_provider: agent("claude"),
20535 },
20536 &history,
20537 )
20538 .unwrap();
20539 let context = shared
20540 .context_catalog
20541 .write()
20542 .unwrap_or_else(|poisoned| poisoned.into_inner())
20543 .insert(pack)
20544 .unwrap();
20545 let mut retained = record("codex", "context-retained-record");
20546 retained.context_id = Some(context.id.clone());
20547 retained.context = Some(context.clone());
20548 let record_id = retained.record_id.clone();
20549 shared.insert_record(retained).unwrap();
20550 assert_eq!(
20551 shared.forget_context_pack(&context.id).unwrap_err().code,
20552 NodeFailureCode::ContextPackBusy,
20553 );
20554 assert!(shared
20555 .context_catalog
20556 .read()
20557 .unwrap_or_else(|poisoned| poisoned.into_inner())
20558 .get(&context.id)
20559 .is_some());
20560 shared
20561 .session_records
20562 .lock()
20563 .unwrap_or_else(|poisoned| poisoned.into_inner())
20564 .records
20565 .remove(&record_id);
20566
20567 let mut spec = spawn_spec_fixture();
20568 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
20569 value: context.id.clone(),
20570 };
20571 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20572 let receipt = resolved.receipt_with_materialization(
20573 shared.incarnation_id,
20574 terminal_address(2),
20575 None,
20576 None,
20577 Some(context.clone()),
20578 );
20579 assert!(receipt.context_binding_is_valid());
20580 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
20581
20582 shared.forget_context_pack(&context.id).unwrap();
20583 assert!(shared
20584 .context_catalog
20585 .read()
20586 .unwrap_or_else(|poisoned| poisoned.into_inner())
20587 .get(&context.id)
20588 .is_none());
20589 assert!(shared
20590 .replay_spawn_spec(&spec, Instant::now())
20591 .unwrap()
20592 .is_none());
20593 }
20594
20595 #[test]
20596 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
20597 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20598 let address = terminal_address(1);
20599 let selection = shared
20600 .select_environment_profile(
20601 address.session.instance_id,
20602 &agent("claude"),
20603 SessionMode::Pty,
20604 Some(&environment_profile),
20605 )
20606 .unwrap()
20607 .unwrap();
20608 shared.bind_session_with_policy(
20609 &address,
20610 ProviderRuntimePolicy::raw_pty(),
20611 Some(environment_profile.clone()),
20612 );
20613 selection.retain();
20614 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20615 assert!(matches!(
20616 runtime.remove_native_launch_profile(&native_id),
20617 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20618 ));
20619 assert!(shared.remove_binding(&address).is_some());
20620 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20621
20622 let mismatched = ResolvedEnvironmentProfileReceipt {
20623 profile_id: environment_profile.profile_id,
20624 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
20625 "local-claude-r2",
20626 )
20627 .unwrap(),
20628 network_allowlist: None,
20629 browser_profile_id: None,
20630 };
20631 let error = match shared.select_environment_profile(
20632 AgentInstanceId(42),
20633 &agent("claude"),
20634 SessionMode::Pty,
20635 Some(&mismatched),
20636 ) {
20637 Ok(_) => panic!("mismatched environment profile revision was selected"),
20638 Err(error) => error,
20639 };
20640 assert_eq!(
20641 error.code,
20642 NodeFailureCode::EnvironmentProfileBindingMismatch,
20643 );
20644 assert!(!shared
20645 .session_bindings
20646 .lock()
20647 .unwrap_or_else(|poisoned| poisoned.into_inner())
20648 .contains_key(&AgentInstanceId(42)));
20649 }
20650
20651 #[test]
20652 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
20653 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20654 let address = terminal_address(1);
20655 let selection = shared
20656 .select_environment_profile(
20657 address.session.instance_id,
20658 &agent("claude"),
20659 SessionMode::Pty,
20660 Some(&environment_profile),
20661 )
20662 .unwrap()
20663 .unwrap();
20664 let overlay = NativeInstanceLaunchOverlay::new(
20665 agent("claude"),
20666 TransportKind::Pty,
20667 vec![EnvMutation {
20668 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
20669 value: Some(OsString::from("fixture-secret")),
20670 }],
20671 vec![
20672 OsString::from("--plugin-dir"),
20673 std::env::current_dir().unwrap().into_os_string(),
20674 ],
20675 )
20676 .unwrap();
20677 let instance_overlay = shared
20678 .install_prepared_launch_overlay(
20679 address.session.instance_id,
20680 PreparedNativeLaunchOverlay::Instance(overlay),
20681 )
20682 .unwrap()
20683 .unwrap();
20684 let bundle = ResolvedBundleReceipt {
20685 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
20686 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
20687 digest: crate::protocol::SpawnBundleDigest::new(format!(
20688 "sha256:{}",
20689 "0".repeat(64),
20690 ))
20691 .unwrap(),
20692 };
20693 shared.bind_session_with_materialization(
20694 &address,
20695 ProviderRuntimePolicy::raw_pty(),
20696 Some(environment_profile),
20697 Some(bundle),
20698 None,
20699 None,
20700 );
20701 selection.retain();
20702 instance_overlay.retain();
20703
20704 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20705 assert!(matches!(
20706 runtime.remove_native_launch_profile(&native_id),
20707 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20708 ));
20709 assert!(shared.remove_binding(&address).is_some());
20710 assert!(!shared
20711 .native_launch_profile_control
20712 .as_ref()
20713 .unwrap()
20714 .clear_native_instance_launch_overlay(address.session.instance_id));
20715 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20716 }
20717
20718 #[test]
20719 fn session_environment_materialization_failure_is_pre_child_and_durable() {
20720 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20721 let address = SessionAddress {
20722 workspace_id: WorkspaceId::new("primary").unwrap(),
20723 session: SessionKey {
20724 instance_id: AgentInstanceId(201),
20725 generation: SessionGeneration(1),
20726 },
20727 };
20728 let error = match shared.prepare_session_materialization(
20729 &address,
20730 &agent("claude"),
20731 SessionMode::Pty,
20732 Some(&receipt),
20733 None,
20734 None,
20735 None,
20736 ) {
20737 Ok(_) => panic!("denied local reference unexpectedly materialized"),
20738 Err(error) => error,
20739 };
20740 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20741 assert!(shared.handle.snapshot().sessions.is_empty());
20742 assert!(shared
20743 .session_bindings
20744 .lock()
20745 .unwrap_or_else(|poisoned| poisoned.into_inner())
20746 .is_empty());
20747 assert!(shared
20748 .materializations
20749 .lock()
20750 .unwrap_or_else(|poisoned| poisoned.into_inner())
20751 .is_empty());
20752 drop(shared);
20753 drop(runtime);
20754 std::fs::remove_dir_all(root).unwrap();
20755 }
20756
20757 #[test]
20758 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
20759 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20760 let address = SessionAddress {
20761 workspace_id: WorkspaceId::new("primary").unwrap(),
20762 session: SessionKey {
20763 instance_id: AgentInstanceId(202),
20764 generation: SessionGeneration(1),
20765 },
20766 };
20767 let (overlay, materialization_guard) = shared
20768 .prepare_session_materialization(
20769 &address,
20770 &agent("claude"),
20771 SessionMode::Pty,
20772 Some(&receipt),
20773 None,
20774 None,
20775 None,
20776 )
20777 .unwrap()
20778 .unwrap();
20779 let materialization_id = materialization_guard.id().unwrap().clone();
20780 let materialization_root = shared
20781 .materializations
20782 .lock()
20783 .unwrap_or_else(|poisoned| poisoned.into_inner())
20784 .get(&materialization_id)
20785 .unwrap()
20786 .root()
20787 .to_path_buf();
20788 let selection = shared
20789 .select_environment_profile(
20790 address.session.instance_id,
20791 &agent("claude"),
20792 SessionMode::Pty,
20793 Some(&receipt),
20794 )
20795 .unwrap()
20796 .unwrap();
20797 assert!(shared
20798 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20799 .unwrap()
20800 .is_none());
20801 shared.bind_session_with_materialization(
20802 &address,
20803 ProviderRuntimePolicy::raw_pty(),
20804 Some(receipt),
20805 None,
20806 None,
20807 Some(materialization_id),
20808 );
20809 selection.retain();
20810 materialization_guard.retain();
20811
20812 assert!(materialization_root.is_dir());
20813 assert!(shared
20814 .session_bindings
20815 .lock()
20816 .unwrap_or_else(|poisoned| poisoned.into_inner())
20817 .contains_key(&address.session.instance_id));
20818
20819 let removed = shared.remove_binding(&address).unwrap();
20820 shared
20821 .cleanup_session_owned_materialization(&address, &removed)
20822 .unwrap();
20823 assert!(!materialization_root.exists());
20824 assert!(shared
20825 .materializations
20826 .lock()
20827 .unwrap_or_else(|poisoned| poisoned.into_inner())
20828 .is_empty());
20829 drop(shared);
20830 drop(runtime);
20831 std::fs::remove_dir_all(root).unwrap();
20832 }
20833
20834 #[tokio::test]
20835 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
20836 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20837 let address = SessionAddress {
20838 workspace_id: WorkspaceId::new("primary").unwrap(),
20839 session: SessionKey {
20840 instance_id: AgentInstanceId(203),
20841 generation: SessionGeneration(1),
20842 },
20843 };
20844 let (overlay, materialization_guard) = shared
20845 .prepare_session_materialization(
20846 &address,
20847 &agent("claude"),
20848 SessionMode::Pty,
20849 Some(&receipt),
20850 None,
20851 None,
20852 None,
20853 )
20854 .unwrap()
20855 .unwrap();
20856 let materialization_id = materialization_guard.id().unwrap().clone();
20857 let materialization_root = shared
20858 .materializations
20859 .lock()
20860 .unwrap_or_else(|poisoned| poisoned.into_inner())
20861 .get(&materialization_id)
20862 .unwrap()
20863 .root()
20864 .to_path_buf();
20865 let selection = shared
20866 .select_environment_profile(
20867 address.session.instance_id,
20868 &agent("claude"),
20869 SessionMode::Pty,
20870 Some(&receipt),
20871 )
20872 .unwrap()
20873 .unwrap();
20874 assert!(shared
20875 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20876 .unwrap()
20877 .is_none());
20878 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20879 let record_id = shared
20880 .bind_spawn_session_with_materialization(
20881 &address,
20882 agent("claude"),
20883 SessionMode::Pty,
20884 policy,
20885 SpawnRecordPolicy::ProviderIdentityOnly,
20886 Some(receipt.clone()),
20887 None,
20888 None,
20889 Some(materialization_id.clone()),
20890 )
20891 .unwrap()
20892 .unwrap();
20893 selection.retain();
20894 materialization_guard.retain();
20895 assert_eq!(
20896 shared
20897 .materializations
20898 .lock()
20899 .unwrap_or_else(|poisoned| poisoned.into_inner())
20900 .get(&materialization_id)
20901 .unwrap()
20902 .owner(),
20903 &MaterializationOwner::Record {
20904 record_id: record_id.clone(),
20905 },
20906 );
20907
20908 let removed = shared.remove_binding(&address).unwrap();
20909 shared
20910 .cleanup_session_owned_materialization(&address, &removed)
20911 .unwrap();
20912 assert!(materialization_root.is_dir());
20913 assert!(shared
20914 .resolve_record_materialization(
20915 &record_id,
20916 &agent("claude"),
20917 SessionMode::Pty,
20918 Some(&receipt),
20919 None,
20920 None,
20921 )
20922 .unwrap()
20923 .is_some());
20924 {
20925 let mut records = shared
20926 .session_records
20927 .lock()
20928 .unwrap_or_else(|poisoned| poisoned.into_inner());
20929 let record = records.records.get_mut(&record_id).unwrap();
20930 record.active_session = None;
20931 record.state = ManagedSessionState::Unavailable;
20932 }
20933 shared.forget_session_record(&record_id).await.unwrap();
20934 assert!(!materialization_root.exists());
20935 assert!(shared
20936 .materializations
20937 .lock()
20938 .unwrap_or_else(|poisoned| poisoned.into_inner())
20939 .is_empty());
20940 drop(shared);
20941 drop(runtime);
20942 std::fs::remove_dir_all(root).unwrap();
20943 }
20944
20945 #[test]
20946 fn record_materialization_revalidation_failure_persists_recovery_required() {
20947 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20948 let state_path = root.join("node-state-v6.json");
20949 shared.state_path = Some(state_path.clone());
20950 let (_address, _record_id, materialization_id, materialization_root) =
20951 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
20952 std::fs::write(
20953 materialization_root.join(".gate4agent-materialization-owner"),
20954 b"tampered",
20955 )
20956 .unwrap();
20957
20958 let error = match shared.resolve_record_materialization(
20959 &_record_id,
20960 &agent("claude"),
20961 SessionMode::Pty,
20962 Some(&receipt),
20963 None,
20964 None,
20965 ) {
20966 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
20967 Err(error) => error,
20968 };
20969 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20970 assert_eq!(
20971 shared
20972 .materializations
20973 .lock()
20974 .unwrap_or_else(|poisoned| poisoned.into_inner())
20975 .get(&materialization_id)
20976 .unwrap()
20977 .state(),
20978 MaterializationState::RecoveryRequired,
20979 );
20980 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
20981 assert_eq!(loaded.materializations.len(), 1);
20982 assert_eq!(
20983 loaded.materializations[0].state(),
20984 MaterializationState::RecoveryRequired,
20985 );
20986 drop(shared);
20987 drop(runtime);
20988 std::fs::remove_dir_all(root).unwrap();
20989 }
20990
20991 #[test]
20992 fn transient_record_materialization_resolution_failure_remains_ready() {
20993 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
20994 let (_address, record_id, materialization_id, _materialization_root) =
20995 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
20996 deny.store(true, Ordering::Release);
20997
20998 let error = match shared.resolve_record_materialization(
20999 &record_id,
21000 &agent("claude"),
21001 SessionMode::Pty,
21002 Some(&receipt),
21003 None,
21004 None,
21005 ) {
21006 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21007 Err(error) => error,
21008 };
21009 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21010 assert_eq!(
21011 shared
21012 .materializations
21013 .lock()
21014 .unwrap_or_else(|poisoned| poisoned.into_inner())
21015 .get(&materialization_id)
21016 .unwrap()
21017 .state(),
21018 MaterializationState::Ready,
21019 );
21020 drop(shared);
21021 drop(runtime);
21022 std::fs::remove_dir_all(root).unwrap();
21023 }
21024
21025 #[test]
21026 fn startup_missing_record_materialization_is_not_resumable() {
21027 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21028 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21029 shared
21030 .insert_record(ManagedSessionRecord {
21031 record_id: record_id.clone(),
21032 display_name: "missing materialization".to_owned(),
21033 provider: agent("claude"),
21034 mode: SessionMode::Pty,
21035 state: ManagedSessionState::Dormant,
21036 workspace_id: WorkspaceId::new("primary").unwrap(),
21037 canonical_root: opaque_windows_path(
21038 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21039 ),
21040 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21041 key: gate4agent_types::ProviderSessionKey::SessionId,
21042 id: "provider-session-missing-materialization".to_owned(),
21043 transcript_path: None,
21044 }),
21045 active_session: None,
21046 environment_profile: Some(receipt),
21047 bundle: None,
21048 context_id: None,
21049 context: None,
21050 exported_context: None,
21051 task_binding: None,
21052 created_at_unix_ms: 1,
21053 updated_at_unix_ms: 1,
21054 last_error: None,
21055 })
21056 .unwrap();
21057
21058 shared.reconcile_materializations();
21059 let record = shared.record(&record_id).unwrap();
21060 assert_eq!(record.state, ManagedSessionState::Unavailable);
21061 assert!(record.provider_session.is_none());
21062 assert_eq!(
21063 record.last_error.as_deref(),
21064 Some("environment-profile-unavailable"),
21065 );
21066 drop(shared);
21067 drop(runtime);
21068 std::fs::remove_dir_all(root).unwrap();
21069 }
21070
21071 #[tokio::test]
21072 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21073 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21074 let lease = install_managed_lease(&shared);
21075 let address = SessionAddress {
21076 workspace_id: WorkspaceId::new("primary").unwrap(),
21077 session: SessionKey {
21078 instance_id: AgentInstanceId(204),
21079 generation: SessionGeneration(1),
21080 },
21081 };
21082 let (overlay, materialization_guard) = shared
21083 .prepare_session_materialization(
21084 &address,
21085 &agent("claude"),
21086 SessionMode::Pty,
21087 Some(&receipt),
21088 None,
21089 None,
21090 Some(lease.lease_id.clone()),
21091 )
21092 .unwrap()
21093 .unwrap();
21094 let materialization_id = materialization_guard.id().unwrap().clone();
21095 let materialization_root = shared
21096 .materializations
21097 .lock()
21098 .unwrap_or_else(|poisoned| poisoned.into_inner())
21099 .get(&materialization_id)
21100 .unwrap()
21101 .root()
21102 .to_path_buf();
21103 let selection = shared
21104 .select_environment_profile(
21105 address.session.instance_id,
21106 &agent("claude"),
21107 SessionMode::Pty,
21108 Some(&receipt),
21109 )
21110 .unwrap()
21111 .unwrap();
21112 assert!(shared
21113 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21114 .unwrap()
21115 .is_none());
21116 shared.bind_session_with_materialization(
21117 &address,
21118 ProviderRuntimePolicy::raw_pty(),
21119 Some(receipt),
21120 None,
21121 None,
21122 Some(materialization_id),
21123 );
21124 selection.retain();
21125 materialization_guard.retain();
21126
21127 let blocked = shared
21128 .cleanup_managed_worktree(&lease.lease_id, false)
21129 .await
21130 .unwrap_err();
21131 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21132 assert!(materialization_root.is_dir());
21133 let removed = shared.remove_binding(&address).unwrap();
21134 shared
21135 .cleanup_session_owned_materialization(&address, &removed)
21136 .unwrap();
21137 assert!(!materialization_root.exists());
21138 drop(shared);
21139 drop(runtime);
21140 std::fs::remove_dir_all(root).unwrap();
21141 }
21142
21143 #[tokio::test]
21144 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21145 let git_root = temporary_workspace_root("raw-managed-inventory");
21146 let source = git_root.join("source");
21147 let allocation = git_root.join("allocation");
21148 std::fs::create_dir_all(&source).unwrap();
21149 std::fs::create_dir_all(&allocation).unwrap();
21150 run_test_git(&source, &["init"]);
21151 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21152 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21153 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21154 run_test_git(&source, &["add", "seed.txt"]);
21155 run_test_git(&source, &["commit", "-m", "seed"]);
21156 let profile = ManagedWorktreeProfile::new(
21157 WorktreeProfileId::new("default").unwrap(),
21158 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21159 &allocation,
21160 "gate4agent",
21161 "HEAD",
21162 ManagedWorktreeRetention::RemoveWhenReleased,
21163 )
21164 .unwrap();
21165 let workspace = WorkspaceConfig::new(
21166 WorkspaceId::new("primary").unwrap(),
21167 &source,
21168 )
21169 .unwrap()
21170 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21171 .with_managed_worktree_profile(profile.clone())
21172 .unwrap();
21173 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21174 materialization_test_shared_with_workspace(false, workspace);
21175 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21176 let source_text = source.to_string_lossy().into_owned();
21177 let target_text = target.to_string_lossy().into_owned();
21178 let created = create_git_worktree(
21179 &source_text,
21180 &target_text,
21181 "gate4agent/mw-raw-inventory",
21182 Some("HEAD"),
21183 )
21184 .await
21185 .unwrap();
21186 let lease = ManagedWorktreeLeaseRecord {
21187 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21188 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21189 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21190 profile_id: profile.profile_id().clone(),
21191 profile_revision: profile.revision().clone(),
21192 target_root: created.path.clone(),
21193 branch: "gate4agent/mw-raw-inventory".to_owned(),
21194 base_commit: created.head.clone(),
21195 expected_head: Some(created.head.clone()),
21196 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21197 state: ManagedWorktreeLeaseState::Ready,
21198 session_holders: Vec::new(),
21199 record_holders: Vec::new(),
21200 cleanup_failure: None,
21201 created_at_unix_ms: 1,
21202 updated_at_unix_ms: 1,
21203 };
21204 shared
21205 .workspaces
21206 .write()
21207 .unwrap_or_else(|poisoned| poisoned.into_inner())
21208 .insert(lease.workspace_id.clone(), created.path.clone());
21209 *shared
21210 .managed_worktrees
21211 .lock()
21212 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21213 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21214 let address = SessionAddress {
21215 workspace_id: lease.workspace_id.clone(),
21216 session: SessionKey {
21217 instance_id: AgentInstanceId(205),
21218 generation: SessionGeneration(1),
21219 },
21220 };
21221 let (overlay, materialization_guard) = shared
21222 .prepare_session_materialization(
21223 &address,
21224 &agent("claude"),
21225 SessionMode::Pty,
21226 Some(&receipt),
21227 None,
21228 None,
21229 Some(lease.lease_id.clone()),
21230 )
21231 .unwrap()
21232 .unwrap();
21233 let materialization_id = materialization_guard.id().unwrap().clone();
21234 let materialization_root = shared
21235 .materializations
21236 .lock()
21237 .unwrap_or_else(|poisoned| poisoned.into_inner())
21238 .get(&materialization_id)
21239 .unwrap()
21240 .root()
21241 .to_path_buf();
21242 let selection = shared
21243 .select_environment_profile(
21244 address.session.instance_id,
21245 &agent("claude"),
21246 SessionMode::Pty,
21247 Some(&receipt),
21248 )
21249 .unwrap()
21250 .unwrap();
21251 assert!(shared
21252 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21253 .unwrap()
21254 .is_none());
21255 let record_id = shared
21256 .bind_spawn_session_with_materialization(
21257 &address,
21258 agent("claude"),
21259 SessionMode::Pty,
21260 ProviderRuntimePolicy::raw_pty(),
21261 SpawnRecordPolicy::Always,
21262 Some(receipt.clone()),
21263 None,
21264 None,
21265 Some(materialization_id.clone()),
21266 )
21267 .unwrap()
21268 .unwrap();
21269 selection.retain();
21270 materialization_guard.retain();
21271 let record = shared.record(&record_id).unwrap();
21272 assert_eq!(record.state, ManagedSessionState::Live);
21273 assert!(record.provider_session.is_none());
21274 assert!(record.environment_profile.is_none());
21275 assert!(record.bundle.is_none());
21276 assert!(record.context_id.is_none());
21277 assert!(record.context.is_none());
21278 assert_eq!(record.active_session.as_ref(), Some(&address));
21279 assert_eq!(
21280 shared
21281 .materializations
21282 .lock()
21283 .unwrap_or_else(|poisoned| poisoned.into_inner())
21284 .get(&materialization_id)
21285 .unwrap()
21286 .owner(),
21287 &MaterializationOwner::Session {
21288 incarnation_id: shared.incarnation_id,
21289 instance_id: address.session.instance_id,
21290 generation: address.session.generation,
21291 },
21292 );
21293 {
21294 let mut bindings = shared
21295 .session_bindings
21296 .lock()
21297 .unwrap_or_else(|poisoned| poisoned.into_inner());
21298 bindings
21299 .get_mut(&address.session.instance_id)
21300 .unwrap()
21301 .managed_worktree_lease_id = Some(lease.lease_id.clone());
21302 }
21303 let lease_snapshot = shared
21304 .managed_worktrees
21305 .lock()
21306 .unwrap_or_else(|poisoned| poisoned.into_inner())
21307 .bind_session(
21308 &lease.lease_id,
21309 ManagedWorktreeSessionHolder {
21310 incarnation_id: shared.incarnation_id,
21311 instance_id: address.session.instance_id,
21312 generation: address.session.generation,
21313 },
21314 None,
21315 unix_time_ms(),
21316 )
21317 .unwrap();
21318 assert_eq!(lease_snapshot.active_session_count, 1);
21319 assert_eq!(lease_snapshot.managed_record_count, 0);
21320 shared.persist_state().unwrap();
21321 assert!(shared
21322 .managed_worktrees
21323 .lock()
21324 .unwrap_or_else(|poisoned| poisoned.into_inner())
21325 .get(&lease.lease_id)
21326 .unwrap()
21327 .record_holders
21328 .is_empty());
21329 shared
21330 .handle
21331 .dispatch(shared.prepare_command(ControlCommand::Register {
21332 instance_id: address.session.instance_id,
21333 agent_id: agent("claude"),
21334 transport: TransportKind::Pty,
21335 }))
21336 .unwrap();
21337 runtime.tick().await;
21338 shared.reconcile_managed_record(
21339 &record_id,
21340 &address,
21341 &ControlEvent {
21342 sequence: 1,
21343 command_id: None,
21344 instance_id: address.session.instance_id,
21345 generation: address.session.generation,
21346 event: ControlEventKind::Removed,
21347 },
21348 false,
21349 false,
21350 );
21351 let remove = shared.remove_session(&address);
21352 let drive_runtime = async {
21353 runtime.tick().await;
21354 };
21355 let (removed, ()) = tokio::join!(remove, drive_runtime);
21356 removed.unwrap();
21357
21358 assert!(!materialization_root.exists());
21359 assert!(!Path::new(&created.path).exists());
21360 assert_eq!(
21361 shared
21362 .managed_worktrees
21363 .lock()
21364 .unwrap_or_else(|poisoned| poisoned.into_inner())
21365 .get(&lease.lease_id)
21366 .unwrap()
21367 .state,
21368 ManagedWorktreeLeaseState::Removed,
21369 );
21370 let renamed = shared
21371 .rename_session_record(&record_id, "raw managed inventory".to_owned())
21372 .unwrap();
21373 assert_eq!(renamed.record_id, record_id);
21374 assert_eq!(renamed.display_name, "raw managed inventory");
21375 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21376 assert!(renamed.active_session.is_none());
21377
21378 drop(shared);
21379 drop(runtime);
21380 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21381 std::fs::remove_dir_all(git_root).unwrap();
21382 }
21383
21384 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21385 let root = std::env::current_dir().unwrap()
21386 .join("managed-test-target")
21387 .to_string_lossy()
21388 .into_owned();
21389 let lease = ManagedWorktreeLeaseRecord {
21390 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21391 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21392 workspace_id: WorkspaceId::new("managed-test").unwrap(),
21393 profile_id: WorktreeProfileId::new("default").unwrap(),
21394 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21395 target_root: root.clone(),
21396 branch: "gate4agent/mw-test".to_owned(),
21397 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21398 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21399 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21400 state: ManagedWorktreeLeaseState::Ready,
21401 session_holders: Vec::new(),
21402 record_holders: Vec::new(),
21403 cleanup_failure: None,
21404 created_at_unix_ms: 1,
21405 updated_at_unix_ms: 1,
21406 };
21407 shared.workspaces.write()
21408 .unwrap_or_else(|poisoned| poisoned.into_inner())
21409 .insert(lease.workspace_id.clone(), root);
21410 *shared.managed_worktrees.lock()
21411 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21412 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21413 lease
21414 }
21415
21416 #[cfg(feature = "fixture")]
21417 #[test]
21418 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21419 let mut shared = terminal_test_shared();
21420 shared.fixture_semantic_hook_policy = true;
21421 let lease = install_managed_lease(&shared);
21422 let address = SessionAddress {
21423 workspace_id: lease.workspace_id.clone(),
21424 session: SessionKey {
21425 instance_id: AgentInstanceId(206),
21426 generation: SessionGeneration(1),
21427 },
21428 };
21429 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21430 let effective_runtime_policy =
21431 shared.effective_spawn_runtime_policy(pre_admission_policy);
21432 assert_ne!(effective_runtime_policy, pre_admission_policy);
21433 assert!(effective_runtime_policy.provider_session_identity);
21434
21435 let record_id = shared
21436 .bind_spawn_session(
21437 &address,
21438 agent("claude"),
21439 SessionMode::Pty,
21440 effective_runtime_policy,
21441 SpawnRecordPolicy::Always,
21442 None,
21443 )
21444 .unwrap()
21445 .unwrap();
21446 assert_eq!(
21447 shared.record(&record_id).unwrap().state,
21448 ManagedSessionState::IdentityPending,
21449 );
21450 let (record_holder, raw_inventory_record_id) = {
21451 let mut bindings = shared
21452 .session_bindings
21453 .lock()
21454 .unwrap_or_else(|poisoned| poisoned.into_inner());
21455 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
21456 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
21457 managed_spawn_record_ownership(binding, effective_runtime_policy)
21458 };
21459 assert_eq!(record_holder.as_ref(), Some(&record_id));
21460 assert!(raw_inventory_record_id.is_none());
21461 let snapshot = shared
21462 .managed_worktrees
21463 .lock()
21464 .unwrap_or_else(|poisoned| poisoned.into_inner())
21465 .bind_session(
21466 &lease.lease_id,
21467 ManagedWorktreeSessionHolder {
21468 incarnation_id: shared.incarnation_id,
21469 instance_id: address.session.instance_id,
21470 generation: address.session.generation,
21471 },
21472 record_holder,
21473 unix_time_ms(),
21474 )
21475 .unwrap();
21476 assert_eq!(snapshot.managed_record_count, 1);
21477 assert_eq!(
21478 shared
21479 .managed_worktrees
21480 .lock()
21481 .unwrap_or_else(|poisoned| poisoned.into_inner())
21482 .get(&lease.lease_id)
21483 .unwrap()
21484 .record_holders,
21485 vec![record_id.clone()],
21486 );
21487
21488 let identity = ProviderSessionIdentity {
21489 key: ProviderSessionKey::SessionId,
21490 id: "monitoring-fixture-managed-session".to_owned(),
21491 transcript_path: None,
21492 };
21493 shared.publish_control(ControlEvent {
21494 sequence: 1,
21495 command_id: None,
21496 instance_id: address.session.instance_id,
21497 generation: address.session.generation,
21498 event: ControlEventKind::ProviderEvent {
21499 sequence: 1,
21500 source: gate4agent_types::ProviderSource {
21501 family: AdapterFamily::Hook,
21502 binding: AdapterBinding::new(
21503 gate4agent_types::AdapterId::new("claude-code").unwrap(),
21504 "fixture/v1",
21505 gate4agent_types::AdapterVerification::SyntheticFixture,
21506 )
21507 .unwrap(),
21508 },
21509 source_sequence: 1,
21510 event: ProviderEvent::SessionIdentityObserved {
21511 identity: identity.clone(),
21512 },
21513 },
21514 });
21515
21516 let live = shared.record(&record_id).unwrap();
21517 assert_eq!(live.state, ManagedSessionState::Live);
21518 assert_eq!(live.provider_session.as_ref(), Some(&identity));
21519 assert_eq!(live.active_session.as_ref(), Some(&address));
21520 assert_eq!(
21521 shared
21522 .managed_worktrees
21523 .lock()
21524 .unwrap_or_else(|poisoned| poisoned.into_inner())
21525 .get(&lease.lease_id)
21526 .unwrap()
21527 .record_holders,
21528 vec![record_id],
21529 );
21530 }
21531
21532 fn managed_v2_test_shared(
21533 allocation_root: &Path,
21534 state_path: Option<PathBuf>,
21535 profile_revision: &str,
21536 ) -> NodeShared {
21537 let profile = ManagedWorktreeProfile::new(
21538 WorktreeProfileId::new("default").unwrap(),
21539 WorktreeProfileRevision::new(profile_revision).unwrap(),
21540 allocation_root,
21541 "gate4agent",
21542 "HEAD",
21543 ManagedWorktreeRetention::RemoveWhenReleased,
21544 )
21545 .unwrap();
21546 let workspace = WorkspaceConfig::new(
21547 WorkspaceId::new("primary").unwrap(),
21548 std::env::current_dir().unwrap(),
21549 )
21550 .unwrap()
21551 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21552 .with_managed_worktree_profile(profile)
21553 .unwrap();
21554 let catalog = active_registry().unwrap();
21555 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21556 let mut shared = NodeShared::new(
21557 handle,
21558 "fixture-token".to_owned(),
21559 NodeId::new("node-terminal-test").unwrap(),
21560 vec![workspace],
21561 vec![agent("claude")],
21562 );
21563 shared.state_path = state_path;
21564 shared
21565 }
21566
21567 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
21568 let mut spawn_spec = spawn_spec_fixture();
21569 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
21570 ManagedWorktreeSpawnRequestV2 {
21571 spawn_spec,
21572 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21573 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
21574 }
21575 }
21576
21577 #[test]
21578 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
21579 let shared = terminal_test_shared();
21580 let mut lease = install_managed_lease(&shared);
21581 let session = SessionAddress {
21582 workspace_id: lease.workspace_id.clone(),
21583 session: SessionKey {
21584 instance_id: AgentInstanceId(73),
21585 generation: SessionGeneration(4),
21586 },
21587 };
21588 lease.state = ManagedWorktreeLeaseState::InUse;
21589 lease.session_holders.push(ManagedWorktreeSessionHolder {
21590 incarnation_id: shared.incarnation_id,
21591 instance_id: session.session.instance_id,
21592 generation: session.session.generation,
21593 });
21594 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21595 assert!(resolved.target.worktree_id.is_none());
21596
21597 let receipt = managed_spawn_receipt(
21598 &resolved,
21599 shared.incarnation_id,
21600 session.clone(),
21601 lease.snapshot(),
21602 None,
21603 None,
21604 None,
21605 );
21606 assert!(resolved.target.worktree_id.is_none());
21607 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
21608 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
21609 assert_eq!(receipt.spawn.session, session);
21610 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
21611 let encoded = serde_json::to_vec(&receipt).unwrap();
21612 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
21613 assert_eq!(decoded, receipt);
21614 }
21615
21616 #[tokio::test]
21617 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
21618 let root = temporary_workspace_root("managed-v2-revision");
21619 let allocation = root.join("allocation");
21620 std::fs::create_dir_all(&allocation).unwrap();
21621 let shared = managed_v2_test_shared(
21622 &allocation,
21623 Some(root.join("node-state.json")),
21624 "v1",
21625 );
21626 let mut request = managed_v2_request("managed-v2-revision");
21627 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
21628
21629 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
21630
21631 assert_eq!(
21632 failure.code,
21633 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21634 );
21635 assert!(shared
21636 .managed_worktrees
21637 .lock()
21638 .unwrap_or_else(|poisoned| poisoned.into_inner())
21639 .records()
21640 .is_empty());
21641 assert!(shared
21642 .managed_spawn_replays
21643 .lock()
21644 .unwrap_or_else(|poisoned| poisoned.into_inner())
21645 .is_empty());
21646 std::fs::remove_dir_all(root).unwrap();
21647 }
21648
21649 #[test]
21650 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
21651 let root = temporary_workspace_root("managed-v2-concurrent");
21652 let allocation = root.join("allocation");
21653 std::fs::create_dir_all(&allocation).unwrap();
21654 let shared = Arc::new(managed_v2_test_shared(
21655 &allocation,
21656 Some(root.join("node-state.json")),
21657 "v1",
21658 ));
21659 let request = managed_v2_request("managed-v2-concurrent");
21660 let barrier = Arc::new(std::sync::Barrier::new(3));
21661 let mut joins = Vec::new();
21662 for _ in 0..2 {
21663 let shared = Arc::clone(&shared);
21664 let request = request.clone();
21665 let barrier = Arc::clone(&barrier);
21666 joins.push(std::thread::spawn(move || {
21667 let digest = managed_spawn_request_digest_v2(&request).unwrap();
21668 barrier.wait();
21669 shared.acquire_durable_managed_spawn_v2(&request, digest)
21670 }));
21671 }
21672 barrier.wait();
21673 let results = joins
21674 .into_iter()
21675 .map(|join| join.join().unwrap())
21676 .collect::<Vec<_>>();
21677 assert_eq!(
21678 results
21679 .iter()
21680 .filter(|result| {
21681 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
21682 })
21683 .count(),
21684 1,
21685 );
21686 assert_eq!(
21687 results
21688 .iter()
21689 .filter(|result| {
21690 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
21691 })
21692 .count(),
21693 1,
21694 );
21695 assert_eq!(
21696 shared
21697 .managed_spawn_replays
21698 .lock()
21699 .unwrap_or_else(|poisoned| poisoned.into_inner())
21700 .len(),
21701 1,
21702 );
21703 assert!(shared
21704 .managed_worktrees
21705 .lock()
21706 .unwrap_or_else(|poisoned| poisoned.into_inner())
21707 .records()
21708 .is_empty());
21709 drop(shared);
21710 std::fs::remove_dir_all(root).unwrap();
21711 }
21712
21713 #[tokio::test]
21714 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
21715 let root = temporary_workspace_root("managed-v2-linked-pending");
21716 let allocation = root.join("allocation");
21717 std::fs::create_dir_all(&allocation).unwrap();
21718 let state_path = root.join("node-state.json");
21719 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21720 let lease = install_managed_lease(&shared);
21721 let request = managed_v2_request("managed-v2-linked-pending");
21722 assert!(matches!(
21723 shared
21724 .acquire_durable_managed_spawn_v2(
21725 &request,
21726 managed_spawn_request_digest_v2(&request).unwrap(),
21727 )
21728 .unwrap(),
21729 DurableManagedSpawnReplayDecision::Reserved,
21730 ));
21731 {
21732 let _transaction = shared
21733 .state_transaction
21734 .lock()
21735 .unwrap_or_else(|poisoned| poisoned.into_inner());
21736 shared
21737 .link_durable_managed_spawn_v2_lease_locked(
21738 &request.spawn_spec.idempotency_key,
21739 &lease.lease_id,
21740 )
21741 .unwrap();
21742 shared.persist_state_locked().unwrap();
21743 }
21744 drop(shared);
21745
21746 let mut loaded = session_registry::load(
21747 Some(&state_path),
21748 &NodeId::new("node-terminal-test").unwrap(),
21749 )
21750 .unwrap();
21751 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21752 *reopened
21753 .managed_worktrees
21754 .lock()
21755 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21756 ManagedWorktreeRegistry::from_records(
21757 std::mem::take(&mut loaded.managed_worktrees),
21758 std::mem::take(&mut loaded.managed_worktree_tombstones),
21759 )
21760 .unwrap();
21761 *reopened
21762 .managed_spawn_replays
21763 .lock()
21764 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21765 .managed_spawn_replays
21766 .into_iter()
21767 .map(|record| (record.idempotency_key.clone(), record))
21768 .collect();
21769 let lease_count = reopened
21770 .managed_worktrees
21771 .lock()
21772 .unwrap_or_else(|poisoned| poisoned.into_inner())
21773 .records()
21774 .len();
21775
21776 let failure = reopened
21777 .spawn_managed_worktree_v2(request)
21778 .await
21779 .unwrap_err();
21780
21781 assert_eq!(
21782 failure.code,
21783 NodeFailureCode::ManagedWorktreeRecoveryRequired,
21784 );
21785 assert_eq!(
21786 reopened
21787 .managed_worktrees
21788 .lock()
21789 .unwrap_or_else(|poisoned| poisoned.into_inner())
21790 .records()
21791 .len(),
21792 lease_count,
21793 );
21794 std::fs::remove_dir_all(root).unwrap();
21795 }
21796
21797 #[tokio::test]
21798 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
21799 let root = temporary_workspace_root("managed-v2-reopen");
21800 let allocation = root.join("allocation");
21801 std::fs::create_dir_all(&allocation).unwrap();
21802 let state_path = root.join("node-state.json");
21803 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21804 let lease = install_managed_lease(&shared);
21805 let request = managed_v2_request("managed-v2-reopen");
21806 let session = SessionAddress {
21807 workspace_id: lease.workspace_id.clone(),
21808 session: SessionKey {
21809 instance_id: AgentInstanceId(91),
21810 generation: SessionGeneration(1),
21811 },
21812 };
21813 let snapshot = {
21814 let mut registry = shared
21815 .managed_worktrees
21816 .lock()
21817 .unwrap_or_else(|poisoned| poisoned.into_inner());
21818 let current = registry.get_mut(&lease.lease_id).unwrap();
21819 current.state = ManagedWorktreeLeaseState::InUse;
21820 current.session_holders.push(ManagedWorktreeSessionHolder {
21821 incarnation_id: shared.incarnation_id,
21822 instance_id: session.session.instance_id,
21823 generation: session.session.generation,
21824 });
21825 current.snapshot()
21826 };
21827 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
21828 let receipt = managed_spawn_receipt(
21829 &resolved,
21830 shared.incarnation_id,
21831 session,
21832 snapshot,
21833 None,
21834 None,
21835 None,
21836 );
21837 assert!(matches!(
21838 shared
21839 .acquire_durable_managed_spawn_v2(
21840 &request,
21841 managed_spawn_request_digest_v2(&request).unwrap(),
21842 )
21843 .unwrap(),
21844 DurableManagedSpawnReplayDecision::Reserved,
21845 ));
21846 {
21847 let _transaction = shared
21848 .state_transaction
21849 .lock()
21850 .unwrap_or_else(|poisoned| poisoned.into_inner());
21851 shared
21852 .link_durable_managed_spawn_v2_lease_locked(
21853 &request.spawn_spec.idempotency_key,
21854 &lease.lease_id,
21855 )
21856 .unwrap();
21857 shared.persist_state_locked().unwrap();
21858 }
21859 shared
21860 .commit_durable_managed_spawn_v2(&request, receipt.clone())
21861 .unwrap();
21862 let legacy_same_key = ManagedWorktreeSpawnRequest {
21863 spawn_spec: request.spawn_spec.clone(),
21864 worktree_profile_id: request.worktree_profile_id.clone(),
21865 };
21866 shared.remember_managed_spawn_attempt(
21867 Some(&request.spawn_spec.idempotency_key),
21868 legacy_same_key.clone(),
21869 Ok(receipt.clone()),
21870 Instant::now(),
21871 );
21872 assert!(shared
21873 .replay_managed_spawn(&legacy_same_key, Instant::now())
21874 .unwrap()
21875 .is_none());
21876 drop(shared);
21877
21878 let mut loaded = session_registry::load(
21879 Some(&state_path),
21880 &NodeId::new("node-terminal-test").unwrap(),
21881 )
21882 .unwrap();
21883 assert_eq!(loaded.managed_spawn_replays.len(), 1);
21884 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21885 *reopened
21886 .managed_worktrees
21887 .lock()
21888 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21889 ManagedWorktreeRegistry::from_records(
21890 std::mem::take(&mut loaded.managed_worktrees),
21891 std::mem::take(&mut loaded.managed_worktree_tombstones),
21892 )
21893 .unwrap();
21894 *reopened
21895 .managed_spawn_replays
21896 .lock()
21897 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21898 .managed_spawn_replays
21899 .into_iter()
21900 .map(|record| (record.idempotency_key.clone(), record))
21901 .collect();
21902 let lease_count_before = reopened
21903 .managed_worktrees
21904 .lock()
21905 .unwrap_or_else(|poisoned| poisoned.into_inner())
21906 .records()
21907 .len();
21908
21909 assert_eq!(
21910 reopened
21911 .spawn_managed_worktree_v2(request.clone())
21912 .await
21913 .unwrap(),
21914 receipt,
21915 );
21916 assert_eq!(
21917 reopened
21918 .managed_worktrees
21919 .lock()
21920 .unwrap_or_else(|poisoned| poisoned.into_inner())
21921 .records()
21922 .len(),
21923 lease_count_before,
21924 );
21925 let mut changed = request;
21926 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
21927 let failure = reopened
21928 .spawn_managed_worktree_v2(changed)
21929 .await
21930 .unwrap_err();
21931 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
21932 assert_eq!(
21933 reopened
21934 .managed_worktrees
21935 .lock()
21936 .unwrap_or_else(|poisoned| poisoned.into_inner())
21937 .records()
21938 .len(),
21939 lease_count_before,
21940 );
21941 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
21942 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
21943 let failure = reopened
21944 .spawn_managed_worktree_v2(new_key)
21945 .await
21946 .unwrap_err();
21947 assert_eq!(
21948 failure.code,
21949 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21950 );
21951 assert_eq!(
21952 reopened
21953 .managed_worktrees
21954 .lock()
21955 .unwrap_or_else(|poisoned| poisoned.into_inner())
21956 .records()
21957 .len(),
21958 lease_count_before,
21959 );
21960 assert_eq!(
21961 reopened
21962 .managed_spawn_replays
21963 .lock()
21964 .unwrap_or_else(|poisoned| poisoned.into_inner())
21965 .len(),
21966 1,
21967 );
21968 std::fs::remove_dir_all(root).unwrap();
21969 }
21970
21971 #[test]
21972 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
21973 let shared = terminal_test_shared();
21974 let request = ManagedWorktreeSpawnRequest {
21975 spawn_spec: spawn_spec_fixture(),
21976 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21977 };
21978 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
21979 shared.remember_managed_spawn(
21980 request.clone(),
21981 Err(expected.clone()),
21982 Instant::now(),
21983 );
21984
21985 assert_eq!(
21986 shared
21987 .replay_managed_spawn(&request, Instant::now())
21988 .unwrap()
21989 .unwrap(),
21990 Err(expected),
21991 );
21992 assert!(shared
21993 .managed_spawn_replays
21994 .lock()
21995 .unwrap_or_else(|poisoned| poisoned.into_inner())
21996 .is_empty());
21997 }
21998
21999 #[tokio::test]
22000 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22001 let shared = terminal_test_shared();
22002 let lease = install_managed_lease(&shared);
22003 let raw = shared.spawn_session(
22004 lease.workspace_id.clone(),
22005 agent("claude"),
22006 SessionMode::Pty,
22007 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22008 None,
22009 ).await.unwrap_err();
22010 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22011 let mut spec = spawn_spec_fixture();
22012 spec.target.workspace_id = lease.workspace_id.clone();
22013 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22014 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22015 let mut selected = spawn_spec_fixture();
22016 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22017 selected.target.worktree_id = Some(lease.workspace_id);
22018 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22019 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22020 assert!(shared.handle.snapshot().sessions.is_empty());
22021 }
22022
22023 #[test]
22024 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22025 let shared = terminal_test_shared();
22026 let lease = install_managed_lease(&shared);
22027 let snapshot = snapshot_for_wire(
22028 &shared, true, true, true, false, true, true, false, false, true,
22029 );
22030 assert!(snapshot.managed_worktrees.is_empty());
22031 assert!(snapshot.workspaces.iter().all(|workspace| {
22032 workspace.managed_worktree_profiles.is_none()
22033 }));
22034 let mut reply = ResponseEnvelope {
22035 request_id: 1,
22036 result: Ok(NodeResponse::Resync {
22037 event_sequence: 1,
22038 oldest_available_sequence: 1,
22039 snapshot: shared.snapshot(),
22040 events: vec![NodeEventEnvelope {
22041 sequence: 1,
22042 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22043 }],
22044 }),
22045 };
22046 project_response_without_managed_worktrees(&mut reply);
22047 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22048 panic!("projection changed the response kind");
22049 };
22050 assert!(snapshot.managed_worktrees.is_empty());
22051 assert!(events.is_empty());
22052 let mut git = git_snapshot_for_parser();
22053 git.branch = Some(lease.branch.clone());
22054 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22055 lease_id: lease.lease_id.clone(),
22056 source_workspace_id: lease.source_workspace_id.clone(),
22057 branch: lease.branch.clone(),
22058 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22059 active_session_count: 1,
22060 managed_record_count: 0,
22061 });
22062 let mut inspection_reply = ResponseEnvelope {
22063 request_id: 2,
22064 result: Ok(NodeResponse::WorkspaceInspected {
22065 inspection: WorkspaceInspection {
22066 workspace_id: lease.workspace_id.clone(),
22067 entries: Vec::new(),
22068 tree_truncated: false,
22069 git,
22070 truncation: None,
22071 },
22072 }),
22073 };
22074 project_response_without_managed_worktrees(&mut inspection_reply);
22075 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22076 panic!("projection changed the inspection response kind");
22077 };
22078 assert!(inspection.git.managed_worktree.is_none());
22079 let failure = managed_git_worktree_failure(GitWorktreeError {
22080 kind: GitWorktreeErrorKind::Failed,
22081 message: r"git failed at C:\private\managed-target".to_owned(),
22082 });
22083 assert!(!failure.message.contains("private"));
22084 assert!(!failure.message.contains("managed-target"));
22085 }
22086
22087 #[tokio::test]
22088 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22089 let root = temporary_workspace_root("managed-restart");
22090 let source = root.join("source");
22091 let allocation = root.join("allocation");
22092 std::fs::create_dir_all(&source).unwrap();
22093 std::fs::create_dir_all(&allocation).unwrap();
22094 run_test_git(&source, &["init"]);
22095 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22096 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22097 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22098 run_test_git(&source, &["add", "seed.txt"]);
22099 run_test_git(&source, &["commit", "-m", "seed"]);
22100 let profile = ManagedWorktreeProfile::new(
22101 WorktreeProfileId::new("default").unwrap(),
22102 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22103 &allocation,
22104 "gate4agent",
22105 "HEAD",
22106 ManagedWorktreeRetention::Retain,
22107 ).unwrap();
22108 let workspace = WorkspaceConfig::new(
22109 WorkspaceId::new("primary").unwrap(),
22110 &source,
22111 ).unwrap()
22112 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22113 .with_managed_worktree_profile(profile.clone()).unwrap();
22114 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22115 let source_text = workspace.canonical_root().to_owned();
22116 let target_text = target.to_string_lossy().into_owned();
22117 let created = create_git_worktree(
22118 &source_text,
22119 &target_text,
22120 "gate4agent/mw-restart",
22121 Some("HEAD"),
22122 ).await.unwrap();
22123 let lease = ManagedWorktreeLeaseRecord {
22124 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22125 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22126 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22127 profile_id: profile.profile_id().clone(),
22128 profile_revision: profile.revision().clone(),
22129 target_root: created.path.clone(),
22130 branch: "gate4agent/mw-restart".to_owned(),
22131 base_commit: created.head.clone(),
22132 expected_head: None,
22133 retention: ManagedWorktreeRetention::Retain,
22134 state: ManagedWorktreeLeaseState::Allocating,
22135 session_holders: Vec::new(),
22136 record_holders: Vec::new(),
22137 cleanup_failure: None,
22138 created_at_unix_ms: 1,
22139 updated_at_unix_ms: 1,
22140 };
22141 let catalog = active_registry().unwrap();
22142 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22143 let shared = NodeShared::new_with_incarnation(
22144 handle,
22145 "fixture-token".to_owned(),
22146 NodeId::new("node-managed-restart").unwrap(),
22147 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22148 vec![workspace],
22149 vec![agent("claude")],
22150 ProviderRuntimeStatuses::default(),
22151 None,
22152 Vec::new(),
22153 Vec::new(),
22154 SpawnProfileRegistry::default(),
22155 None,
22156 None,
22157 None,
22158 None,
22159 Vec::new(),
22160 vec![lease.clone()],
22161 Vec::new(),
22162 Vec::new(),
22163 None,
22164 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22165 );
22166 shared.reconcile_managed_worktrees().await;
22167 let retained = shared.managed_worktrees.lock()
22168 .unwrap_or_else(|poisoned| poisoned.into_inner())
22169 .get(&lease.lease_id).unwrap().clone();
22170 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22171 assert!(shared.workspaces.read()
22172 .unwrap_or_else(|poisoned| poisoned.into_inner())
22173 .contains_key(&lease.workspace_id));
22174 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22175 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22176 assert!(!Path::new(&created.path).exists());
22177 std::fs::remove_dir_all(root).unwrap();
22178 }
22179
22180 fn run_test_git(root: &Path, arguments: &[&str]) {
22181 let output = std::process::Command::new("git")
22182 .arg("-C").arg(root).args(arguments).output().unwrap();
22183 assert!(
22184 output.status.success(),
22185 "git {:?} failed: {}",
22186 arguments,
22187 String::from_utf8_lossy(&output.stderr),
22188 );
22189 }
22190
22191 #[tokio::test]
22192 async fn spawn_spec_validation_fails_before_session_mutation() {
22193 let shared = terminal_test_shared();
22194 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22195 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22196 }));
22197 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22198 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22199 }));
22200 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22201 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22202 }));
22203
22204 let mut target_mismatch = spawn_spec_fixture();
22205 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22206 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22207 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22208
22209 let mut unknown_profile = spawn_spec_fixture();
22210 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22211 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22212 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22213 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22214
22215 let mut revision_mismatch = spawn_spec_fixture();
22216 revision_mismatch.idempotency_key =
22217 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22218 revision_mismatch.expected_profile_revision =
22219 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22220 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22221 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22222
22223 let mut unsupported_materializer = spawn_spec_fixture();
22224 unsupported_materializer.idempotency_key =
22225 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22226 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22227 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22228 };
22229 let failure = shared
22230 .spawn_from_spec(unsupported_materializer)
22231 .await
22232 .unwrap_err();
22233 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22234
22235 let failure = shared
22236 .spawn_session_with_deadline(
22237 WorkspaceId::new("primary").unwrap(),
22238 agent("claude"),
22239 SessionMode::Pty,
22240 gate4agent_types::TerminalSize {
22241 rows: 24,
22242 columns: 80,
22243 },
22244 None,
22245 None,
22246 None,
22247 None,
22248 None,
22249 None,
22250 SpawnRecordPolicy::ProviderIdentityOnly,
22251 Some(Instant::now()),
22252 &[],
22253 None,
22254 ApprovalLevel::default(),
22255 None,
22256 None,
22257 )
22258 .await
22259 .unwrap_err();
22260 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22261 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22262 assert!(shared.handle.snapshot().sessions.is_empty());
22263 }
22264
22265 #[test]
22266 fn worktree_selection_requires_its_separate_negotiated_capability() {
22267 let mut spec = spawn_spec_fixture();
22268 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22269 let request = NodeRequest::SpawnSpec { spec };
22270 let spawn_spec_capability =
22271 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22272 assert!(request_uses_unnegotiated_capability(
22273 &request,
22274 &[spawn_spec_capability.clone()],
22275 ));
22276 let worktree_capability =
22277 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22278 assert!(request_uses_unnegotiated_capability(
22279 &request,
22280 &[spawn_spec_capability.clone(), worktree_capability.clone()],
22281 ));
22282 let profile_revision_capability =
22283 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22284 assert!(request_uses_unnegotiated_capability(
22285 &request,
22286 &[
22287 spawn_spec_capability.clone(),
22288 worktree_capability.clone(),
22289 profile_revision_capability.clone(),
22290 ],
22291 ));
22292 let bundle_materialization_capability =
22299 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22300 assert!(request_uses_unnegotiated_capability(
22301 &request,
22302 &[
22303 spawn_spec_capability.clone(),
22304 worktree_capability.clone(),
22305 profile_revision_capability.clone(),
22306 bundle_materialization_capability.clone(),
22307 ],
22308 ));
22309 let history_context_pack_capability =
22310 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22311 assert!(!request_uses_unnegotiated_capability(
22312 &request,
22313 &[
22314 spawn_spec_capability,
22315 worktree_capability,
22316 profile_revision_capability,
22317 bundle_materialization_capability,
22318 history_context_pack_capability,
22319 ],
22320 ));
22321 }
22322
22323 #[tokio::test]
22324 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22325 let mut shared = terminal_test_shared();
22326 let spec = spawn_spec_fixture();
22327 let receipt = shared
22328 .resolve_spawn_spec(&spec)
22329 .unwrap()
22330 .receipt(shared.incarnation_id, terminal_address(1));
22331 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22332
22333 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22334 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22335 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22336
22337 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22338
22339 let mut revision_conflicting = spec.clone();
22340 revision_conflicting.expected_profile_revision =
22341 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22342 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22343 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22344
22345 let mut stale_cache_miss = spec.clone();
22346 stale_cache_miss.idempotency_key =
22347 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22348 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22349 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22350
22351 let mut conflicting = spec;
22352 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22353 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22354 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22355 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22356 assert!(shared.handle.snapshot().sessions.is_empty());
22357 }
22358
22359 #[tokio::test]
22360 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22361 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22362 let mut spec = spawn_spec_fixture();
22363 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22364 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22365 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22366
22367 let failure = shared
22368 .create_worktree(
22369 WorkspaceId::new("primary").unwrap(),
22370 WorkspaceId::new("new-worktree").unwrap(),
22371 "not-an-absolute-path".to_owned(),
22372 "invalid branch".to_owned(),
22373 None,
22374 )
22375 .await
22376 .unwrap_err();
22377 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22378
22379 let failure = shared
22380 .remove_worktree(
22381 WorkspaceId::new("primary").unwrap(),
22382 "not-an-absolute-path".to_owned(),
22383 )
22384 .await
22385 .unwrap_err();
22386 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22387 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22388 assert!(shared.handle.snapshot().sessions.is_empty());
22389 }
22390
22391 #[tokio::test]
22392 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22393 let root = temporary_workspace_root("manual-worktree-compensation");
22394 let source = root.join("source");
22395 let target = root.join("target");
22396 let invalid_state_parent = root.join("state-parent-is-a-file");
22397 let invalid_state_path = invalid_state_parent.join("state.json");
22398 std::fs::create_dir_all(&source).unwrap();
22399 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22400 run_test_git(&source, &["init"]);
22401 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22402 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22403 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22404 run_test_git(&source, &["add", "seed.txt"]);
22405 run_test_git(&source, &["commit", "-m", "seed"]);
22406
22407 let catalog = active_registry().unwrap();
22408 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22409 let workspace = WorkspaceConfig::new(
22410 WorkspaceId::new("primary").unwrap(),
22411 &source,
22412 ).unwrap();
22413 let mut shared = NodeShared::new(
22414 handle,
22415 "fixture-token".to_owned(),
22416 NodeId::new("node-worktree-compensation").unwrap(),
22417 vec![workspace],
22418 vec![agent("claude")],
22419 );
22420 shared.state_path = Some(invalid_state_path);
22421
22422 let error = shared.create_worktree(
22423 WorkspaceId::new("primary").unwrap(),
22424 WorkspaceId::new("compensated").unwrap(),
22425 target.to_string_lossy().into_owned(),
22426 "gate4agent/compensated".to_owned(),
22427 Some("HEAD".to_owned()),
22428 ).await.unwrap_err();
22429
22430 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22431 assert!(!target.exists());
22432 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22433 .iter()
22434 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22435 std::fs::remove_dir_all(root).unwrap();
22436 }
22437
22438 #[test]
22439 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22440 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22441 NativeGitWorktreeSnapshot {
22442 path: path.to_owned(),
22443 head: "0123456789abcdef".to_owned(),
22444 branch: Some("refs/heads/test".to_owned()),
22445 is_bare,
22446 is_main,
22447 locked: false,
22448 lock_reason: None,
22449 prunable: false,
22450 prunable_reason: None,
22451 workspace_id: None,
22452 }
22453 }
22454
22455 let source = r"C:\repo";
22456 let selected = r"C:\repo-worktree";
22457 let listed = vec![
22458 worktree(source, true, false),
22459 worktree(selected, false, false),
22460 ];
22461 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
22462
22463 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
22464 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
22465
22466 let bare = vec![
22467 worktree(source, true, false),
22468 worktree(selected, false, true),
22469 ];
22470 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
22471 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
22472
22473 let foreign_failure =
22474 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
22475 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
22476 }
22477
22478 fn terminal_frame(sequence: u64) -> TerminalFrame {
22479 TerminalFrame {
22480 sequence,
22481 size: gate4agent_types::TerminalSize {
22482 rows: 24,
22483 columns: 80,
22484 },
22485 cursor_row: 2,
22486 cursor_column: 3,
22487 contents: format!("frame-{sequence}"),
22488 formatted: vec![1, 2, 3],
22489 scrollback_formatted: Vec::new(),
22490 alternate_screen: false,
22491 mouse_protocol_enabled: false,
22492 mouse_protocol_encoding: Default::default(),
22493 produced_at_unix_ms: 0,
22494 screen_state: gate4agent_types::PtyScreenState::default(),
22495 bracketed_paste: None,
22496 }
22497 }
22498
22499 #[test]
22500 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
22501 let shared = terminal_test_shared();
22502 let mut receiver = shared.terminal_event_tx.subscribe();
22503 let original = terminal_address(1);
22504
22505 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22506 let first = receiver.try_recv().unwrap();
22507 assert_eq!(first.len(), 1);
22508 assert!(matches!(
22509 &first[0].event,
22510 NodeEvent::TerminalFrame { address, frame }
22511 if address == &original && frame.sequence == 7
22512 ));
22513
22514 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22515 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
22516 assert!(matches!(
22517 receiver.try_recv(),
22518 Err(broadcast::error::TryRecvError::Empty)
22519 ));
22520
22521 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
22522 let advanced = receiver.try_recv().unwrap();
22523 assert_eq!(advanced[0].sequence, 2);
22524
22525 let resumed = terminal_address(2);
22526 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
22527 let reset = receiver.try_recv().unwrap();
22528 assert_eq!(reset[0].sequence, 3);
22529 assert!(matches!(
22530 &reset[0].event,
22531 NodeEvent::TerminalFrame { address, frame }
22532 if address == &resumed && frame.sequence == 1
22533 ));
22534 shared.clear_terminal_frame_watermark(&resumed);
22535 assert!(!shared
22536 .terminal_frame_watermarks
22537 .lock()
22538 .unwrap_or_else(|poisoned| poisoned.into_inner())
22539 .contains_key(&resumed.session.instance_id));
22540 }
22541
22542 #[test]
22543 fn terminal_frame_batches_replace_without_entering_node_event_history() {
22544 let shared = terminal_test_shared();
22545 let mut receiver = shared.terminal_event_tx.subscribe();
22546 let address = terminal_address(1);
22547 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
22548
22549 for sequence in 1..=count {
22550 shared.publish_terminal_frame_candidates(vec![(
22551 address.clone(),
22552 terminal_frame(sequence),
22553 )]);
22554 }
22555
22556 let history = shared
22557 .history
22558 .lock()
22559 .unwrap_or_else(|poisoned| poisoned.into_inner());
22560 assert!(history.events.is_empty());
22561 assert_eq!(history.last_sequence, count);
22562 assert_eq!(history.replay_floor_sequence, 1);
22563 drop(history);
22564 let NodeResponse::Resync {
22565 event_sequence,
22566 oldest_available_sequence,
22567 events,
22568 ..
22569 } = shared.resync(0) else {
22570 unreachable!("resync helper returned another response")
22571 };
22572 assert_eq!(event_sequence, count);
22573 assert_eq!(oldest_available_sequence, 1);
22574 assert!(events.is_empty());
22575 assert!(matches!(
22576 resync_required_event(&shared).event,
22577 NodeEvent::ResyncRequired {
22578 oldest_available_sequence: 1,
22579 }
22580 ));
22581 assert!(matches!(
22582 receiver.try_recv(),
22583 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
22584 ));
22585 let latest = receiver.try_recv().unwrap();
22586 assert_eq!(latest.len(), 1);
22587 assert_eq!(latest[0].sequence, count);
22588 }
22589
22590 #[test]
22591 fn real_durable_eviction_advances_authoritative_replay_floor() {
22592 let shared = terminal_test_shared();
22593 for _ in 0..=NODE_EVENT_HISTORY_MAX {
22594 shared.publish(NodeEvent::ControllerChanged { controller: None });
22595 }
22596
22597 let NodeResponse::Resync {
22598 event_sequence,
22599 oldest_available_sequence,
22600 events,
22601 ..
22602 } = shared.resync(0) else {
22603 unreachable!("resync helper returned another response")
22604 };
22605 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
22606 assert_eq!(oldest_available_sequence, 2);
22607 assert_eq!(events.first().map(|event| event.sequence), Some(2));
22608 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
22609 assert!(matches!(
22610 resync_required_event(&shared).event,
22611 NodeEvent::ResyncRequired {
22612 oldest_available_sequence: 2,
22613 }
22614 ));
22615 }
22616
22617 #[test]
22618 fn legacy_terminal_frame_connection_has_no_live_subscription() {
22619 let shared = terminal_test_shared();
22620 let without_capability = terminal_event_subscription(&shared, false);
22621 let with_capability = terminal_event_subscription(&shared, true);
22622 assert!(without_capability.is_none());
22623 assert!(with_capability.is_some());
22624 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22625 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
22626 }));
22627 }
22628
22629 #[test]
22630 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
22631 let mut pending = Vec::new();
22632 let mut event_sequence = 0_u64;
22633 for frame_sequence in 1..=4 {
22634 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
22635 event_sequence += 1;
22636 let mut address = terminal_address(1);
22637 address.session.instance_id = AgentInstanceId(instance);
22638 queue_connection_event(
22639 &mut pending,
22640 NodeEventEnvelope {
22641 sequence: event_sequence,
22642 event: NodeEvent::TerminalFrame {
22643 address,
22644 frame: terminal_frame(frame_sequence),
22645 },
22646 },
22647 0,
22648 );
22649 }
22650 }
22651 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
22652 pending.sort_unstable_by_key(|event| event.sequence);
22653
22654 let mut uninterrupted = pending.clone();
22655 let first_burst = connection_event_burst_len(uninterrupted.len());
22656 uninterrupted.drain(..first_burst);
22657 let second_burst = connection_event_burst_len(uninterrupted.len());
22658 uninterrupted.drain(..second_burst);
22659 assert!(uninterrupted.is_empty());
22660
22661 let (frame_tx, mut frame_rx) =
22662 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
22663 frame_tx
22664 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
22665 request_id: 77,
22666 request: NodeRequest::Snapshot,
22667 })))
22668 .unwrap();
22669 let burst_len = connection_event_burst_len(pending.len());
22670 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
22671 pending.drain(..burst_len);
22672 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
22673 assert!(matches!(
22674 frame_rx.try_recv(),
22675 Ok(Ok(ClientFrame::Request(RequestEnvelope {
22676 request_id: 77,
22677 request: NodeRequest::Snapshot,
22678 })))
22679 ));
22680 }
22681
22682 #[test]
22683 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
22684 let mut pending = Vec::new();
22685 for sequence in [7_u64, 8] {
22686 let mut address = terminal_address(1);
22687 address.session.instance_id = AgentInstanceId(sequence);
22688 queue_connection_event(
22689 &mut pending,
22690 NodeEventEnvelope {
22691 sequence,
22692 event: NodeEvent::TerminalFrame {
22693 address,
22694 frame: terminal_frame(sequence),
22695 },
22696 },
22697 0,
22698 );
22699 }
22700 let mut discard_through = 0;
22701 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
22702 assert!(pending.is_empty());
22703
22704 let replayed = NodeEventEnvelope {
22705 sequence: 8,
22706 event: NodeEvent::TerminalFrame {
22707 address: terminal_address(1),
22708 frame: terminal_frame(8),
22709 },
22710 };
22711 let advanced = NodeEventEnvelope {
22712 sequence: 9,
22713 event: NodeEvent::TerminalFrame {
22714 address: terminal_address(2),
22715 frame: terminal_frame(1),
22716 },
22717 };
22718 queue_connection_event_batch(
22719 &mut pending,
22720 &[replayed, advanced.clone()],
22721 discard_through,
22722 );
22723 assert_eq!(pending, vec![advanced]);
22724 }
22725
22726 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
22727 ManagedSessionRecord {
22728 record_id: SessionRecordId::new(record_id).unwrap(),
22729 display_name: format!("{provider} record"),
22730 provider: agent(provider),
22731 mode: SessionMode::Pty,
22732 state: ManagedSessionState::Unavailable,
22733 workspace_id: WorkspaceId::new("primary").unwrap(),
22734 canonical_root: opaque_windows_path(
22735 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22736 ),
22737 provider_session: None,
22738 active_session: None,
22739 environment_profile: None,
22740 bundle: None,
22741 context_id: None,
22742 context: None,
22743 exported_context: None,
22744 task_binding: None,
22745 created_at_unix_ms: 1,
22746 updated_at_unix_ms: 1,
22747 last_error: None,
22748 }
22749 }
22750
22751 fn retention_test_record(
22752 workspace_id: &str,
22753 record_id: &str,
22754 state: ManagedSessionState,
22755 updated_at_unix_ms: u64,
22756 ) -> ManagedSessionRecord {
22757 ManagedSessionRecord {
22758 record_id: SessionRecordId::new(record_id).unwrap(),
22759 display_name: format!("{record_id} display"),
22760 provider: agent("claude"),
22761 mode: SessionMode::Pty,
22762 state,
22763 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
22764 canonical_root: opaque_windows_path(
22765 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22766 ),
22767 provider_session: None,
22768 active_session: None,
22769 environment_profile: None,
22770 bundle: None,
22771 context_id: None,
22772 context: None,
22773 exported_context: None,
22774 task_binding: None,
22775 created_at_unix_ms: updated_at_unix_ms,
22776 updated_at_unix_ms,
22777 last_error: None,
22778 }
22779 }
22780
22781 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
22789 vec![
22790 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
22791 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
22792 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
22793 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
22794 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
22795 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
22796 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
22797 retention_test_record(
22798 "workspace-a",
22799 "decoy-pending",
22800 ManagedSessionState::IdentityPending,
22801 now - 500_000,
22802 ),
22803 retention_test_record(
22804 "workspace-a",
22805 "decoy-dormant",
22806 ManagedSessionState::Dormant,
22807 now - 500_000,
22808 ),
22809 ]
22810 }
22811
22812 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
22813 let root = temporary_workspace_root("session-record-retention");
22814 std::fs::create_dir_all(&root).unwrap();
22815 let catalog = active_registry().unwrap();
22816 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22817 let workspace_a =
22818 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
22819 let workspace_b =
22820 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
22821 let mut shared = NodeShared::new(
22822 handle,
22823 "fixture-token".to_owned(),
22824 NodeId::new("node-retention-test").unwrap(),
22825 vec![workspace_a, workspace_b],
22826 vec![agent("claude")],
22827 );
22828 shared.state_path = Some(root.join("node-state-v10.json"));
22829 let now = unix_time_ms();
22830 for record in retention_fixture_records(now) {
22831 shared.insert_record(record).unwrap();
22832 }
22833 shared
22834 .persist_state_locked()
22835 .expect("fixture-only, empty-history persist against a fresh temp state path");
22836 (shared, runtime, root, now)
22837 }
22838
22839 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22840 shared
22841 .history
22842 .lock()
22843 .unwrap_or_else(|poisoned| poisoned.into_inner())
22844 .events
22845 .iter()
22846 .filter_map(|envelope| match &envelope.event {
22847 NodeEvent::SessionRecordRemoved { record_id } => {
22848 Some(record_id.as_str().to_owned())
22849 }
22850 _ => None,
22851 })
22852 .collect()
22853 }
22854
22855 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22856 shared
22857 .session_records
22858 .lock()
22859 .unwrap_or_else(|poisoned| poisoned.into_inner())
22860 .records
22861 .keys()
22862 .map(|record_id| record_id.as_str().to_owned())
22863 .collect()
22864 }
22865
22866 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
22867 values.iter().map(|value| (*value).to_owned()).collect()
22868 }
22869
22870 #[tokio::test]
22871 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
22872 let (mut shared, runtime, root, _now) = retention_fixture_shared();
22873 shared.session_record_retention = SessionRecordRetentionConfig {
22874 age_ms: 0,
22875 keep_per_workspace: 1,
22876 };
22877 shared.retire_unavailable_session_records().await;
22878
22879 assert_eq!(
22880 retention_remaining_record_ids(&shared),
22881 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22882 );
22883 assert_eq!(
22884 retention_removed_record_ids(&shared),
22885 owned(&["a1", "a2", "b1", "b2"]),
22886 );
22887 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
22888
22889 drop(shared);
22890 drop(runtime);
22891 std::fs::remove_dir_all(root).unwrap();
22892 }
22893
22894 #[tokio::test]
22900 async fn session_record_retention_both_set_retires_only_the_intersection() {
22901 let (mut shared, runtime, root, _now) = retention_fixture_shared();
22902 shared.session_record_retention = SessionRecordRetentionConfig {
22903 age_ms: 250_000,
22904 keep_per_workspace: 1,
22905 };
22906 shared.retire_unavailable_session_records().await;
22907
22908 assert_eq!(
22909 retention_remaining_record_ids(&shared),
22910 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22911 );
22912 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
22913 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
22914
22915 drop(shared);
22916 drop(runtime);
22917 std::fs::remove_dir_all(root).unwrap();
22918 }
22919
22920 #[tokio::test]
22921 async fn session_record_retention_both_disabled_is_a_no_op() {
22922 let (mut shared, runtime, root, _now) = retention_fixture_shared();
22923 shared.session_record_retention = SessionRecordRetentionConfig::default();
22924 shared.retire_unavailable_session_records().await;
22925
22926 assert_eq!(
22927 retention_remaining_record_ids(&shared),
22928 owned(&[
22929 "a1", "a2", "a3", "b1", "b2", "b3",
22930 "decoy-live", "decoy-pending", "decoy-dormant",
22931 ]),
22932 );
22933 assert!(retention_removed_record_ids(&shared).is_empty());
22934 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
22935
22936 drop(shared);
22937 drop(runtime);
22938 std::fs::remove_dir_all(root).unwrap();
22939 }
22940
22941 #[test]
22942 fn session_record_context_export_selects_one_exact_history_identity() {
22943 let candidates = vec![
22944 HistoryCandidateSummary {
22945 id: "candidate-other".to_owned(),
22946 session_id_hint: "provider-other".to_owned(),
22947 modified_at_unix_ms: Some(1),
22948 },
22949 HistoryCandidateSummary {
22950 id: "candidate-exact".to_owned(),
22951 session_id_hint: "provider-exact".to_owned(),
22952 modified_at_unix_ms: Some(2),
22953 },
22954 ];
22955 assert_eq!(
22956 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
22957 "candidate-exact",
22958 );
22959 }
22960
22961 #[test]
22962 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
22963 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
22964 id: id.to_owned(),
22965 session_id_hint: hint.to_owned(),
22966 modified_at_unix_ms: None,
22967 };
22968 let missing = select_history_candidate(
22969 &[candidate("candidate-other", "provider-other")],
22970 Some("provider-exact"),
22971 )
22972 .unwrap_err();
22973 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
22974 let ambiguous = select_history_candidate(
22975 &[
22976 candidate("candidate-a", "provider-exact"),
22977 candidate("candidate-b", "provider-exact"),
22978 ],
22979 Some("provider-exact"),
22980 )
22981 .unwrap_err();
22982 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
22983 }
22984
22985 #[test]
22993 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
22994 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
22995 id: id.to_owned(),
22996 session_id_hint: hint.to_owned(),
22997 modified_at_unix_ms: None,
22998 };
22999 assert_eq!(
23000 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23001 .unwrap(),
23002 "candidate-sole",
23003 );
23004 let missing = select_history_candidate(&[], None).unwrap_err();
23005 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23006 let ambiguous = select_history_candidate(
23007 &[
23008 candidate("candidate-a", "sidecar-session-a"),
23009 candidate("candidate-b", "sidecar-session-b"),
23010 ],
23011 None,
23012 )
23013 .unwrap_err();
23014 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23015 }
23016
23017 #[test]
23018 fn session_record_context_export_revalidation_rejects_record_change() {
23019 let session = terminal_address(1);
23020 let identity = ProviderSessionIdentity {
23021 key: ProviderSessionKey::SessionId,
23022 id: "provider-exact".to_owned(),
23023 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23024 };
23025 let mut expected = record("claude", "record-context-export");
23026 expected.state = ManagedSessionState::Live;
23027 expected.provider_session = Some(identity.clone());
23028 expected.active_session = Some(session.clone());
23029 let binding = SessionBinding {
23030 workspace_id: session.workspace_id.clone(),
23031 generation: session.session.generation,
23032 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23033 pending_resume: None,
23034 record_id: Some(expected.record_id.clone()),
23035 managed_worktree_lease_id: None,
23036 environment_profile: None,
23037 bundle: None,
23038 context: None,
23039 materialization_id: None,
23040 };
23041 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23042 assert!(session_record_context_export_binding_is_exact(
23043 &expected,
23044 &expected,
23045 Some(&identity),
23046 &session,
23047 &binding,
23048 &expected.provider,
23049 ¤t_root,
23050 false,
23051 ));
23052 let mut changed = expected.clone();
23053 changed.updated_at_unix_ms += 1;
23054 assert!(!session_record_context_export_binding_is_exact(
23055 &expected,
23056 &changed,
23057 Some(&identity),
23058 &session,
23059 &binding,
23060 &expected.provider,
23061 ¤t_root,
23062 false,
23063 ));
23064 assert!(!session_record_context_export_binding_is_exact(
23069 &expected,
23070 &changed,
23071 Some(&identity),
23072 &session,
23073 &binding,
23074 &expected.provider,
23075 ¤t_root,
23076 true,
23077 ));
23078 let mut detached = expected.clone();
23079 detached.state = ManagedSessionState::Dormant;
23080 detached.active_session = None;
23081 detached.updated_at_unix_ms += 1;
23082 assert!(!session_record_context_export_binding_is_exact(
23083 &expected,
23084 &detached,
23085 Some(&identity),
23086 &session,
23087 &binding,
23088 &expected.provider,
23089 ¤t_root,
23090 false,
23091 ));
23092 assert!(session_record_context_export_binding_is_exact(
23093 &expected,
23094 &detached,
23095 Some(&identity),
23096 &session,
23097 &binding,
23098 &expected.provider,
23099 ¤t_root,
23100 true,
23101 ));
23102 }
23103
23104 #[test]
23111 fn session_record_context_export_binding_is_exact_without_identity() {
23112 let session = terminal_address(1);
23113 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23114 expected.state = ManagedSessionState::Live;
23115 expected.active_session = Some(session.clone());
23116 let binding = SessionBinding {
23117 workspace_id: session.workspace_id.clone(),
23118 generation: session.session.generation,
23119 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23120 pending_resume: None,
23121 record_id: Some(expected.record_id.clone()),
23122 managed_worktree_lease_id: None,
23123 environment_profile: None,
23124 bundle: None,
23125 context: None,
23126 materialization_id: None,
23127 };
23128 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23129 assert!(session_record_context_export_binding_is_exact(
23130 &expected,
23131 &expected,
23132 None,
23133 &session,
23134 &binding,
23135 &expected.provider,
23136 ¤t_root,
23137 false,
23138 ));
23139 let mut drifted = expected.clone();
23143 drifted.provider_session = Some(ProviderSessionIdentity {
23144 key: ProviderSessionKey::SessionId,
23145 id: "unexpected-identity".to_owned(),
23146 transcript_path: None,
23147 });
23148 assert!(!session_record_context_export_binding_is_exact(
23149 &expected,
23150 &drifted,
23151 None,
23152 &session,
23153 &binding,
23154 &expected.provider,
23155 ¤t_root,
23156 false,
23157 ));
23158 }
23159
23160 #[test]
23161 fn session_record_context_export_preflight_requires_live_usable_source() {
23162 let mut source = record("claude", "record-context-export-preflight");
23163 source.state = ManagedSessionState::Live;
23164 assert!(session_record_context_export_source_is_usable(
23165 &source,
23166 &SessionStatus::Running,
23167 false,
23168 ));
23169 assert!(session_record_context_export_source_is_usable(
23170 &source,
23171 &SessionStatus::Exited { exit_code: Some(0) },
23172 false,
23173 ));
23174
23175 source.state = ManagedSessionState::Dormant;
23176 assert!(!session_record_context_export_source_is_usable(
23177 &source,
23178 &SessionStatus::Running,
23179 false,
23180 ));
23181 assert!(session_record_context_export_source_is_usable(
23185 &source,
23186 &SessionStatus::Exited { exit_code: Some(0) },
23187 true,
23188 ));
23189 assert!(!session_record_context_export_source_is_usable(
23190 &source,
23191 &SessionStatus::Exited { exit_code: Some(0) },
23192 false,
23193 ));
23194 source.state = ManagedSessionState::Live;
23195 assert!(!session_record_context_export_source_is_usable(
23196 &source,
23197 &SessionStatus::Starting,
23198 false,
23199 ));
23200 }
23201
23202 #[test]
23203 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23204 let catalog = active_registry().unwrap();
23205 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23206 let workspace = WorkspaceConfig::new(
23207 WorkspaceId::new("primary").unwrap(),
23208 std::env::current_dir().unwrap(),
23209 )
23210 .unwrap();
23211 let shared = NodeShared::new(
23212 handle,
23213 "fixture-token".to_owned(),
23214 NodeId::new("node-context-export-conflict").unwrap(),
23215 vec![workspace],
23216 vec![agent("claude")],
23217 );
23218 let history = HistorySessionRecord {
23219 session_id: "provider-exact".to_owned(),
23220 title: None,
23221 cwd: None,
23222 model: None,
23223 message_count: 1,
23224 completed_turn_count: None,
23225 total_tokens: 0,
23226 messages: vec![gate4agent_types::HistoryMessageRecord {
23227 role: gate4agent_types::HistoryMessageRole::User,
23228 text: "bounded context".to_owned(),
23229 }],
23230 };
23231 let pack = NodeContextPack::export(
23232 ContextPackLineageReceipt {
23233 source_node_id: shared.node_id.clone(),
23234 source_session: terminal_address(1),
23235 source_provider: agent("claude"),
23236 },
23237 &history,
23238 )
23239 .unwrap();
23240 let context_id = pack.receipt().id.clone();
23241 let session = terminal_address(1);
23242 let identity = ProviderSessionIdentity {
23243 key: ProviderSessionKey::SessionId,
23244 id: history.session_id.clone(),
23245 transcript_path: None,
23246 };
23247 let mut expected = record("claude", "record-context-export-final-conflict");
23248 expected.state = ManagedSessionState::Live;
23249 expected.provider_session = Some(identity.clone());
23250 expected.active_session = Some(session.clone());
23251 let mut changed = expected.clone();
23252 changed.updated_at_unix_ms += 1;
23253 shared.insert_record(changed).unwrap();
23254
23255 let error = shared
23256 .commit_context_pack_for_session_record(
23257 &expected,
23258 Some(&identity),
23259 &session,
23260 "candidate-exact",
23261 &history,
23262 pack,
23263 false,
23264 )
23265 .unwrap_err();
23266
23267 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23268 assert!(shared
23269 .context_catalog
23270 .read()
23271 .unwrap_or_else(|poisoned| poisoned.into_inner())
23272 .get(&context_id)
23273 .is_none());
23274 }
23275
23276 #[tokio::test]
23277 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23278 let root = temporary_workspace_root("session-task-binding");
23279 std::fs::create_dir_all(&root).unwrap();
23280 let state_path = root.join("node-state.json");
23281 let catalog = active_registry().unwrap();
23282 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23283 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23284 let node_id = NodeId::new("node-1").unwrap();
23285 let mut shared = NodeShared::new(
23286 handle,
23287 "fixture-token".to_owned(),
23288 node_id.clone(),
23289 vec![workspace],
23290 vec![agent("claude")],
23291 );
23292 shared.state_path = Some(state_path.clone());
23293 let record = record("claude", "sr-task");
23294 let record_id = record.record_id.clone();
23295 shared.insert_record(record).unwrap();
23296 shared.persist_state().unwrap();
23297 shared
23298 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23299 .unwrap();
23300
23301 let minted = process_request_inner(
23302 &shared,
23303 77,
23304 ClientRole::Operator,
23305 NodeRequest::SetSessionTask {
23306 record_id: record_id.clone(),
23307 expected_revision: 0,
23308 target: SessionTaskTargetV1::New,
23309 },
23310 )
23311 .await
23312 .unwrap();
23313 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23314 panic!("session task mutation returned another response");
23315 };
23316 let minted_binding = minted.task_binding.clone().unwrap();
23317 let minted_task_id = minted_binding.task_id.clone().unwrap();
23318 assert_eq!(minted_binding.revision, 1);
23319 assert!(minted_task_id.as_str().starts_with("task-"));
23320 assert_eq!(minted_task_id.as_str().len(), 29);
23321
23322 let event_count = shared.current_sequence();
23323 let repeated = process_request_inner(
23324 &shared,
23325 77,
23326 ClientRole::Operator,
23327 NodeRequest::SetSessionTask {
23328 record_id: record_id.clone(),
23329 expected_revision: 1,
23330 target: SessionTaskTargetV1::Existing {
23331 task_id: minted_task_id.clone(),
23332 },
23333 },
23334 )
23335 .await
23336 .unwrap();
23337 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23338 assert_eq!(shared.current_sequence(), event_count);
23339
23340 let stale = process_request_inner(
23341 &shared,
23342 77,
23343 ClientRole::Operator,
23344 NodeRequest::SetSessionTask {
23345 record_id: record_id.clone(),
23346 expected_revision: 0,
23347 target: SessionTaskTargetV1::Existing {
23348 task_id: minted_task_id,
23349 },
23350 },
23351 )
23352 .await
23353 .unwrap_err();
23354 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23355
23356 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23357 let assigned = process_request_inner(
23358 &shared,
23359 77,
23360 ClientRole::Operator,
23361 NodeRequest::SetSessionTask {
23362 record_id: record_id.clone(),
23363 expected_revision: 1,
23364 target: SessionTaskTargetV1::Existing {
23365 task_id: assigned_id.clone(),
23366 },
23367 },
23368 )
23369 .await
23370 .unwrap();
23371 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23372 panic!("session task assignment returned another response");
23373 };
23374 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23375 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23376
23377 let cleared = process_request_inner(
23378 &shared,
23379 77,
23380 ClientRole::Operator,
23381 NodeRequest::SetSessionTask {
23382 record_id: record_id.clone(),
23383 expected_revision: 2,
23384 target: SessionTaskTargetV1::Clear,
23385 },
23386 )
23387 .await
23388 .unwrap();
23389 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23390 panic!("session task clear returned another response");
23391 };
23392 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23393 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23394 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23395 assert_eq!(
23396 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23397 cleared.updated_at_unix_ms,
23398 );
23399
23400 let persisted = std::fs::read(&state_path).unwrap();
23401 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23402 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23403 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23404 std::fs::remove_dir_all(root).unwrap();
23405 }
23406
23407 #[test]
23408 fn session_task_capability_strips_snapshots_events_and_replies() {
23409 let mut bound = record("claude", "sr-task-projection");
23410 bound.updated_at_unix_ms = 2;
23411 bound.task_binding = Some(SessionTaskBindingV1 {
23412 revision: 1,
23413 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23414 changed_at_unix_ms: 2,
23415 });
23416 let request = NodeRequest::SetSessionTask {
23417 record_id: bound.record_id.clone(),
23418 expected_revision: 1,
23419 target: SessionTaskTargetV1::Clear,
23420 };
23421 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23422 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23423 }));
23424 assert!(!request_uses_unnegotiated_capability(
23425 &request,
23426 &baseline_capabilities().unwrap(),
23427 ));
23428 assert!(request_uses_unnegotiated_capability(&request, &[]));
23429
23430 let mut snapshot = terminal_test_shared().snapshot();
23431 snapshot.session_records.push(bound.clone());
23432 let mut reply = ResponseEnvelope {
23433 request_id: 1,
23434 result: Ok(NodeResponse::Resync {
23435 event_sequence: 1,
23436 oldest_available_sequence: 1,
23437 snapshot,
23438 events: vec![NodeEventEnvelope {
23439 sequence: 1,
23440 event: NodeEvent::SessionRecordUpserted {
23441 record: bound.clone(),
23442 },
23443 }],
23444 }),
23445 };
23446 project_response_without_session_task_binding(&mut reply);
23447 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23448 panic!("task projection changed the response kind");
23449 };
23450 assert!(snapshot.session_records[0].task_binding.is_none());
23451 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
23452 panic!("task projection changed the event kind");
23453 };
23454 assert!(record.task_binding.is_none());
23455
23456 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
23457 sequence: 2,
23458 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
23459 });
23460 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
23461 panic!("task event projection changed the event kind");
23462 };
23463 assert!(record.task_binding.is_none());
23464
23465 let mut direct = ResponseEnvelope {
23466 request_id: 2,
23467 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
23468 };
23469 project_response_without_session_task_binding(&mut direct);
23470 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
23471 panic!("task reply projection changed the response kind");
23472 };
23473 assert!(record.task_binding.is_none());
23474 }
23475
23476 #[test]
23477 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
23478 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
23479 let workspace = WorkspaceConfig::new(
23480 WorkspaceId::new("primary").unwrap(),
23481 std::env::current_dir().unwrap(),
23482 )
23483 .unwrap();
23484 let config = NodeServerConfig::new(
23485 DEFAULT_NODE_ENDPOINT,
23486 "fixture-token",
23487 NodeId::new("node-1").unwrap(),
23488 [workspace],
23489 )
23490 .unwrap();
23491 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
23492 }
23493
23494 #[cfg(all(feature = "fixture", windows))]
23495 #[test]
23496 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
23497 let root = temporary_workspace_root("provider-bundle-argv-hold");
23498 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
23499 std::fs::create_dir_all(&root).unwrap();
23500 std::fs::create_dir_all(&outside).unwrap();
23501 let proof_path = root.join("provider.proof");
23502 let release_signal = root.join("release.signal");
23503 let config = NodeServerConfig::new(
23504 format!(
23505 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
23506 std::process::id(),
23507 ),
23508 "fixture-token",
23509 NodeId::new("provider-bundle-hold-node").unwrap(),
23510 [WorkspaceConfig::new(
23511 WorkspaceId::new("primary").unwrap(),
23512 std::env::current_dir().unwrap(),
23513 )
23514 .unwrap()],
23515 )
23516 .unwrap()
23517 .with_spawn_profiles(SpawnProfileRegistry::new([
23518 crate::protocol::SpawnProfileDefaults {
23519 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
23520 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
23521 .unwrap(),
23522 provider: agent("kimi"),
23523 mode: SessionMode::Pty,
23524 terminal_size: gate4agent_types::TerminalSize {
23525 rows: 24,
23526 columns: 80,
23527 },
23528 prompt: None,
23529 bundle_id: None,
23530 context_id: None,
23531 environment_profile_id: None,
23532 },
23533 ])
23534 .unwrap());
23535 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
23536 config,
23537 agent("kimi"),
23538 proof_path.clone(),
23539 release_signal.clone(),
23540 )
23541 .unwrap();
23542 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
23543
23544 let spec = NodeServer::provider_bundle_argv_fixture_spec(
23545 agent("kimi"),
23546 proof_path.clone(),
23547 Some(release_signal.clone()),
23548 )
23549 .unwrap();
23550 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
23551 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
23552 let release_wait = wrapper
23553 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
23554 .unwrap();
23555 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
23556 assert!(proof_write < release_wait);
23557 assert!(release_wait < fixture_ready);
23558 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
23559 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
23560 assert_eq!(
23561 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
23562 &[
23563 proof_path.to_string_lossy().into_owned(),
23564 release_signal.to_string_lossy().into_owned(),
23565 ],
23566 );
23567
23568 let error = NodeServer::provider_bundle_argv_fixture_spec(
23569 agent("kimi"),
23570 proof_path,
23571 Some(outside.join("release.signal")),
23572 )
23573 .unwrap_err();
23574 assert!(matches!(
23575 error,
23576 NodeServerError::Registry(message)
23577 if message == "provider bundle argv release path must share the proof parent"
23578 ));
23579 drop(server);
23580 std::fs::remove_dir_all(root).unwrap();
23581 std::fs::remove_dir_all(outside).unwrap();
23582 }
23583
23584 #[cfg(feature = "fixture")]
23585 #[test]
23586 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
23587 let root = temporary_workspace_root("context-fixture-registry");
23588 std::fs::create_dir_all(&root).unwrap();
23589 let proof_path = root.join("context.proof");
23590 let config = NodeServerConfig::new(
23591 format!(
23592 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
23593 std::process::id(),
23594 ),
23595 "fixture-token",
23596 NodeId::new("context-fixture-node").unwrap(),
23597 [WorkspaceConfig::new(
23598 WorkspaceId::new("primary").unwrap(),
23599 std::env::current_dir().unwrap(),
23600 )
23601 .unwrap()],
23602 )
23603 .unwrap();
23604 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
23605 let mut enabled = server
23606 .shared
23607 .enabled_providers
23608 .iter()
23609 .map(AgentId::as_str)
23610 .collect::<Vec<_>>();
23611 enabled.sort_unstable();
23612 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
23613 for provider in &server.shared.enabled_providers {
23614 assert!(server
23615 .shared
23616 .provider_adapter_contracts
23617 .iter()
23618 .any(|contract| {
23619 &contract.provider == provider
23620 && contract.family == AdapterFamily::History
23621 }));
23622 }
23623 drop(server);
23624 std::fs::remove_dir_all(root).unwrap();
23625 }
23626
23627 #[cfg(feature = "fixture")]
23628 #[test]
23629 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
23630 let root = temporary_workspace_root("monitoring-context-fixture");
23631 std::fs::create_dir_all(&root).unwrap();
23632 let proof_path = root.join("context.proof");
23633 let standard = NodeServer::context_pack_fixture_catalog(
23634 proof_path.clone(),
23635 None,
23636 false,
23637 ).unwrap();
23638 let monitoring = NodeServer::context_pack_fixture_catalog(
23639 proof_path.clone(),
23640 None,
23641 true,
23642 ).unwrap();
23643 let claude = AgentId::new("claude").unwrap();
23644 let standard_claude = standard.get(&claude).unwrap();
23645 let monitoring_claude = monitoring.get(&claude).unwrap();
23646 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
23650 assert_eq!(
23651 monitoring_claude.capabilities.adapters.history,
23652 standard_claude.capabilities.adapters.history,
23653 );
23654 assert_ne!(monitoring_claude.launch, standard_claude.launch);
23655 let codex = AgentId::new("codex").unwrap();
23656 let standard_codex = standard.get(&codex).unwrap();
23657 let monitoring_codex = monitoring.get(&codex).unwrap();
23658 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
23659 assert_eq!(
23660 monitoring_codex.capabilities.adapters.history,
23661 standard_codex.capabilities.adapters.history,
23662 );
23663 assert_ne!(monitoring_codex.launch, standard_codex.launch);
23664 let codex_validation = monitoring_codex.launch.fixed_args.iter()
23665 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
23666 .unwrap();
23667 for required in [
23668 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
23669 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
23670 "@('role', 'text')",
23671 "g4a-private-provider-session-canary",
23672 "private-provider-login-identity",
23673 "$rawProof",
23674 "$env:GATE4AGENT_HOOK_URL",
23675 "g4a-private-provider-session-canary",
23676 ] {
23677 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
23678 }
23679 for provider in ["grok", "kimi"] {
23680 let provider = AgentId::new(provider).unwrap();
23681 assert_eq!(monitoring.get(&provider), standard.get(&provider));
23682 }
23683
23684 let config = NodeServerConfig::new(
23685 format!(
23686 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
23687 std::process::id(),
23688 ),
23689 "fixture-token",
23690 NodeId::new("monitoring-context-fixture-node").unwrap(),
23691 [WorkspaceConfig::new(
23692 WorkspaceId::new("primary").unwrap(),
23693 std::env::current_dir().unwrap(),
23694 )
23695 .unwrap()],
23696 )
23697 .unwrap();
23698 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
23699 assert!(server.shared.fixture_semantic_hook_policy);
23700 drop(server);
23701 std::fs::remove_dir_all(root).unwrap();
23702 }
23703
23704 #[cfg(feature = "fixture")]
23705 #[test]
23706 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
23707 let root = temporary_workspace_root("kimi-context-only-fixture");
23708 std::fs::create_dir_all(&root).unwrap();
23709 let proof_path = root.join("context.proof");
23710 let standard = NodeServer::context_pack_fixture_catalog(
23711 proof_path.clone(),
23712 None,
23713 false,
23714 ).unwrap();
23715 let kimi = AgentId::new("kimi").unwrap();
23716 let context_only = NodeServer::context_pack_fixture_catalog(
23717 proof_path,
23718 Some(&kimi),
23719 false,
23720 )
23721 .unwrap();
23722
23723 let codex = AgentId::new("codex").unwrap();
23724 assert_eq!(
23725 standard.get(&codex).unwrap().launch.fixed_args,
23726 context_only.get(&codex).unwrap().launch.fixed_args,
23727 );
23728 let script = context_only
23729 .get(&kimi)
23730 .unwrap()
23731 .launch
23732 .fixed_args
23733 .iter()
23734 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
23735 .expect("Kimi context-only validation script is missing");
23736 for required in [
23737 "$bundleArgs.Count -ne 0",
23738 "GATE4AGENT_CONTEXT_ROOT",
23739 "context-pack.json",
23740 "$entries.Count -ne 1",
23741 "g4a-context-pack-v1",
23742 "$document.PSObject.Properties['cwd']",
23743 "'claude', 'codex', 'grok', 'kimi'",
23744 "$hasUser",
23745 "$hasAssistant",
23746 "SHA256",
23747 "context-only",
23748 ] {
23749 assert!(script.contains(required), "missing validation: {required}");
23750 }
23751 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
23752 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
23753 }
23754 assert!(!standard
23755 .get(&kimi)
23756 .unwrap()
23757 .launch
23758 .fixed_args
23759 .iter()
23760 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
23761
23762 std::fs::remove_dir_all(root).unwrap();
23763 }
23764
23765 #[cfg(all(feature = "fixture", windows))]
23766 #[tokio::test]
23767 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
23768 let root = temporary_workspace_root("kimi-context-only-spawn");
23769 let workspace_root = root.join("workspace");
23770 std::fs::create_dir_all(&workspace_root).unwrap();
23771 let materialization_root = root.join("private-materializations");
23772 let proof_path = root.join("context.proof");
23773 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
23774 let workspace_id = WorkspaceId::new("primary").unwrap();
23775 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
23776 let endpoint = format!(
23777 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
23778 std::process::id(),
23779 unix_time_ms(),
23780 );
23781 let spawn_profiles = SpawnProfileRegistry::new([
23782 crate::protocol::SpawnProfileDefaults {
23783 profile_id: profile_id.clone(),
23784 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
23785 provider: agent("kimi"),
23786 mode: SessionMode::Pty,
23787 terminal_size: gate4agent_types::TerminalSize {
23788 rows: 30,
23789 columns: 120,
23790 },
23791 prompt: None,
23792 bundle_id: None,
23793 context_id: None,
23794 environment_profile_id: None,
23795 },
23796 ])
23797 .unwrap();
23798 let mut config = NodeServerConfig::new(
23799 &endpoint,
23800 "fixture-token",
23801 node_id.clone(),
23802 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
23803 )
23804 .unwrap()
23805 .with_spawn_profiles(spawn_profiles)
23806 .with_session_environment_materialization(
23807 materialization_root,
23808 Arc::new(FixtureSecretResolver {
23809 deny: Arc::new(AtomicBool::new(false)),
23810 }),
23811 )
23812 .unwrap();
23813 config.fixture_raw_pty_runtime = true;
23814 let server = NodeServer::new_context_only_proof_fixture(
23815 config,
23816 agent("kimi"),
23817 proof_path.clone(),
23818 )
23819 .unwrap();
23820 let shared = Arc::clone(&server.shared);
23821 let shutdown = server.shutdown_handle();
23822
23823 let history = HistorySessionRecord {
23824 session_id: "context-only-source-history".to_owned(),
23825 title: None,
23826 cwd: None,
23827 model: None,
23828 message_count: 2,
23829 completed_turn_count: None,
23830 total_tokens: 0,
23831 messages: vec![
23832 gate4agent_types::HistoryMessageRecord {
23833 role: gate4agent_types::HistoryMessageRole::User,
23834 text: "continue the bounded parent run through native C2".to_owned(),
23835 },
23836 gate4agent_types::HistoryMessageRecord {
23837 role: gate4agent_types::HistoryMessageRole::Assistant,
23838 text: "the exact parent context is ready for the child run".to_owned(),
23839 },
23840 ],
23841 };
23842 let pack = NodeContextPack::export(
23843 ContextPackLineageReceipt {
23844 source_node_id: node_id.clone(),
23845 source_session: SessionAddress {
23846 workspace_id: workspace_id.clone(),
23847 session: SessionKey {
23848 instance_id: AgentInstanceId(71),
23849 generation: SessionGeneration(1),
23850 },
23851 },
23852 source_provider: agent("codex"),
23853 },
23854 &history,
23855 )
23856 .unwrap();
23857 let expected_context_hash = digest(&SHA256, pack.bytes())
23858 .as_ref()
23859 .iter()
23860 .map(|byte| format!("{byte:02x}"))
23861 .collect::<String>();
23862 let context = shared
23863 .context_catalog
23864 .write()
23865 .unwrap_or_else(|poisoned| poisoned.into_inner())
23866 .insert(pack)
23867 .unwrap();
23868 let spec = SpawnSpec {
23869 target: crate::protocol::SpawnTarget {
23870 node_id,
23871 workspace_id: workspace_id.clone(),
23872 worktree_id: None,
23873 },
23874 profile_id,
23875 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
23876 "target-kimi-r1",
23877 ).unwrap(),
23878 overrides: crate::protocol::SpawnOverrides {
23879 context_id: crate::protocol::SpawnOverride::Set {
23880 value: context.id.clone(),
23881 },
23882 ..crate::protocol::SpawnOverrides::default()
23883 },
23884 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
23885 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
23886 required_capabilities: SpawnRequiredCapabilities::default(),
23887 };
23888 let resolved = match shared.resolve_spawn_spec(&spec) {
23889 Ok(resolved) => resolved,
23890 Err(error) => panic!(
23891 "Kimi context-only resolve rejected: exact_code={:?} message={}",
23892 error.code,
23893 error.message,
23894 ),
23895 };
23896 let resolved_context = match shared.resolve_context(&resolved) {
23897 Ok(Some(resolved_context)) => resolved_context,
23898 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
23899 Err(error) => panic!(
23900 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
23901 error.code,
23902 error.message,
23903 ),
23904 };
23905 assert_eq!(resolved_context, context);
23906 let runtime_policy = match shared
23907 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
23908 .await
23909 {
23910 Ok(runtime_policy) => runtime_policy,
23911 Err(error) => panic!(
23912 "Kimi RawPty admission rejected: exact_code={:?} message={}",
23913 error.code,
23914 error.message,
23915 ),
23916 };
23917 assert!(runtime_policy.raw_pty_lifecycle);
23918
23919 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
23920 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
23921 agent("kimi"),
23922 TransportKind::Pty,
23923 vec![EnvMutation {
23924 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
23925 value: Some(OsString::from("must-remain-rejected")),
23926 }],
23927 )
23928 .unwrap();
23929 assert_eq!(
23930 launch_control
23931 .install_native_launch_environment_overlay(
23932 AgentInstanceId(72),
23933 generic_overlay,
23934 )
23935 .unwrap_err(),
23936 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
23937 );
23938
23939 let diagnostic_address = SessionAddress {
23940 workspace_id: workspace_id.clone(),
23941 session: SessionKey {
23942 instance_id: AgentInstanceId(73),
23943 generation: SessionGeneration(1),
23944 },
23945 };
23946 let (diagnostic_overlay, diagnostic_materialization) = match shared
23947 .prepare_session_materialization(
23948 &diagnostic_address,
23949 &agent("kimi"),
23950 SessionMode::Pty,
23951 None,
23952 None,
23953 Some(&resolved_context),
23954 None,
23955 )
23956 {
23957 Ok(Some(prepared)) => prepared,
23958 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
23959 Err(error) => panic!(
23960 "Kimi context-only materialization rejected: exact_code={:?} message={}",
23961 error.code,
23962 error.message,
23963 ),
23964 };
23965 let diagnostic_overlay = diagnostic_overlay
23966 .expect("Kimi context-only materialization omitted its environment overlay");
23967 assert!(matches!(
23968 &diagnostic_overlay,
23969 PreparedNativeLaunchOverlay::Environment(_),
23970 ));
23971 match shared.install_prepared_launch_overlay(
23972 diagnostic_address.session.instance_id,
23973 diagnostic_overlay,
23974 ) {
23975 Ok(None) => {}
23976 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
23977 Err(error) => panic!(
23978 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
23979 error.code,
23980 error.message,
23981 ),
23982 }
23983 assert!(launch_control.clear_native_instance_launch_overlay(
23984 diagnostic_address.session.instance_id,
23985 ));
23986 drop(diagnostic_materialization);
23987 assert!(shared
23988 .materializations
23989 .lock()
23990 .unwrap_or_else(|poisoned| poisoned.into_inner())
23991 .is_empty());
23992
23993 let node_task = tokio::spawn(server.run());
23994 let ready_client = timeout(Duration::from_secs(5), async {
23995 loop {
23996 match gate4agent_node_wire::LocalNodeClient::connect(
23997 &endpoint,
23998 &shared.node_id,
23999 ClientRole::Observer,
24000 "fixture-token",
24001 )
24002 .await
24003 {
24004 Ok(client) => break client,
24005 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24006 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24007 }
24008 }
24009 })
24010 .await
24011 .expect("Kimi context-only Node did not become ready");
24012 assert_eq!(
24013 ready_client
24014 .hello()
24015 .snapshot
24016 .provider_runtime_statuses
24017 .iter()
24018 .find(|status| status.provider().as_str() == "kimi")
24019 .expect("Kimi runtime status is missing")
24020 .mode(),
24021 crate::protocol::ProviderRuntimeMode::RawPassthrough,
24022 );
24023 drop(ready_client);
24024
24025 let receipt = match shared.spawn_from_spec(spec).await {
24026 Ok(receipt) => receipt,
24027 Err(error) => panic!(
24028 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24029 error.code,
24030 error.message,
24031 ),
24032 };
24033 assert_eq!(receipt.provider, agent("kimi"));
24034 assert_eq!(receipt.context.as_ref(), Some(&context));
24035 assert!(receipt.context_binding_is_valid());
24036 timeout(Duration::from_secs(5), async {
24037 while !proof_path.is_file() {
24038 sleep(Duration::from_millis(10)).await;
24039 }
24040 })
24041 .await
24042 .expect("Kimi context-only fixture did not write its proof");
24043
24044 let proof = std::fs::read_to_string(&proof_path).unwrap();
24045 let lines = proof.lines().collect::<Vec<_>>();
24046 assert_eq!(lines.len(), 7);
24047 assert_eq!(lines[0], "context-only");
24048 assert!(Path::new(lines[1]).is_absolute());
24049 assert_ne!(
24050 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24051 std::fs::canonicalize(&workspace_root).unwrap(),
24052 );
24053 assert_eq!(
24054 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24055 std::fs::canonicalize(&workspace_root).unwrap(),
24056 );
24057 assert_eq!(lines[3], expected_context_hash);
24058 assert_eq!(lines[4], "g4a-context-pack-v1");
24059 assert_eq!(lines[5], "codex");
24060 assert_eq!(lines[6], "2");
24061 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24062 session.instance_id == receipt.session.session.instance_id
24063 && session.agent_id.as_str() == "kimi"
24064 }));
24065
24066 shutdown.request_shutdown().await.unwrap();
24067 timeout(Duration::from_secs(5), node_task)
24068 .await
24069 .unwrap()
24070 .unwrap()
24071 .unwrap();
24072 drop(shutdown);
24073 drop(shared);
24074 std::fs::remove_dir_all(root).unwrap();
24075 }
24076
24077 #[cfg(feature = "fixture")]
24078 #[test]
24079 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24080 let root = temporary_workspace_root("clean-exit-constructor");
24081 std::fs::create_dir_all(&root).unwrap();
24082 let config = construction_test_config("clean-exit-constructor")
24083 .with_state_path(root.join("node-state.json"))
24084 .unwrap();
24085 let server = NodeServer::new_clean_exit_fixture(
24086 config,
24087 root.clone(),
24088 root.join("started.marker"),
24089 root.join("release.signal"),
24090 )
24091 .unwrap();
24092 assert_eq!(
24093 server
24094 .shared
24095 .enabled_providers
24096 .iter()
24097 .map(AgentId::as_str)
24098 .collect::<Vec<_>>(),
24099 ["claude"],
24100 );
24101 drop(server);
24102 std::fs::remove_dir_all(root).unwrap();
24103 }
24104
24105 #[test]
24106 fn production_registry_provider_contract_manifest_is_exact() {
24107 let registry = active_registry().unwrap();
24108 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24109 assert_eq!(
24110 providers
24111 .iter()
24112 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24113 .collect::<Vec<_>>(),
24114 vec![
24115 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24116 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24117 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24118 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24119 ]
24120 );
24121 assert_eq!(
24122 adapters
24123 .iter()
24124 .map(|contract| (
24125 contract.provider.as_str(),
24126 contract.family,
24127 contract.adapter_id.as_str(),
24128 contract.revision.as_str(),
24129 ))
24130 .collect::<Vec<_>>(),
24131 vec![
24132 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24133 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24134 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24135 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24136 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24137 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24138 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24139 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24140 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24141 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24142 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24143 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24144 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24145 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24146 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24147 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24148 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24149 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24150 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24151 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24152 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24153 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24154 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24155 ]
24156 );
24157 }
24158
24159 fn construction_test_config(name: &str) -> NodeServerConfig {
24160 let workspace = WorkspaceConfig::new(
24161 WorkspaceId::new("primary").unwrap(),
24162 std::env::current_dir().unwrap(),
24163 )
24164 .unwrap();
24165 NodeServerConfig::new(
24166 format!(r"\\.\pipe\gate4agent-{name}"),
24167 "fixture-token",
24168 NodeId::new(name).unwrap(),
24169 [workspace],
24170 )
24171 .unwrap()
24172 }
24173
24174 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24175 let adapter = AdapterBinding::new(
24176 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24177 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24178 gate4agent_types::AdapterVerification::SyntheticFixture,
24179 )
24180 .unwrap();
24181 let agent_ids = ["claude", "codex", "kimi"]
24182 .into_iter()
24183 .map(|agent_id| AgentId::new(agent_id).unwrap())
24184 .collect::<Vec<_>>();
24185 let adapters = gate4agent_catalog::AdapterRegistry::new(
24186 [
24187 AdapterFamily::PtySemantic,
24188 AdapterFamily::Pipe,
24189 AdapterFamily::OneShot,
24190 AdapterFamily::Acp,
24191 AdapterFamily::Hook,
24192 AdapterFamily::ManagedHook,
24193 AdapterFamily::History,
24194 AdapterFamily::Resume,
24195 AdapterFamily::SessionOptions,
24196 AdapterFamily::CapabilityProbe,
24197 ]
24198 .into_iter()
24199 .map(|family| gate4agent_catalog::AdapterDescriptor {
24200 family,
24201 binding: adapter.clone(),
24202 agents: agent_ids.clone(),
24203 }),
24204 )
24205 .unwrap();
24206 let specs = agent_ids
24207 .iter()
24208 .map(|agent_id| {
24209 let agent_id = agent_id.as_str();
24210 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24211 spec.revision =
24212 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24213 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24214 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24215 adapter: adapter.clone(),
24216 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24217 launch_override: None,
24218 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24219 });
24220 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24221 adapter: adapter.clone(),
24222 launch_override: None,
24223 });
24224 spec.capabilities.adapters.hook = Some(adapter.clone());
24225 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24226 spec.capabilities.adapters.one_shot = Some(adapter.clone());
24227 spec.capabilities.adapters.history = Some(adapter.clone());
24228 spec.capabilities.adapters.resume = Some(adapter.clone());
24229 spec.capabilities.adapters.session_options = Some(adapter.clone());
24230 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24231 spec
24232 })
24233 .collect::<Vec<_>>();
24234 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24235 }
24236
24237 #[test]
24245 fn node_server_construction_accepts_the_production_manifest() {
24246 let registry = active_registry().unwrap();
24247 let (providers, _) = provider_contract_manifest(®istry).unwrap();
24248 assert_eq!(providers.len(), registry.iter().count());
24249 assert!(!providers.is_empty());
24250 let server = NodeServer::new_with_registry(
24251 construction_test_config("manifest-production-bound"),
24252 registry,
24253 )
24254 .unwrap();
24255 drop(server);
24256 }
24257
24258 #[test]
24259 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24260 let registry = maximum_size_provider_manifest_registry();
24261 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24262 assert_eq!(providers.len(), 3);
24263 assert_eq!(adapters.len(), 30);
24264 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24265 let error = match NodeServer::new_with_registry(
24266 construction_test_config("manifest-handshake-overflow"),
24267 registry,
24268 ) {
24269 Ok(_) => panic!("oversized negotiated manifest was accepted"),
24270 Err(error) => error,
24271 };
24272 assert!(matches!(
24273 error,
24274 NodeServerError::ProviderContractManifest(message)
24275 if message.contains("authentication binding length")
24276 && message.contains("8192-byte limit")
24277 ));
24278 }
24279
24280 #[test]
24281 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24282 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24283 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24284 let registry = AgentRegistry::new([spec]).unwrap();
24285 assert!(matches!(
24286 provider_contract_manifest(®istry),
24287 Err(NodeServerError::ProviderContractManifest(message))
24288 if message.contains("exceeds the 128-byte limit")
24289 ));
24290 }
24291
24292 #[test]
24293 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24294 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24295 spec.revision = "invalid revision".to_owned();
24296 let registry = AgentRegistry::new([spec]).unwrap();
24297 assert!(matches!(
24298 provider_contract_manifest(®istry),
24299 Err(NodeServerError::ProviderContractManifest(message))
24300 if message.contains("bounded lowercase ASCII")
24301 ));
24302 }
24303
24304 #[test]
24305 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24306 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24307 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24308 let conflicting_binding = builtin_registry()
24309 .get_by_id("codex")
24310 .unwrap()
24311 .capabilities
24312 .adapters
24313 .one_shot
24314 .clone()
24315 .unwrap();
24316 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24317 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24318 pipe.adapter = transport_binding;
24319 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24320 let registry = AgentRegistry::new([spec]).unwrap();
24321 assert!(matches!(
24322 provider_contract_manifest(®istry),
24323 Err(NodeServerError::ProviderContractManifest(message))
24324 if message.contains("conflicting OneShot adapter bindings")
24325 ));
24326 }
24327
24328 #[test]
24329 fn public_node_failures_expose_only_fixed_categories() {
24330 let provider = failure(
24331 NodeFailureCode::BackendOperationFailed,
24332 r"provider bearer-secret failed at C:\private\session.jsonl",
24333 );
24334 assert_eq!(provider.message, "backend-operation-failed");
24335 assert!(!provider.message.contains("bearer-secret"));
24336 assert!(!provider.message.contains(r"C:\private"));
24337 let persistence = persistence_failure(io::Error::new(
24338 io::ErrorKind::Other,
24339 r"state write failed at C:\private\state-v1.json",
24340 ));
24341 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24342 assert!(!persistence.message.contains(r"C:\private"));
24343
24344 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24345 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24346 assert_eq!(
24347 generic.to_string(),
24348 "node durable state failed: durable-state-load-failed",
24349 );
24350 }
24351 }
24352
24353 #[test]
24354 fn terminal_byte_sequences_are_bounded_before_dispatch() {
24355 let sequence = b"\x1b[1;5D".to_vec();
24356 assert_eq!(
24357 terminal_bytes_action(sequence.clone()).unwrap(),
24358 InputAction::TerminalBytes(sequence),
24359 );
24360 assert_eq!(
24361 terminal_bytes_action(Vec::new()).unwrap_err().code,
24362 NodeFailureCode::InvalidRequest,
24363 );
24364 assert_eq!(
24365 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24366 .unwrap_err()
24367 .code,
24368 NodeFailureCode::InvalidRequest,
24369 );
24370 }
24371
24372 #[test]
24373 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24374 let root = temporary_workspace_root("exclusive-state-owner");
24375 std::fs::create_dir_all(&root).unwrap();
24376 let workspace = WorkspaceConfig::new(
24377 WorkspaceId::new("primary").unwrap(),
24378 &root,
24379 )
24380 .unwrap();
24381 let state_path = root.join("state-v1.json");
24382 let endpoint = format!(
24383 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24384 std::process::id(),
24385 std::time::SystemTime::now()
24386 .duration_since(std::time::UNIX_EPOCH)
24387 .unwrap()
24388 .as_nanos(),
24389 );
24390 let config = NodeServerConfig::new(
24391 endpoint,
24392 "fixture-token",
24393 NodeId::new("node-1").unwrap(),
24394 [workspace],
24395 )
24396 .unwrap()
24397 .with_state_path(&state_path)
24398 .unwrap();
24399
24400 let first = NodeServer::new(config.clone()).unwrap();
24401 let error = match NodeServer::new(config.clone()) {
24402 Ok(_) => panic!("a second node server acquired the same durable state path"),
24403 Err(error) => error,
24404 };
24405 assert!(matches!(
24406 &error,
24407 NodeServerError::DurableState(error)
24408 if error.kind() == io::ErrorKind::WouldBlock
24409 ));
24410 assert_eq!(
24411 error.to_string(),
24412 "node durable state failed: durable-state-lock-failed",
24413 );
24414 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24415 drop(first);
24416 let second = NodeServer::new(config).unwrap();
24417 drop(second);
24418 std::fs::remove_dir_all(root).unwrap();
24419 }
24420
24421 #[test]
24422 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24423 let catalog = active_registry().unwrap();
24424 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24425 let workspace_id = WorkspaceId::new("primary").unwrap();
24426 let workspace = WorkspaceConfig::new(
24427 workspace_id.clone(),
24428 std::env::current_dir().unwrap(),
24429 )
24430 .unwrap();
24431 let shared = NodeShared::new(
24432 handle,
24433 "fixture-token".to_owned(),
24434 NodeId::new("node-1").unwrap(),
24435 vec![workspace.clone()],
24436 vec![agent("claude"), agent("codex"), agent("kimi")],
24437 );
24438 let instance_id = AgentInstanceId(17);
24439 let generation = SessionGeneration(3);
24440 assert!(shared.address_for(instance_id, generation).is_none());
24441 shared.bind_session(instance_id, workspace_id.clone(), generation);
24442 let address = SessionAddress {
24443 workspace_id,
24444 session: SessionKey { instance_id, generation },
24445 };
24446 assert_eq!(
24447 shared.workspace_root(&address.workspace_id).unwrap(),
24448 workspace.canonical_root,
24449 );
24450 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
24451 shared.remove_binding(&address);
24452 assert!(shared.address_for(instance_id, generation).is_none());
24453 }
24454
24455 #[test]
24456 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
24457 let address = SessionAddress {
24458 workspace_id: WorkspaceId::new("primary").unwrap(),
24459 session: SessionKey {
24460 instance_id: AgentInstanceId(7),
24461 generation: SessionGeneration(2),
24462 },
24463 };
24464 let record_id = SessionRecordId::new("record-a").unwrap();
24465 let size = gate4agent_types::TerminalSize {
24466 rows: 24,
24467 columns: 80,
24468 };
24469 let address_requests = [
24470 NodeRequest::Resume {
24471 session: address.clone(),
24472 terminal_size: size,
24473 initial_prompt: None,
24474 },
24475 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
24476 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
24477 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
24478 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
24479 NodeRequest::TerminalControl {
24480 session: address.clone(),
24481 control: TerminalControl::Enter,
24482 },
24483 NodeRequest::Resize { session: address.clone(), size },
24484 NodeRequest::Interrupt { session: address.clone() },
24485 NodeRequest::Stop { session: address.clone(), force: false },
24486 NodeRequest::Remove { session: address },
24487 ];
24488 for request in &address_requests {
24489 assert!(request_requires_open_provider_ids_with(
24490 request,
24491 |_| Some(agent("third-party-agent")),
24492 |_| Some(agent("claude")),
24493 ));
24494 assert!(!request_requires_open_provider_ids_with(
24495 request,
24496 |_| Some(agent("claude")),
24497 |_| Some(agent("claude")),
24498 ));
24499 assert!(request_requires_open_provider_ids_with(
24500 request,
24501 |_| None,
24502 |_| Some(agent("claude")),
24503 ));
24504 }
24505
24506 let record_requests = [
24507 NodeRequest::RenameSessionRecord {
24508 record_id: record_id.clone(),
24509 display_name: "renamed".to_owned(),
24510 },
24511 NodeRequest::ResumeSessionRecord {
24512 record_id: record_id.clone(),
24513 terminal_size: size,
24514 initial_prompt: None,
24515 },
24516 NodeRequest::ForgetSessionRecord { record_id },
24517 ];
24518 for request in &record_requests {
24519 assert!(request_requires_open_provider_ids_with(
24520 request,
24521 |_| Some(agent("claude")),
24522 |_| Some(agent("grok")),
24523 ));
24524 assert!(!request_requires_open_provider_ids_with(
24525 request,
24526 |_| Some(agent("claude")),
24527 |_| Some(agent("kimi")),
24528 ));
24529 assert!(request_requires_open_provider_ids_with(
24530 request,
24531 |_| Some(agent("claude")),
24532 |_| None,
24533 ));
24534 }
24535
24536 assert!(request_requires_open_provider_ids_with(
24537 &NodeRequest::Spawn {
24538 workspace_id: WorkspaceId::new("primary").unwrap(),
24539 provider: agent("third-party-agent"),
24540 mode: SessionMode::Pty,
24541 terminal_size: size,
24542 initial_prompt: None,
24543 },
24544 |_| None,
24545 |_| None,
24546 ));
24547 assert!(!request_requires_open_provider_ids_with(
24548 &NodeRequest::Snapshot,
24549 |_| None,
24550 |_| None,
24551 ));
24552 assert!(request_requires_open_provider_ids_with(
24553 &NodeRequest::ForgetContextPack {
24554 context_id: SpawnContextId::new("ctx-forget").unwrap(),
24555 },
24556 |_| None,
24557 |_| None,
24558 ));
24559 }
24560
24561 #[test]
24562 fn legacy_projection_filters_open_record_replies_and_event_history() {
24563 let catalog = active_registry().unwrap();
24564 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24565 let workspace = WorkspaceConfig::new(
24566 WorkspaceId::new("primary").unwrap(),
24567 std::env::current_dir().unwrap(),
24568 )
24569 .unwrap();
24570 let shared = NodeShared::new(
24571 handle,
24572 "fixture-token".to_owned(),
24573 NodeId::new("node-1").unwrap(),
24574 vec![workspace],
24575 vec![agent("claude"), agent("grok")],
24576 );
24577 let legacy = record("claude", "legacy-record");
24578 let open = record("grok", "open-record");
24579 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24580 record: legacy.clone(),
24581 });
24582 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24583 record: open.clone(),
24584 });
24585 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24586 record_id: legacy.record_id.clone(),
24587 });
24588 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24589 record_id: open.record_id.clone(),
24590 });
24591 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
24592 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
24593 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
24594 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
24595
24596 let mut record_reply = ResponseEnvelope {
24597 request_id: 1,
24598 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
24599 };
24600 project_response_legacy_provider_ids(&shared, &mut record_reply);
24601 assert!(matches!(
24602 record_reply.result,
24603 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
24604 ));
24605
24606 let mut resync = ResponseEnvelope {
24607 request_id: 2,
24608 result: Ok(NodeResponse::Resync {
24609 event_sequence: open_removed.sequence,
24610 oldest_available_sequence: 1,
24611 snapshot: shared.snapshot(),
24612 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
24613 }),
24614 };
24615 project_response_legacy_provider_ids(&shared, &mut resync);
24616 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
24617 panic!("legacy projection changed the resync response kind");
24618 };
24619 assert!(snapshot.session_records.iter().all(|item| {
24620 provider_id_is_legacy(&item.provider)
24621 }));
24622 assert_eq!(events.len(), 2);
24623 assert!(events.iter().all(|event| matches!(
24624 &event.event,
24625 NodeEvent::SessionRecordUpserted { record }
24626 if provider_id_is_legacy(&record.provider)
24627 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
24628 }
24629
24630 #[test]
24631 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
24632 let catalog = active_registry().unwrap();
24633 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24634 let workspace_id = WorkspaceId::new("primary").unwrap();
24635 let workspace = WorkspaceConfig::new(
24636 workspace_id.clone(),
24637 std::env::current_dir().unwrap(),
24638 )
24639 .unwrap();
24640 let shared = NodeShared::new(
24641 handle,
24642 "fixture-token".to_owned(),
24643 NodeId::new("node-1").unwrap(),
24644 vec![workspace],
24645 vec![agent("claude")],
24646 );
24647 for index in 0..CONTROL_SESSIONS_MAX {
24648 shared.bind_session(
24649 AgentInstanceId((index + 1) as u64),
24650 workspace_id.clone(),
24651 SessionGeneration(1),
24652 );
24653 }
24654 let error = shared.ensure_binding_capacity().unwrap_err();
24655 assert_eq!(error.code, NodeFailureCode::BackendBusy);
24656 }
24657
24658 #[tokio::test]
24659 async fn provider_spawn_admission_rejects_before_session_mutation() {
24660 let catalog = active_registry().unwrap();
24661 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24662 let workspace = WorkspaceConfig::new(
24663 WorkspaceId::new("primary").unwrap(),
24664 std::env::current_dir().unwrap(),
24665 )
24666 .unwrap();
24667 let mut shared = NodeShared::new(
24668 handle,
24669 "fixture-token".to_owned(),
24670 NodeId::new("node-provider-admission").unwrap(),
24671 vec![workspace],
24672 vec![agent("claude")],
24673 );
24674 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24675 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
24676 ])
24677 .unwrap();
24678 let terminal_size = gate4agent_types::TerminalSize {
24679 rows: 24,
24680 columns: 80,
24681 };
24682
24683 let unavailable = shared
24684 .spawn_session(
24685 WorkspaceId::new("primary").unwrap(),
24686 agent("claude"),
24687 SessionMode::Pty,
24688 terminal_size,
24689 None,
24690 )
24691 .await
24692 .unwrap_err();
24693 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
24694 assert_eq!(unavailable.message, "backend-operation-failed");
24695
24696 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24697 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
24698 agent("claude"),
24699 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
24700 ),
24701 ])
24702 .unwrap();
24703 let semantic = shared
24704 .spawn_session(
24705 WorkspaceId::new("primary").unwrap(),
24706 agent("claude"),
24707 SessionMode::Inline,
24708 terminal_size,
24709 None,
24710 )
24711 .await
24712 .unwrap_err();
24713 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
24714 assert_eq!(semantic.message, "unsupported-capability");
24715
24716 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
24717 assert!(shared
24718 .session_bindings
24719 .lock()
24720 .unwrap_or_else(|poisoned| poisoned.into_inner())
24721 .is_empty());
24722 assert!(shared
24723 .session_records
24724 .lock()
24725 .unwrap_or_else(|poisoned| poisoned.into_inner())
24726 .records
24727 .is_empty());
24728 assert!(shared.handle.snapshot().sessions.is_empty());
24729 }
24730
24731 #[tokio::test]
24732 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
24733 let catalog = active_registry().unwrap();
24734 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24735 let workspace_id = WorkspaceId::new("primary").unwrap();
24736 let workspace = WorkspaceConfig::new(
24737 workspace_id.clone(),
24738 std::env::current_dir().unwrap(),
24739 )
24740 .unwrap();
24741 let shared = NodeShared::new(
24742 handle,
24743 "fixture-token".to_owned(),
24744 NodeId::new("node-raw-spawn-record").unwrap(),
24745 vec![workspace],
24746 vec![agent("claude")],
24747 );
24748 let address = SessionAddress {
24749 workspace_id,
24750 session: SessionKey {
24751 instance_id: AgentInstanceId(71),
24752 generation: SessionGeneration::default(),
24753 },
24754 };
24755
24756 let record_id = shared
24757 .bind_spawn_session(
24758 &address,
24759 agent("claude"),
24760 SessionMode::Pty,
24761 ProviderRuntimePolicy::raw_pty(),
24762 SpawnRecordPolicy::Always,
24763 None,
24764 )
24765 .unwrap()
24766 .unwrap();
24767 shared
24768 .handle
24769 .dispatch(shared.prepare_command(ControlCommand::Register {
24770 instance_id: address.session.instance_id,
24771 agent_id: agent("claude"),
24772 transport: TransportKind::Pty,
24773 }))
24774 .unwrap();
24775 runtime.tick().await;
24776
24777 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24778 session.instance_id == address.session.instance_id
24779 && session.generation == address.session.generation
24780 }));
24781 let record = shared.record(&record_id).unwrap();
24782 assert_eq!(record.display_name, "claude #1");
24783 assert_eq!(record.state, ManagedSessionState::Live);
24784 assert_eq!(record.active_session.as_ref(), Some(&address));
24785 assert!(record.provider_session.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_eq!(binding_record_id.as_ref(), Some(&record_id));
24793
24794 let renamed = shared
24795 .rename_session_record(&record_id, "raw review".to_owned())
24796 .unwrap();
24797 assert_eq!(renamed.record_id, record_id);
24798 assert_eq!(renamed.display_name, "raw review");
24799 shared.reconcile_managed_record(
24800 &record_id,
24801 &address,
24802 &ControlEvent {
24803 sequence: 1,
24804 command_id: None,
24805 instance_id: address.session.instance_id,
24806 generation: address.session.generation,
24807 event: ControlEventKind::Running { process_id: None },
24808 },
24809 false,
24810 false,
24811 );
24812 let running = shared.record(&record_id).unwrap();
24813 assert_eq!(running.state, ManagedSessionState::Live);
24814 assert_eq!(running.active_session.as_ref(), Some(&address));
24815 assert_eq!(running.display_name, "raw review");
24816
24817 shared.reconcile_managed_record(
24818 &record_id,
24819 &address,
24820 &ControlEvent {
24821 sequence: 2,
24822 command_id: None,
24823 instance_id: address.session.instance_id,
24824 generation: address.session.generation,
24825 event: ControlEventKind::Exited {
24826 exit_code: Some(0),
24827 forced: false,
24828 },
24829 },
24830 false,
24831 false,
24832 );
24833 let detached = shared.record(&record_id).unwrap();
24834 assert_eq!(detached.state, ManagedSessionState::Unavailable);
24835 assert!(detached.active_session.is_none());
24836 assert_eq!(detached.display_name, "raw review");
24837 let renamed_detached = shared
24838 .rename_session_record(&record_id, "raw review detached".to_owned())
24839 .unwrap();
24840 assert_eq!(renamed_detached.record_id, record_id);
24841 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
24842 assert!(renamed_detached.active_session.is_none());
24843 assert_eq!(renamed_detached.display_name, "raw review detached");
24844 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
24845 }
24846
24847 #[test]
24848 fn legacy_raw_spawn_binding_remains_untracked() {
24849 let catalog = active_registry().unwrap();
24850 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24851 let workspace_id = WorkspaceId::new("primary").unwrap();
24852 let workspace = WorkspaceConfig::new(
24853 workspace_id.clone(),
24854 std::env::current_dir().unwrap(),
24855 )
24856 .unwrap();
24857 let shared = NodeShared::new(
24858 handle,
24859 "fixture-token".to_owned(),
24860 NodeId::new("node-legacy-raw-spawn").unwrap(),
24861 vec![workspace],
24862 vec![agent("claude")],
24863 );
24864 let address = SessionAddress {
24865 workspace_id,
24866 session: SessionKey {
24867 instance_id: AgentInstanceId(72),
24868 generation: SessionGeneration::default(),
24869 },
24870 };
24871
24872 let record_id = shared
24873 .bind_spawn_session(
24874 &address,
24875 agent("claude"),
24876 SessionMode::Pty,
24877 ProviderRuntimePolicy::raw_pty(),
24878 SpawnRecordPolicy::ProviderIdentityOnly,
24879 None,
24880 )
24881 .unwrap();
24882
24883 assert!(record_id.is_none());
24884 let binding_record_id = shared
24885 .session_bindings
24886 .lock()
24887 .unwrap_or_else(|poisoned| poisoned.into_inner())
24888 .get(&address.session.instance_id)
24889 .and_then(|binding| binding.record_id.clone());
24890 assert!(binding_record_id.is_none());
24891 assert!(shared.snapshot().session_records.is_empty());
24892 }
24893
24894 #[tokio::test]
24895 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
24896 let catalog = active_registry().unwrap();
24897 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24898 let workspace_id = WorkspaceId::new("primary").unwrap();
24899 let workspace = WorkspaceConfig::new(
24900 workspace_id.clone(),
24901 std::env::current_dir().unwrap(),
24902 )
24903 .unwrap();
24904 let mut shared = NodeShared::new(
24905 handle,
24906 "fixture-token".to_owned(),
24907 NodeId::new("node-raw-semantic-gate").unwrap(),
24908 vec![workspace],
24909 vec![agent("claude")],
24910 );
24911 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24912 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
24913 ])
24914 .unwrap();
24915 shared
24916 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24917 .unwrap();
24918 let address = SessionAddress {
24919 workspace_id,
24920 session: SessionKey {
24921 instance_id: AgentInstanceId(72),
24922 generation: SessionGeneration::default(),
24923 },
24924 };
24925 shared.bind_session_with_policy(
24926 &address,
24927 ProviderRuntimePolicy::raw_pty(),
24928 None,
24929 );
24930 shared
24931 .handle
24932 .dispatch(shared.prepare_command(ControlCommand::Register {
24933 instance_id: address.session.instance_id,
24934 agent_id: agent("claude"),
24935 transport: TransportKind::Pty,
24936 }))
24937 .unwrap();
24938 runtime.tick().await;
24939 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
24940 let revision_before = shared.handle.snapshot().revision;
24941 let history_sequence_before = shared.current_sequence();
24942 let terminal_size = gate4agent_types::TerminalSize {
24943 rows: 24,
24944 columns: 80,
24945 };
24946
24947 for request in [
24948 NodeRequest::Prompt {
24949 session: address.clone(),
24950 text: "prompt".to_owned(),
24951 },
24952 NodeRequest::Paste {
24953 session: address.clone(),
24954 text: "paste".to_owned(),
24955 },
24956 NodeRequest::Resume {
24962 session: address.clone(),
24963 terminal_size,
24964 initial_prompt: Some("resume".to_owned()),
24965 },
24966 ] {
24967 let error = process_request_inner(
24968 &shared,
24969 77,
24970 ClientRole::Operator,
24971 request,
24972 )
24973 .await
24974 .unwrap_err();
24975 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
24976 }
24977
24978 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
24979 assert_eq!(shared.handle.snapshot().revision, revision_before);
24980 assert_eq!(shared.current_sequence(), history_sequence_before);
24981 let bindings = shared
24982 .session_bindings
24983 .lock()
24984 .unwrap_or_else(|poisoned| poisoned.into_inner());
24985 let binding = bindings.get(&address.session.instance_id).unwrap();
24986 assert_eq!(binding.generation, address.session.generation);
24987 assert!(binding.pending_resume.is_none());
24988 assert!(binding.record_id.is_none());
24989 }
24990
24991 #[tokio::test]
24992 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
24993 let catalog = active_registry().unwrap();
24994 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24995 let primary = WorkspaceConfig::new(
24996 WorkspaceId::new("primary").unwrap(),
24997 std::env::current_dir().unwrap(),
24998 )
24999 .unwrap();
25000 let shared = NodeShared::new(
25001 handle,
25002 "fixture-token".to_owned(),
25003 NodeId::new("node-1").unwrap(),
25004 vec![primary],
25005 vec![agent("claude")],
25006 );
25007 shared
25008 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25009 .unwrap();
25010 let before = shared.snapshot();
25011 let error = process_request_inner(
25012 &shared,
25013 77,
25014 ClientRole::Operator,
25015 NodeRequest::RegisterWorkspace {
25016 workspace_id: WorkspaceId::new("foreign").unwrap(),
25017 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25018 },
25019 )
25020 .await
25021 .unwrap_err();
25022 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25023 assert_eq!(error.message, "invalid-request");
25024 assert_eq!(shared.snapshot().workspaces, before.workspaces);
25025 }
25026
25027 #[tokio::test]
25028 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25029 let catalog = active_registry().unwrap();
25030 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25031 let primary_id = WorkspaceId::new("primary").unwrap();
25032 let primary = WorkspaceConfig::new(
25033 primary_id.clone(),
25034 std::env::current_dir().unwrap(),
25035 )
25036 .unwrap();
25037 let shared = NodeShared::new(
25038 handle,
25039 "fixture-token".to_owned(),
25040 NodeId::new("node-1").unwrap(),
25041 vec![primary.clone()],
25042 vec![agent("claude")],
25043 );
25044 let secondary_id = WorkspaceId::new("secondary").unwrap();
25045 let secondary = shared
25046 .register_workspace(
25047 secondary_id.clone(),
25048 std::env::temp_dir().to_string_lossy().into_owned(),
25049 )
25050 .await
25051 .unwrap();
25052 assert_eq!(secondary.workspace_id, secondary_id);
25053 assert!(secondary.sessions.is_empty());
25054 assert_eq!(shared.snapshot().workspaces.len(), 2);
25055
25056 let duplicate_id = shared
25057 .register_workspace(
25058 secondary_id.clone(),
25059 primary.canonical_root().to_owned(),
25060 )
25061 .await
25062 .unwrap_err();
25063 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25064 let duplicate_root = shared
25065 .register_workspace(
25066 WorkspaceId::new("duplicate-root").unwrap(),
25067 windows_path_text(&secondary.canonical_root).to_owned(),
25068 )
25069 .await
25070 .unwrap_err();
25071 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25072
25073 let address = SessionAddress {
25074 workspace_id: secondary_id.clone(),
25075 session: SessionKey {
25076 instance_id: AgentInstanceId(41),
25077 generation: SessionGeneration(1),
25078 },
25079 };
25080 shared.bind_session(
25081 address.session.instance_id,
25082 secondary_id.clone(),
25083 address.session.generation,
25084 );
25085 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25086 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25087 shared.remove_binding(&address);
25088 shared.unregister_workspace(&secondary_id).unwrap();
25089 assert_eq!(shared.snapshot().workspaces.len(), 1);
25090
25091 let last = shared.unregister_workspace(&primary_id).unwrap_err();
25092 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25093 let history = shared
25094 .history
25095 .lock()
25096 .unwrap_or_else(|poisoned| poisoned.into_inner());
25097 assert!(matches!(
25098 &history.events[0],
25099 NodeEventEnvelope {
25100 sequence: 1,
25101 event: NodeEvent::WorkspaceAdded { workspace },
25102 } if workspace.workspace_id == secondary_id
25103 ));
25104 assert!(matches!(
25105 &history.events[1],
25106 NodeEventEnvelope {
25107 sequence: 2,
25108 event: NodeEvent::WorkspaceRemoved { workspace_id },
25109 } if workspace_id == &secondary_id
25110 ));
25111 }
25112
25113 #[tokio::test]
25114 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25115 let secondary_root = temporary_workspace_root("provider-session-reference-index");
25116 std::fs::create_dir_all(&secondary_root).unwrap();
25117 let catalog = active_registry().unwrap();
25118 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25119 let primary_id = WorkspaceId::new("primary").unwrap();
25120 let secondary_id = WorkspaceId::new("secondary").unwrap();
25121 let shared = NodeShared::new(
25122 handle,
25123 "fixture-token".to_owned(),
25124 NodeId::new("node-session-reference-index").unwrap(),
25125 vec![
25126 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25127 .unwrap(),
25128 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25129 ],
25130 vec![agent("claude"), agent("codex")],
25131 );
25132 let identity = ProviderSessionIdentity {
25133 key: gate4agent_types::ProviderSessionKey::SessionId,
25134 id: "provider-session-42".to_owned(),
25135 transcript_path: None,
25136 };
25137 let index_request = |workspace_id: WorkspaceId,
25138 provider: AgentId,
25139 identity: ProviderSessionIdentity,
25140 display_name: &str| {
25141 NodeRequest::IndexProviderSession {
25142 workspace_id,
25143 provider,
25144 identity,
25145 display_name: display_name.to_owned(),
25146 }
25147 };
25148 let request = index_request(
25149 primary_id.clone(),
25150 agent("claude"),
25151 identity.clone(),
25152 "release shepherd",
25153 );
25154 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25155 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25156 }));
25157 let observer = process_request_inner(
25158 &shared,
25159 77,
25160 ClientRole::Observer,
25161 request.clone(),
25162 )
25163 .await
25164 .unwrap_err();
25165 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25166 let uncontrolled = process_request_inner(
25167 &shared,
25168 77,
25169 ClientRole::Operator,
25170 request.clone(),
25171 )
25172 .await
25173 .unwrap_err();
25174 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25175 shared
25176 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25177 .unwrap();
25178
25179 let indexed = process_request_inner(
25180 &shared,
25181 77,
25182 ClientRole::Operator,
25183 request.clone(),
25184 )
25185 .await
25186 .unwrap();
25187 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25188 panic!("provider session reference index returned another response");
25189 };
25190 assert_eq!(record.state, ManagedSessionState::Dormant);
25191 assert_eq!(record.mode, SessionMode::Pty);
25192 assert_eq!(record.workspace_id, primary_id);
25193 assert_eq!(record.provider_session.as_ref(), Some(&identity));
25194 assert!(record.active_session.is_none());
25195 assert!(record.environment_profile.is_none());
25196 assert!(record.bundle.is_none());
25197 assert!(record.context.is_none());
25198
25199 let repeated = process_request_inner(
25200 &shared,
25201 77,
25202 ClientRole::Operator,
25203 index_request(
25204 primary_id.clone(),
25205 agent("claude"),
25206 identity.clone(),
25207 "ignored replacement",
25208 ),
25209 )
25210 .await
25211 .unwrap();
25212 assert!(matches!(
25213 repeated,
25214 NodeResponse::ProviderSessionIndexed { record: ref existing }
25215 if existing.record_id == record.record_id
25216 && existing.display_name == "release shepherd"
25217 ));
25218 let conflicting_workspace = process_request_inner(
25219 &shared,
25220 77,
25221 ClientRole::Operator,
25222 index_request(
25223 secondary_id,
25224 agent("claude"),
25225 identity.clone(),
25226 "release shepherd",
25227 ),
25228 )
25229 .await
25230 .unwrap_err();
25231 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25232 let conflicting_provider = process_request_inner(
25233 &shared,
25234 77,
25235 ClientRole::Operator,
25236 index_request(
25237 primary_id.clone(),
25238 agent("codex"),
25239 identity.clone(),
25240 "release shepherd",
25241 ),
25242 )
25243 .await
25244 .unwrap_err();
25245 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25246 let transcript_rejected = process_request_inner(
25247 &shared,
25248 77,
25249 ClientRole::Operator,
25250 index_request(
25251 primary_id,
25252 agent("claude"),
25253 ProviderSessionIdentity {
25254 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25255 ..identity
25256 },
25257 "release shepherd",
25258 ),
25259 )
25260 .await
25261 .unwrap_err();
25262 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25263 let upserts = shared
25264 .history
25265 .lock()
25266 .unwrap_or_else(|poisoned| poisoned.into_inner())
25267 .events
25268 .iter()
25269 .filter(|event| matches!(
25270 &event.event,
25271 NodeEvent::SessionRecordUpserted { record: published }
25272 if published.record_id == record.record_id
25273 ))
25274 .count();
25275 assert_eq!(upserts, 1);
25276 std::fs::remove_dir_all(secondary_root).unwrap();
25277 }
25278
25279 #[tokio::test]
25280 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25281 let fixture_root = temporary_workspace_root("standalone-independent");
25282 let target = fixture_root.join("independent");
25283 std::fs::create_dir_all(&fixture_root).unwrap();
25284 let catalog = active_registry().unwrap();
25285 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25286 let selected = WorkspaceConfig::new(
25287 WorkspaceId::new("primary").unwrap(),
25288 std::env::current_dir().unwrap(),
25289 )
25290 .unwrap();
25291 let selected_root = selected.canonical_root().to_owned();
25292 let shared = NodeShared::new(
25293 handle,
25294 "fixture-token".to_owned(),
25295 NodeId::new("node-standalone").unwrap(),
25296 vec![selected],
25297 vec![agent("claude")],
25298 );
25299 let workspace_id = WorkspaceId::new("independent").unwrap();
25300 let request = NodeRequest::CreateStandaloneWorkspace {
25301 workspace_id: workspace_id.clone(),
25302 root: opaque_windows_path(target.to_string_lossy().into_owned()),
25303 initial_branch: Some("main".to_owned()),
25304 };
25305 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25306 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25307 }));
25308 assert!(!request_uses_unnegotiated_capability(
25309 &request,
25310 &baseline_capabilities().unwrap(),
25311 ));
25312 assert!(request_uses_unnegotiated_capability(&request, &[]));
25313
25314 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25315 .await
25316 .unwrap_err();
25317 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25318 assert!(!target.exists());
25319 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25320 .await
25321 .unwrap_err();
25322 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25323 assert!(!target.exists());
25324 shared
25325 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25326 .unwrap();
25327
25328 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25329 .await
25330 .unwrap();
25331 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25332 panic!("standalone workspace creation returned another response");
25333 };
25334 assert_eq!(workspace.workspace_id, workspace_id);
25335 assert!(target.join(".git").is_dir());
25336 let branch = run_git_bounded(
25337 target.to_str().unwrap(),
25338 &["branch", "--show-current"],
25339 GIT_OUTPUT_MAX_BYTES,
25340 )
25341 .await
25342 .unwrap();
25343 assert!(branch.success);
25344 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25345 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25346 assert_eq!(worktrees.len(), 1);
25347 let listed_root = WorkspaceConfig::new(
25348 WorkspaceId::new("listed-independent").unwrap(),
25349 &worktrees[0].path,
25350 )
25351 .unwrap();
25352 assert!(platform::roots_equal(
25353 listed_root.canonical_root(),
25354 windows_path_text(&workspace.canonical_root),
25355 ));
25356 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25357 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25358 snapshot.workspace_id == workspace_id
25359 && snapshot.canonical_root == workspace.canonical_root
25360 }));
25361 assert!(shared
25362 .history
25363 .lock()
25364 .unwrap_or_else(|poisoned| poisoned.into_inner())
25365 .events
25366 .iter()
25367 .any(|event| matches!(
25368 &event.event,
25369 NodeEvent::WorkspaceAdded { workspace }
25370 if workspace.workspace_id == workspace_id
25371 )));
25372 std::fs::remove_dir_all(fixture_root).unwrap();
25373 }
25374
25375 #[tokio::test]
25376 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25377 let fixture_root = temporary_workspace_root("standalone-compensation");
25378 let created_target = fixture_root.join("created-target");
25379 let existing_target = fixture_root.join("existing-target");
25380 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25381 let invalid_state_path = invalid_state_parent.join("state.json");
25382 std::fs::create_dir_all(&existing_target).unwrap();
25383 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25384 let catalog = active_registry().unwrap();
25385 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25386 let primary = WorkspaceConfig::new(
25387 WorkspaceId::new("primary").unwrap(),
25388 std::env::current_dir().unwrap(),
25389 )
25390 .unwrap();
25391 let mut shared = NodeShared::new(
25392 handle,
25393 "fixture-token".to_owned(),
25394 NodeId::new("node-standalone-compensation").unwrap(),
25395 vec![primary],
25396 vec![agent("claude")],
25397 );
25398 shared.state_path = Some(invalid_state_path);
25399
25400 let created_recovery = shared
25401 .create_standalone_workspace(
25402 WorkspaceId::new("compensated").unwrap(),
25403 created_target.to_string_lossy().into_owned(),
25404 Some("main".to_owned()),
25405 )
25406 .await
25407 .unwrap_err();
25408 assert_eq!(
25409 created_recovery.code,
25410 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25411 );
25412 assert!(created_target.join(".git").is_dir());
25413 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25414 workspace.workspace_id.as_str() != "compensated"
25415 }));
25416
25417 let existing_recovery = shared
25418 .create_standalone_workspace(
25419 WorkspaceId::new("restored").unwrap(),
25420 existing_target.to_string_lossy().into_owned(),
25421 None,
25422 )
25423 .await
25424 .unwrap_err();
25425 assert_eq!(
25426 existing_recovery.code,
25427 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25428 );
25429 assert!(existing_target.is_dir());
25430 assert!(existing_target.join(".git").is_dir());
25431 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25432 workspace.workspace_id.as_str() != "restored"
25433 }));
25434
25435 let registered_empty = fixture_root.join("registered-empty");
25436 std::fs::create_dir(®istered_empty).unwrap();
25437 let catalog = active_registry().unwrap();
25438 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25439 let registered = WorkspaceConfig::new(
25440 WorkspaceId::new("registered-empty").unwrap(),
25441 ®istered_empty,
25442 )
25443 .unwrap();
25444 let duplicate_shared = NodeShared::new(
25445 handle,
25446 "fixture-token".to_owned(),
25447 NodeId::new("node-standalone-duplicate-root").unwrap(),
25448 vec![registered],
25449 vec![agent("claude")],
25450 );
25451 let duplicate = duplicate_shared
25452 .create_standalone_workspace(
25453 WorkspaceId::new("duplicate-root").unwrap(),
25454 registered_empty.to_string_lossy().into_owned(),
25455 None,
25456 )
25457 .await
25458 .unwrap_err();
25459 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
25460 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
25461 std::fs::remove_dir_all(fixture_root).unwrap();
25462 }
25463
25464 #[test]
25465 fn managed_record_id_collision_preserves_the_existing_record() {
25466 let catalog = active_registry().unwrap();
25467 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25468 let workspace = WorkspaceConfig::new(
25469 WorkspaceId::new("primary").unwrap(),
25470 std::env::current_dir().unwrap(),
25471 )
25472 .unwrap();
25473 let shared = NodeShared::new(
25474 handle,
25475 "fixture-token".to_owned(),
25476 NodeId::new("node-1").unwrap(),
25477 vec![workspace.clone()],
25478 vec![agent("claude")],
25479 );
25480 let record_id = SessionRecordId::new("sr-collision").unwrap();
25481 let original = ManagedSessionRecord {
25482 record_id: record_id.clone(),
25483 display_name: "original".to_owned(),
25484 provider: agent("claude"),
25485 mode: SessionMode::Pty,
25486 state: ManagedSessionState::Dormant,
25487 workspace_id: workspace.workspace_id,
25488 canonical_root: opaque_windows_path(workspace.canonical_root),
25489 provider_session: None,
25490 active_session: None,
25491 environment_profile: None,
25492 bundle: None,
25493 context_id: None,
25494 context: None,
25495 exported_context: None,
25496 task_binding: None,
25497 created_at_unix_ms: 1,
25498 updated_at_unix_ms: 1,
25499 last_error: None,
25500 };
25501 shared.insert_record(original.clone()).unwrap();
25502 let mut collision = original.clone();
25503 collision.display_name = "replacement".to_owned();
25504 let error = shared.insert_record(collision).unwrap_err();
25505 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
25506 assert_eq!(shared.record(&record_id).unwrap(), original);
25507 }
25508
25509 #[test]
25510 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
25511 let catalog = active_registry().unwrap();
25512 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25513 let workspace = WorkspaceConfig::new(
25514 WorkspaceId::new("primary").unwrap(),
25515 std::env::current_dir().unwrap(),
25516 )
25517 .unwrap();
25518 let shared = NodeShared::new(
25519 handle,
25520 "fixture-token".to_owned(),
25521 NodeId::new("node-1").unwrap(),
25522 vec![workspace.clone()],
25523 vec![agent("claude")],
25524 );
25525 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
25526 shared
25527 .insert_record(ManagedSessionRecord {
25528 record_id: record_id.clone(),
25529 display_name: "runtime error".to_owned(),
25530 provider: agent("claude"),
25531 mode: SessionMode::Pty,
25532 state: ManagedSessionState::IdentityPending,
25533 workspace_id: workspace.workspace_id,
25534 canonical_root: opaque_windows_path(workspace.canonical_root),
25535 provider_session: None,
25536 active_session: None,
25537 environment_profile: None,
25538 bundle: None,
25539 context_id: None,
25540 context: None,
25541 exported_context: None,
25542 task_binding: None,
25543 created_at_unix_ms: 1,
25544 updated_at_unix_ms: 1,
25545 last_error: None,
25546 })
25547 .unwrap();
25548
25549 shared
25550 .mark_record_error(
25551 &record_id,
25552 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
25553 )
25554 .unwrap();
25555 let record = shared.record(&record_id).unwrap();
25556 assert_eq!(record.state, ManagedSessionState::Unavailable);
25557 assert_eq!(
25558 record.last_error.as_deref(),
25559 Some("provider-runtime-failed"),
25560 );
25561 let snapshot = shared.snapshot();
25562 assert_eq!(snapshot.session_records, vec![record]);
25563 }
25564
25565 #[test]
25574 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
25575 let catalog = active_registry().unwrap();
25576 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25577 let workspace = WorkspaceConfig::new(
25578 WorkspaceId::new("primary").unwrap(),
25579 std::env::current_dir().unwrap(),
25580 )
25581 .unwrap();
25582 let shared = NodeShared::new(
25583 handle,
25584 "fixture-token".to_owned(),
25585 NodeId::new("node-1").unwrap(),
25586 vec![workspace],
25587 vec![agent("claude")],
25588 );
25589
25590 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25591
25592 shared.record_rejected_command(
25593 CommandId(1),
25594 "agent 'claude' does not support transport Acp".to_owned(),
25595 true,
25596 );
25597 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
25599
25600 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
25601 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
25602 assert!(reason.unsupported_transport);
25603
25604 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25606
25607 shared.record_rejected_command(
25612 CommandId(3),
25613 "backend busy".to_owned(),
25614 false,
25615 );
25616 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
25617 assert!(!unclassified.unsupported_transport);
25618 }
25619
25620 #[test]
25621 fn provider_identity_update_is_ignored_without_exact_session_policy() {
25622 let catalog = active_registry().unwrap();
25623 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25624 let workspace = WorkspaceConfig::new(
25625 WorkspaceId::new("primary").unwrap(),
25626 std::env::current_dir().unwrap(),
25627 )
25628 .unwrap();
25629 let shared = NodeShared::new(
25630 handle,
25631 "fixture-token".to_owned(),
25632 NodeId::new("node-identity-policy").unwrap(),
25633 vec![workspace.clone()],
25634 vec![agent("claude")],
25635 );
25636 let address = SessionAddress {
25637 workspace_id: workspace.workspace_id.clone(),
25638 session: SessionKey {
25639 instance_id: AgentInstanceId(90),
25640 generation: SessionGeneration(1),
25641 },
25642 };
25643 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
25644 shared
25645 .insert_record(ManagedSessionRecord {
25646 record_id: record_id.clone(),
25647 display_name: "policy denied".to_owned(),
25648 provider: agent("claude"),
25649 mode: SessionMode::Pty,
25650 state: ManagedSessionState::IdentityPending,
25651 workspace_id: workspace.workspace_id,
25652 canonical_root: opaque_windows_path(workspace.canonical_root),
25653 provider_session: None,
25654 active_session: Some(address.clone()),
25655 environment_profile: None,
25656 bundle: None,
25657 context_id: None,
25658 context: None,
25659 exported_context: None,
25660 task_binding: None,
25661 created_at_unix_ms: 1,
25662 updated_at_unix_ms: 1,
25663 last_error: None,
25664 })
25665 .unwrap();
25666 let event = ControlEvent {
25667 sequence: 1,
25668 command_id: None,
25669 instance_id: address.session.instance_id,
25670 generation: address.session.generation,
25671 event: ControlEventKind::ProviderEvent {
25672 sequence: 1,
25673 source: gate4agent_types::ProviderSource {
25674 family: AdapterFamily::PtySemantic,
25675 binding: AdapterBinding::new(
25676 gate4agent_types::AdapterId::new("claude").unwrap(),
25677 "fixture/v1",
25678 gate4agent_types::AdapterVerification::SyntheticFixture,
25679 )
25680 .unwrap(),
25681 },
25682 source_sequence: 1,
25683 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25684 identity: gate4agent_types::ProviderSessionIdentity {
25685 key: gate4agent_types::ProviderSessionKey::SessionId,
25686 id: "provider-session".to_owned(),
25687 transcript_path: None,
25688 },
25689 },
25690 },
25691 };
25692
25693 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
25694
25695 let record = shared.record(&record_id).unwrap();
25696 assert!(record.provider_session.is_none());
25697 assert_eq!(record.state, ManagedSessionState::IdentityPending);
25698 assert_eq!(record.active_session.as_ref(), Some(&address));
25699 }
25700
25701 #[test]
25702 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
25703 let secondary_root = temporary_workspace_root("identity-scope");
25704 std::fs::create_dir_all(&secondary_root).unwrap();
25705 let catalog = active_registry().unwrap();
25706 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25707 let primary = WorkspaceConfig::new(
25708 WorkspaceId::new("primary").unwrap(),
25709 std::env::current_dir().unwrap(),
25710 )
25711 .unwrap();
25712 let secondary = WorkspaceConfig::new(
25713 WorkspaceId::new("secondary").unwrap(),
25714 &secondary_root,
25715 )
25716 .unwrap();
25717 let shared = NodeShared::new(
25718 handle,
25719 "fixture-token".to_owned(),
25720 NodeId::new("node-1").unwrap(),
25721 vec![primary.clone(), secondary.clone()],
25722 vec![agent("claude")],
25723 );
25724 let identity = gate4agent_types::ProviderSessionIdentity {
25725 key: gate4agent_types::ProviderSessionKey::SessionId,
25726 id: "provider-session".to_owned(),
25727 transcript_path: None,
25728 };
25729 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
25730 shared
25731 .insert_record(ManagedSessionRecord {
25732 record_id: dormant_id.clone(),
25733 display_name: "dormant".to_owned(),
25734 provider: agent("claude"),
25735 mode: SessionMode::Pty,
25736 state: ManagedSessionState::Dormant,
25737 workspace_id: primary.workspace_id,
25738 canonical_root: opaque_windows_path(primary.canonical_root),
25739 provider_session: Some(identity.clone()),
25740 active_session: None,
25741 environment_profile: None,
25742 bundle: None,
25743 context_id: None,
25744 context: None,
25745 exported_context: None,
25746 task_binding: None,
25747 created_at_unix_ms: 1,
25748 updated_at_unix_ms: 1,
25749 last_error: None,
25750 })
25751 .unwrap();
25752 let address = SessionAddress {
25753 workspace_id: secondary.workspace_id.clone(),
25754 session: SessionKey {
25755 instance_id: AgentInstanceId(91),
25756 generation: SessionGeneration(1),
25757 },
25758 };
25759 let current_id = SessionRecordId::new("sr-current").unwrap();
25760 shared
25761 .insert_record(ManagedSessionRecord {
25762 record_id: current_id.clone(),
25763 display_name: "current".to_owned(),
25764 provider: agent("claude"),
25765 mode: SessionMode::Pty,
25766 state: ManagedSessionState::IdentityPending,
25767 workspace_id: secondary.workspace_id,
25768 canonical_root: opaque_windows_path(secondary.canonical_root),
25769 provider_session: None,
25770 active_session: Some(address.clone()),
25771 environment_profile: None,
25772 bundle: None,
25773 context_id: None,
25774 context: None,
25775 exported_context: None,
25776 task_binding: None,
25777 created_at_unix_ms: 2,
25778 updated_at_unix_ms: 2,
25779 last_error: None,
25780 })
25781 .unwrap();
25782 shared.bind_managed_session(
25783 &address,
25784 current_id.clone(),
25785 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25786 None,
25787 );
25788 let event = ControlEvent {
25789 sequence: 1,
25790 command_id: None,
25791 instance_id: address.session.instance_id,
25792 generation: address.session.generation,
25793 event: ControlEventKind::ProviderEvent {
25794 sequence: 1,
25795 source: gate4agent_types::ProviderSource {
25796 family: gate4agent_types::AdapterFamily::PtySemantic,
25797 binding: gate4agent_types::AdapterBinding::new(
25798 gate4agent_types::AdapterId::new("claude").unwrap(),
25799 "fixture/v1",
25800 gate4agent_types::AdapterVerification::SyntheticFixture,
25801 )
25802 .unwrap(),
25803 },
25804 source_sequence: 1,
25805 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25806 identity,
25807 },
25808 },
25809 };
25810 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
25811
25812 let binding = shared
25813 .session_bindings
25814 .lock()
25815 .unwrap_or_else(|poisoned| poisoned.into_inner())
25816 .get(&address.session.instance_id)
25817 .cloned()
25818 .unwrap();
25819 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
25820 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
25821 let current = shared.record(¤t_id).unwrap();
25822 assert_eq!(current.state, ManagedSessionState::Unavailable);
25823 assert_eq!(current.active_session.as_ref(), Some(&address));
25824 assert_eq!(
25825 current.last_error.as_deref(),
25826 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
25827 );
25828 std::fs::remove_dir_all(secondary_root).unwrap();
25829 }
25830
25831 #[tokio::test]
25832 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
25833 let root = temporary_workspace_root("semantic-provider-session");
25834 std::fs::create_dir_all(&root).unwrap();
25835 let state_path = root.join("state-v1.json");
25836 let catalog = active_registry().unwrap();
25837 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25838 let workspace = WorkspaceConfig::new(
25839 WorkspaceId::new("primary").unwrap(),
25840 &root,
25841 )
25842 .unwrap();
25843 let node_id = NodeId::new("node-semantic-identity").unwrap();
25844 let shared = NodeShared::new_with_incarnation(
25845 handle,
25846 "fixture-token".to_owned(),
25847 node_id.clone(),
25848 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
25849 vec![workspace.clone()],
25850 vec![agent("claude")],
25851 ProviderRuntimeStatuses::default(),
25852 None,
25853 Vec::new(),
25854 Vec::new(),
25855 SpawnProfileRegistry::default(),
25856 None,
25857 None,
25858 None,
25859 Some(state_path.clone()),
25860 Vec::new(),
25861 Vec::new(),
25862 Vec::new(),
25863 Vec::new(),
25864 None,
25865 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
25866 );
25867 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
25868 key: gate4agent_types::ProviderSessionKey::SessionId,
25869 id: "provider-session".to_owned(),
25870 transcript_path: None,
25871 };
25872 let durable_id = SessionRecordId::new("sr-durable").unwrap();
25873 shared
25874 .insert_record(ManagedSessionRecord {
25875 record_id: durable_id.clone(),
25876 display_name: "durable".to_owned(),
25877 provider: agent("claude"),
25878 mode: SessionMode::Pty,
25879 state: ManagedSessionState::Dormant,
25880 workspace_id: workspace.workspace_id.clone(),
25881 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25882 provider_session: Some(persisted_identity.clone()),
25883 active_session: None,
25884 environment_profile: None,
25885 bundle: None,
25886 context_id: None,
25887 context: None,
25888 exported_context: None,
25889 task_binding: None,
25890 created_at_unix_ms: 1,
25891 updated_at_unix_ms: 1,
25892 last_error: None,
25893 })
25894 .unwrap();
25895 let address = SessionAddress {
25896 workspace_id: workspace.workspace_id.clone(),
25897 session: SessionKey {
25898 instance_id: AgentInstanceId(92),
25899 generation: SessionGeneration(2),
25900 },
25901 };
25902 let pending_id = SessionRecordId::new("sr-pending").unwrap();
25903 shared
25904 .insert_record(ManagedSessionRecord {
25905 record_id: pending_id.clone(),
25906 display_name: "pending".to_owned(),
25907 provider: agent("claude"),
25908 mode: SessionMode::Pty,
25909 state: ManagedSessionState::IdentityPending,
25910 workspace_id: workspace.workspace_id.clone(),
25911 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25912 provider_session: None,
25913 active_session: Some(address.clone()),
25914 environment_profile: None,
25915 bundle: None,
25916 context_id: None,
25917 context: None,
25918 exported_context: None,
25919 task_binding: None,
25920 created_at_unix_ms: 2,
25921 updated_at_unix_ms: 2,
25922 last_error: None,
25923 })
25924 .unwrap();
25925 shared.bind_managed_session(
25926 &address,
25927 pending_id.clone(),
25928 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25929 None,
25930 );
25931 let observed_identity = gate4agent_types::ProviderSessionIdentity {
25932 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
25933 ..persisted_identity
25934 };
25935 let event = ControlEvent {
25936 sequence: 1,
25937 command_id: None,
25938 instance_id: address.session.instance_id,
25939 generation: address.session.generation,
25940 event: ControlEventKind::ProviderEvent {
25941 sequence: 1,
25942 source: gate4agent_types::ProviderSource {
25943 family: gate4agent_types::AdapterFamily::PtySemantic,
25944 binding: gate4agent_types::AdapterBinding::new(
25945 gate4agent_types::AdapterId::new("claude").unwrap(),
25946 "fixture/v1",
25947 gate4agent_types::AdapterVerification::SyntheticFixture,
25948 )
25949 .unwrap(),
25950 },
25951 source_sequence: 1,
25952 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25953 identity: observed_identity.clone(),
25954 },
25955 },
25956 };
25957 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
25958
25959 let snapshot = shared.snapshot();
25960 assert_eq!(snapshot.session_records.len(), 1);
25961 let live = &snapshot.session_records[0];
25962 assert_eq!(live.record_id, durable_id);
25963 assert_eq!(live.state, ManagedSessionState::Live);
25964 assert_eq!(live.active_session.as_ref(), Some(&address));
25965 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
25966 let binding = shared
25967 .session_bindings
25968 .lock()
25969 .unwrap_or_else(|poisoned| poisoned.into_inner())
25970 .get(&address.session.instance_id)
25971 .cloned()
25972 .unwrap();
25973 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
25974
25975 let mut duplicate = live.clone();
25976 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
25977 duplicate.display_name = "duplicate".to_owned();
25978 duplicate.state = ManagedSessionState::Dormant;
25979 duplicate.active_session = None;
25980 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
25981 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
25982 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
25983 assert_eq!(shared.snapshot().session_records.len(), 1);
25984
25985 let terminal_size = gate4agent_types::TerminalSize {
25986 rows: 24,
25987 columns: 80,
25988 };
25989 let (first_busy, second_busy) = tokio::join!(
25990 shared.resume_session_record(&durable_id, terminal_size, None),
25991 shared.resume_session_record(&durable_id, terminal_size, None),
25992 );
25993 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
25994 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
25995 assert_eq!(busy.message, "session-record-busy");
25996 }
25997
25998 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
25999 assert_eq!(loaded.records.len(), 1);
26000 assert_eq!(loaded.records[0].record_id, durable_id);
26001 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26002 assert_eq!(
26003 loaded.records[0]
26004 .provider_session
26005 .as_ref()
26006 .unwrap()
26007 .transcript_path,
26008 None,
26009 );
26010 let catalog = active_registry().unwrap();
26011 let (reloaded_handle, _reloaded_runtime) =
26012 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26013 let reloaded = NodeShared::new_with_incarnation(
26014 reloaded_handle,
26015 "fixture-token".to_owned(),
26016 node_id,
26017 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26018 vec![workspace],
26019 vec![agent("claude")],
26020 ProviderRuntimeStatuses::default(),
26021 None,
26022 Vec::new(),
26023 Vec::new(),
26024 SpawnProfileRegistry::default(),
26025 None,
26026 None,
26027 None,
26028 None,
26029 loaded.records,
26030 loaded.managed_worktrees,
26031 loaded.managed_worktree_tombstones,
26032 loaded.materializations,
26033 loaded.warning,
26034 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26035 );
26036 assert_eq!(reloaded.snapshot().session_records.len(), 1);
26037 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26038 std::fs::remove_dir_all(root).unwrap();
26039 }
26040
26041 #[tokio::test]
26042 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26043 let secondary_root = temporary_workspace_root("workspace-restore");
26044 let other_root = temporary_workspace_root("workspace-conflict");
26045 std::fs::create_dir_all(&secondary_root).unwrap();
26046 std::fs::create_dir_all(&other_root).unwrap();
26047 let catalog = active_registry().unwrap();
26048 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26049 let primary = WorkspaceConfig::new(
26050 WorkspaceId::new("primary").unwrap(),
26051 std::env::current_dir().unwrap(),
26052 )
26053 .unwrap();
26054 let secondary = WorkspaceConfig::new(
26055 WorkspaceId::new("secondary").unwrap(),
26056 &secondary_root,
26057 )
26058 .unwrap();
26059 let shared = NodeShared::new(
26060 handle,
26061 "fixture-token".to_owned(),
26062 NodeId::new("node-1").unwrap(),
26063 vec![primary, secondary.clone()],
26064 vec![agent("claude")],
26065 );
26066 let record_id = SessionRecordId::new("sr-workspace").unwrap();
26067 shared
26068 .insert_record(ManagedSessionRecord {
26069 record_id: record_id.clone(),
26070 display_name: "workspace record".to_owned(),
26071 provider: agent("claude"),
26072 mode: SessionMode::Pty,
26073 state: ManagedSessionState::Dormant,
26074 workspace_id: secondary.workspace_id.clone(),
26075 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26076 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26077 key: gate4agent_types::ProviderSessionKey::SessionId,
26078 id: "provider-session".to_owned(),
26079 transcript_path: None,
26080 }),
26081 active_session: None,
26082 environment_profile: None,
26083 bundle: None,
26084 context_id: None,
26085 context: None,
26086 exported_context: None,
26087 task_binding: None,
26088 created_at_unix_ms: 1,
26089 updated_at_unix_ms: 1,
26090 last_error: None,
26091 })
26092 .unwrap();
26093 shared.unregister_workspace(&secondary.workspace_id).unwrap();
26094 let unavailable = shared.record(&record_id).unwrap();
26095 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26096 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26097
26098 let conflict = shared
26099 .register_workspace(
26100 secondary.workspace_id.clone(),
26101 other_root.to_string_lossy().into_owned(),
26102 )
26103 .await
26104 .unwrap_err();
26105 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26106 shared
26107 .register_workspace(
26108 secondary.workspace_id,
26109 secondary_root.to_string_lossy().into_owned(),
26110 )
26111 .await
26112 .unwrap();
26113 let restored = shared.record(&record_id).unwrap();
26114 assert_eq!(restored.state, ManagedSessionState::Dormant);
26115 assert!(restored.last_error.is_none());
26116 std::fs::remove_dir_all(secondary_root).unwrap();
26117 std::fs::remove_dir_all(other_root).unwrap();
26118 }
26119
26120 #[test]
26121 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26122 let error = validate_workspace_request_root(
26123 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26124 )
26125 .unwrap_err();
26126 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26127 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26128 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26129 }
26130
26131 #[test]
26132 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26133 let catalog = active_registry().unwrap();
26134 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26135 let workspace = WorkspaceConfig::new(
26136 WorkspaceId::new("primary").unwrap(),
26137 std::env::current_dir().unwrap(),
26138 )
26139 .unwrap();
26140 let shared = NodeShared::new(
26141 handle,
26142 "fixture-token".to_owned(),
26143 NodeId::new("node-1").unwrap(),
26144 vec![workspace],
26145 vec![agent("claude")],
26146 );
26147 let publishers = 4_u64;
26148 let events_per_publisher = 256_u64;
26149 let start = std::sync::Barrier::new(publishers as usize + 1);
26150 let finished = AtomicU64::new(0);
26151
26152 std::thread::scope(|scope| {
26153 for _ in 0..publishers {
26154 scope.spawn(|| {
26155 start.wait();
26156 for _ in 0..events_per_publisher {
26157 shared.publish(NodeEvent::ControllerChanged { controller: None });
26158 }
26159 finished.fetch_add(1, Ordering::Release);
26160 });
26161 }
26162 start.wait();
26163 while finished.load(Ordering::Acquire) != publishers {
26164 assert_resync_history_is_linearized(shared.resync(0));
26165 std::thread::yield_now();
26166 }
26167 });
26168 let response = shared.resync(0);
26169 assert_resync_history_is_linearized(response.clone());
26170 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26171 unreachable!("resync helper returned another response");
26172 };
26173 assert_eq!(event_sequence, publishers * events_per_publisher);
26174 assert_eq!(events.len() as u64, event_sequence);
26175 }
26176
26177 fn assert_resync_history_is_linearized(response: NodeResponse) {
26178 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26179 unreachable!("resync helper returned another response");
26180 };
26181 assert!(events.windows(2).all(|pair| {
26182 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26183 }));
26184 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26185 }
26186
26187 #[test]
26188 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26189 assert_eq!(
26190 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26191 r"C:\repo\worktree",
26192 );
26193 assert_eq!(
26194 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26195 r"\\server\share\repo",
26196 );
26197 assert!(validate_workspace_root(
26198 &WorkspaceId::new("network").unwrap(),
26199 r"\\server\share\repo",
26200 )
26201 .is_err());
26202 let workspace = WorkspaceConfig::new(
26203 WorkspaceId::new("primary").unwrap(),
26204 std::env::current_dir().unwrap(),
26205 )
26206 .unwrap();
26207 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26208 }
26209
26210 #[test]
26211 fn resume_rebinds_only_on_the_expected_authorized_generation() {
26212 let catalog = active_registry().unwrap();
26213 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26214 let workspace_id = WorkspaceId::new("primary").unwrap();
26215 let workspace = WorkspaceConfig::new(
26216 workspace_id.clone(),
26217 std::env::current_dir().unwrap(),
26218 )
26219 .unwrap();
26220 let shared = NodeShared::new(
26221 handle,
26222 "fixture-token".to_owned(),
26223 NodeId::new("node-1").unwrap(),
26224 vec![workspace],
26225 vec![agent("claude")],
26226 );
26227 let instance_id = AgentInstanceId(17);
26228 let original = SessionAddress {
26229 workspace_id,
26230 session: SessionKey {
26231 instance_id,
26232 generation: SessionGeneration(3),
26233 },
26234 };
26235 shared.bind_session(
26236 instance_id,
26237 original.workspace_id.clone(),
26238 original.session.generation,
26239 );
26240 shared
26241 .arm_resume(
26242 &original,
26243 CommandId(41),
26244 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26245 )
26246 .unwrap();
26247
26248 shared.publish_control(ControlEvent {
26249 sequence: 1,
26250 command_id: None,
26251 instance_id,
26252 generation: SessionGeneration(4),
26253 event: ControlEventKind::Running { process_id: None },
26254 });
26255 assert_eq!(
26256 shared.address_for(instance_id, SessionGeneration(3)),
26257 Some(original.clone()),
26258 );
26259 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26260
26261 shared.publish_control(ControlEvent {
26262 sequence: 2,
26263 command_id: Some(CommandId(42)),
26264 instance_id,
26265 generation: SessionGeneration(3),
26266 event: ControlEventKind::CommandRejected {
26267 message: "unrelated command".to_owned(),
26268 },
26269 });
26270
26271 shared.publish_control(ControlEvent {
26272 sequence: 3,
26273 command_id: None,
26274 instance_id,
26275 generation: SessionGeneration(4),
26276 event: ControlEventKind::ResumeAuthorized {
26277 session: gate4agent_types::ResumeSessionSummary {
26278 key: gate4agent_types::ProviderSessionKey::SessionId,
26279 id: "fixture-session".to_owned(),
26280 },
26281 },
26282 });
26283 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26284 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26285 assert_eq!(rebound.workspace_id, original.workspace_id);
26286 let history = shared
26287 .history
26288 .lock()
26289 .unwrap_or_else(|poisoned| poisoned.into_inner());
26290 assert!(history.events.iter().any(|envelope| matches!(
26291 &envelope.event,
26292 NodeEvent::Control { address, event }
26293 if address == &rebound
26294 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26295 )));
26296 }
26297
26298 #[test]
26299 fn all_node_pty_prompts_use_bracketed_paste_framing() {
26300 for agent in ["claude", "codex", "kimi"] {
26301 assert_eq!(
26302 prompt_framing(&AgentId::new(agent).unwrap()),
26303 PromptFraming::BracketedPaste
26304 );
26305 }
26306 }
26307
26308 fn generous_walk_deadline() -> Instant {
26309 Instant::now() + Duration::from_secs(30)
26310 }
26311
26312 #[test]
26313 fn workspace_tree_is_relative_and_skips_heavy_directories() {
26314 let root = temporary_workspace_root("tree");
26315 std::fs::create_dir_all(root.join("src")).unwrap();
26316 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26317 std::fs::create_dir_all(root.join("target/debug")).unwrap();
26318 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26319 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26320 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26321 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26327 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26328 std::fs::create_dir_all(root.join("target-notes")).unwrap();
26332 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26333
26334 let (entries, truncated, _budget) =
26335 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26336 assert!(!truncated);
26337 assert!(entries.iter().any(|entry| {
26338 entry.relative_path.as_utf8() == Some("src")
26339 && entry.kind == WorkspaceEntryKind::Directory
26340 }));
26341 assert!(entries.iter().any(|entry| {
26342 entry.relative_path.as_utf8() == Some("src/lib.rs")
26343 && entry.kind == WorkspaceEntryKind::File
26344 }));
26345 assert!(entries.iter().any(|entry| {
26346 entry.relative_path.as_utf8() == Some("target-notes")
26347 && entry.kind == WorkspaceEntryKind::Directory
26348 }));
26349 assert!(entries.iter().any(|entry| {
26350 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26351 && entry.kind == WorkspaceEntryKind::File
26352 }));
26353 assert!(entries.iter().all(|entry| {
26354 let path = entry.relative_path.as_utf8().unwrap();
26355 !path.starts_with(".git")
26356 && !(path == "target" || path.starts_with("target/"))
26357 && !(path == "target-a2" || path.starts_with("target-a2/"))
26358 && !path.starts_with("node_modules")
26359 }));
26360
26361 std::fs::remove_dir_all(&root).unwrap();
26362 }
26363
26364 #[test]
26365 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26366 let root = temporary_workspace_root("tree-entry-cap");
26367 std::fs::create_dir_all(&root).unwrap();
26368 for index in 0..20 {
26369 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26370 }
26371
26372 let (entries, truncated, budget) =
26373 collect_workspace_entries(&root, generous_walk_deadline(), 5);
26374 assert!(truncated);
26375 assert!(budget.entry_cap_exceeded);
26376 assert!(!budget.time_budget_exceeded);
26377 assert!(entries.len() <= 5);
26378
26379 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26380 .expect("an entry-cap-truncated walk must report a truncation marker");
26381 assert!(inspection_truncation.walk_entry_cap_exceeded);
26382 assert!(!inspection_truncation.walk_time_budget_exceeded);
26383 assert!(!inspection_truncation.git_time_budget_exceeded);
26384 assert!(inspection_truncation.entries_visited >= 5);
26385
26386 std::fs::remove_dir_all(&root).unwrap();
26387 }
26388
26389 #[test]
26390 fn workspace_walk_within_budget_reports_no_truncation() {
26391 let root = temporary_workspace_root("tree-no-truncation");
26392 std::fs::create_dir_all(&root).unwrap();
26393 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26394
26395 let (_entries, truncated, budget) =
26396 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26397 assert!(!truncated);
26398 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26399
26400 std::fs::remove_dir_all(&root).unwrap();
26401 }
26402
26403 #[test]
26404 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26405 let root = temporary_workspace_root("tree-prefix-siblings");
26406 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26407 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26408 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26409 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26410
26411 let (entries, truncated, _budget) =
26412 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26413 assert!(!truncated);
26414 let paths = entries
26415 .iter()
26416 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26417 .collect::<Vec<_>>();
26418 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26419 assert_eq!(
26420 paths,
26421 vec![
26422 "gate4agent-c2".to_owned(),
26423 "gate4agent-c2-client".to_owned(),
26424 "gate4agent-c2-client/lib.rs".to_owned(),
26425 "gate4agent-c2/src".to_owned(),
26426 "gate4agent-c2/src/lib.rs".to_owned(),
26427 ],
26428 );
26429
26430 std::fs::remove_dir_all(&root).unwrap();
26431 }
26432
26433 #[test]
26434 fn git_parsers_cap_status_and_commit_results() {
26435 let mut status_output = Vec::new();
26436 for index in 0..=GIT_STATUS_MAX_ENTRIES {
26437 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26438 }
26439 let mut snapshot = git_snapshot_for_parser();
26440 parse_git_status(&status_output, &mut snapshot);
26441 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26442 assert!(snapshot.truncated);
26443 assert_eq!(snapshot.status[0].index_status, " ");
26444 assert_eq!(snapshot.status[0].worktree_status, "M");
26445
26446 snapshot.truncated = false;
26447 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26448 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
26449 .collect::<String>();
26450 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
26451 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
26452 assert!(snapshot.truncated);
26453 }
26454
26455 #[test]
26456 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
26457 let mut snapshot = git_snapshot_for_parser();
26458 parse_git_status(
26459 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
26460 &mut snapshot,
26461 );
26462
26463 assert!(!snapshot.truncated);
26464 assert_eq!(snapshot.status.len(), 3);
26465 assert_eq!(snapshot.status[0].index_status, "R");
26466 assert_eq!(snapshot.status[0].worktree_status, " ");
26467 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
26468 assert_eq!(
26469 snapshot.status[0]
26470 .previous_path
26471 .as_ref()
26472 .and_then(RepositoryPath::as_utf8),
26473 Some("src/previous name.rs"),
26474 );
26475 assert_eq!(snapshot.status[1].index_status, " ");
26476 assert_eq!(snapshot.status[1].worktree_status, "C");
26477 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
26478 assert_eq!(
26479 snapshot.status[1]
26480 .previous_path
26481 .as_ref()
26482 .and_then(RepositoryPath::as_utf8),
26483 Some("src/original.rs"),
26484 );
26485 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
26486 assert!(snapshot.status[2].previous_path.is_none());
26487 }
26488
26489 #[test]
26490 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
26491 let mut snapshot = git_snapshot_for_parser();
26492 parse_git_status(
26493 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
26494 &mut snapshot,
26495 );
26496
26497 assert!(snapshot.truncated);
26498 assert_eq!(snapshot.status.len(), 1);
26499 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
26500 assert!(snapshot.status[0].previous_path.is_none());
26501 }
26502
26503 #[tokio::test]
26504 async fn observer_can_browse_host_directories_without_controller() {
26505 let root = temporary_workspace_root("host-directory-observer");
26506 std::fs::create_dir_all(root.join("directory-b")).unwrap();
26507 std::fs::create_dir_all(root.join("directory-a")).unwrap();
26508 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
26509 let workspace_id = WorkspaceId::new("primary").unwrap();
26510 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
26511 let catalog = active_registry().unwrap();
26512 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26513 let shared = NodeShared::new(
26514 handle,
26515 "fixture-token".to_owned(),
26516 NodeId::new("node-1").unwrap(),
26517 vec![workspace],
26518 vec![agent("claude")],
26519 );
26520 let request = NodeRequest::BrowseHostDirectories {
26521 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
26522 after: None,
26523 };
26524 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26525 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
26526 }));
26527 assert!(!request_uses_unnegotiated_capability(
26528 &request,
26529 &baseline_capabilities().unwrap(),
26530 ));
26531 assert!(request_uses_unnegotiated_capability(&request, &[]));
26532
26533 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
26534 .await
26535 .unwrap();
26536 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
26537 panic!("host directory browse returned another response");
26538 };
26539 assert_eq!(
26540 listing
26541 .entries
26542 .iter()
26543 .map(|entry| entry.display_name.as_str())
26544 .collect::<Vec<_>>(),
26545 vec!["directory-a", "directory-b"],
26546 );
26547 assert!(listing.entries.iter().all(|entry| !entry.is_link));
26548 assert!(!listing.incomplete);
26549 std::fs::remove_dir_all(root).unwrap();
26550 }
26551
26552 #[tokio::test]
26553 async fn observer_can_inspect_only_a_registered_workspace() {
26554 let root = temporary_workspace_root("observer");
26555 std::fs::create_dir_all(root.join("src")).unwrap();
26556 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
26557 let workspace_id = WorkspaceId::new("primary").unwrap();
26558 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26559 let catalog = active_registry().unwrap();
26560 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26561 let shared = NodeShared::new(
26562 handle,
26563 "fixture-token".to_owned(),
26564 NodeId::new("node-1").unwrap(),
26565 vec![workspace],
26566 vec![agent("claude")],
26567 );
26568
26569 let response = process_request_inner(
26570 &shared,
26571 77,
26572 ClientRole::Observer,
26573 NodeRequest::InspectWorkspace {
26574 workspace_id: workspace_id.clone(),
26575 },
26576 )
26577 .await
26578 .unwrap();
26579 let NodeResponse::WorkspaceInspected { inspection } = response else {
26580 panic!("workspace inspection returned another response");
26581 };
26582 assert_eq!(inspection.workspace_id, workspace_id);
26583 assert!(inspection.entries.iter().any(|entry| {
26584 entry.relative_path.as_utf8() == Some("src/main.rs")
26585 }));
26586
26587 let unknown = process_request_inner(
26588 &shared,
26589 77,
26590 ClientRole::Observer,
26591 NodeRequest::InspectWorkspace {
26592 workspace_id: WorkspaceId::new("unknown").unwrap(),
26593 },
26594 )
26595 .await
26596 .unwrap_err();
26597 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
26598 std::fs::remove_dir_all(&root).unwrap();
26599 }
26600
26601 #[test]
26602 fn context_pack_source_accepts_only_running_or_successful_exit() {
26603 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
26604 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
26605 exit_code: Some(0),
26606 }));
26607 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26608 exit_code: Some(1),
26609 }));
26610 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26611 exit_code: None,
26612 }));
26613 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
26614 message: "provider failed".to_owned(),
26615 }));
26616 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
26617 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
26618 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
26619 }
26620
26621 #[test]
26622 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
26623 let git = git_snapshot_for_parser();
26624 let first = ContextPackRepositoryObservation {
26625 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26626 git: git.clone(),
26627 files: Vec::new(),
26628 };
26629 let second = ContextPackRepositoryObservation {
26630 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
26631 git,
26632 files: Vec::new(),
26633 };
26634
26635 let error = stable_context_pack_repository(first, second).unwrap_err();
26636
26637 assert_eq!(
26638 error.code,
26639 NodeFailureCode::ContextPackMaterializationFailed,
26640 );
26641 }
26642
26643 #[test]
26644 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
26645 let git = git_snapshot_for_parser();
26646 let first = ContextPackRepositoryObservation {
26647 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26648 git: git.clone(),
26649 files: vec![ContextPackRepositoryFileSource::utf8(
26650 "README.md",
26651 "first\n".to_owned(),
26652 6,
26653 )],
26654 };
26655 let second = ContextPackRepositoryObservation {
26656 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26657 git,
26658 files: vec![ContextPackRepositoryFileSource::utf8(
26659 "README.md",
26660 "other\n".to_owned(),
26661 6,
26662 )],
26663 };
26664
26665 let error = stable_context_pack_repository(first, second).unwrap_err();
26666
26667 assert_eq!(
26668 error.code,
26669 NodeFailureCode::ContextPackMaterializationFailed,
26670 );
26671 }
26672
26673 #[tokio::test]
26674 async fn native_session_catalog_zero_active_session_fixture() {
26675 let workspace_root = temporary_workspace_root("native-catalog-workspace");
26676 let history_root = temporary_workspace_root("native-catalog-history");
26677 let outside_root = temporary_workspace_root("native-catalog-outside");
26678 std::fs::create_dir_all(&workspace_root).unwrap();
26679 std::fs::create_dir_all(&history_root).unwrap();
26680 std::fs::create_dir_all(&outside_root).unwrap();
26681 let transcript = |session_id: &str, cwd: &Path, title: &str| {
26682 [
26683 serde_json::json!({
26684 "type": "user",
26685 "sessionId": session_id,
26686 "cwd": cwd.to_str().unwrap(),
26687 "message": { "content": "raw user text must not cross the catalog wire" }
26688 }),
26689 serde_json::json!({
26690 "type": "assistant",
26691 "sessionId": session_id,
26692 "cwd": cwd.to_str().unwrap(),
26693 "message": { "model": "claude-sonnet", "content": "raw answer" }
26694 }),
26695 serde_json::json!({
26696 "type": "ai-title",
26697 "sessionId": session_id,
26698 "aiTitle": title
26699 }),
26700 ]
26701 .into_iter()
26702 .map(|record| record.to_string())
26703 .collect::<Vec<_>>()
26704 .join("\n")
26705 };
26706 std::fs::write(
26707 history_root.join("stable-session.jsonl"),
26708 transcript("stable-session", &workspace_root, "Workspace review"),
26709 )
26710 .unwrap();
26711 std::fs::write(
26712 history_root.join("outside-session.jsonl"),
26713 transcript("outside-session", &outside_root, "Outside review"),
26714 )
26715 .unwrap();
26716 let history = NativeHistoryConfig::new(vec![
26717 NativeHistoryRoot::new(
26718 gate4agent_types::AdapterId::new("claude-code").unwrap(),
26719 HistorySourceLayout::SingleNdjson,
26720 history_root.clone(),
26721 )
26722 .unwrap(),
26723 ])
26724 .unwrap();
26725 let catalog = active_registry().unwrap();
26726 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26727 assert!(handle.snapshot().sessions.is_empty());
26728 let workspace_id = WorkspaceId::new("primary").unwrap();
26729 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
26730 let mut shared = NodeShared::new(
26731 handle,
26732 "fixture-token".to_owned(),
26733 NodeId::new("node-native-catalog").unwrap(),
26734 vec![workspace],
26735 vec![agent("claude")],
26736 );
26737 shared.native_session_catalog = Some(Arc::new(Mutex::new(
26738 NativeSessionCatalogAuthority::new(history),
26739 )));
26740 let route = NativeSessionCatalogRoute::workspace(
26741 workspace_id.clone(),
26742 agent("claude"),
26743 );
26744 let request = NodeRequest::CatalogNativeSessions {
26745 route: route.clone(),
26746 limit: 2,
26747 };
26748 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
26749 .await
26750 .unwrap_err();
26751 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26752 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
26753 .await
26754 .unwrap();
26755 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
26756 panic!("native catalog returned a different response");
26757 };
26758 let summary = summary.expect("node must emit native session catalog summary metadata");
26759 assert_eq!(entries.len(), 1);
26760 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
26761 assert_eq!(entries[0].external_group, None);
26762 assert_eq!(entries[0].record_id, None);
26763 assert_eq!(entries[0].title, None);
26764 assert_eq!(entries[0].model, None);
26765 assert_eq!(entries[0].message_count, 0);
26766 let encoded_entries = serde_json::to_string(&entries).unwrap();
26767 assert!(!encoded_entries.contains("stable-session"));
26768 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
26769 let page_request = NodeRequest::PageNativeSessions {
26770 route: route.clone(),
26771 window: crate::protocol::NativeSessionCatalogWindow::Recent,
26772 catalog_revision: summary.catalog_revision,
26773 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26774 after_selection_id: None,
26775 limit: 2,
26776 };
26777 let observer = process_request_inner(
26778 &shared,
26779 7,
26780 ClientRole::Observer,
26781 page_request.clone(),
26782 )
26783 .await
26784 .unwrap_err();
26785 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26786 let page_response = process_request_inner(
26787 &shared,
26788 7,
26789 ClientRole::Operator,
26790 page_request.clone(),
26791 )
26792 .await
26793 .unwrap();
26794 assert!(matches!(
26795 page_response,
26796 NodeResponse::NativeSessionsPaged { page, .. }
26797 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
26798 ));
26799 let stale = process_request_inner(
26800 &shared,
26801 7,
26802 ClientRole::Operator,
26803 NodeRequest::PageNativeSessions {
26804 route: route.clone(),
26805 window: crate::protocol::NativeSessionCatalogWindow::Recent,
26806 catalog_revision: summary.catalog_revision.wrapping_add(1),
26807 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26808 after_selection_id: None,
26809 limit: 2,
26810 },
26811 )
26812 .await
26813 .unwrap_err();
26814 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
26815 let selection = NativeSessionSelection {
26816 route: route.clone(),
26817 catalog_revision: summary.catalog_revision,
26818 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26819 selection_id: entries[0].selection_id.clone(),
26820 };
26821 let preview_response = process_request_inner(
26822 &shared,
26823 7,
26824 ClientRole::Operator,
26825 NodeRequest::PreviewNativeSession {
26826 selection: selection.clone(),
26827 message_limit: 1,
26828 },
26829 )
26830 .await
26831 .unwrap();
26832 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
26833 panic!("native preview returned a different response");
26834 };
26835 assert_eq!(preview.messages.len(), 1);
26836 assert!(preview.message_count_exact);
26837 assert_eq!(preview.messages[0].text, "raw answer");
26838 assert!(preview.truncated);
26839
26840 let index_request = NodeRequest::IndexNativeSession {
26841 selection: selection.clone(),
26842 display_name: "Saved Claude".to_owned(),
26843 };
26844 let observer = process_request_inner(
26845 &shared,
26846 7,
26847 ClientRole::Observer,
26848 index_request.clone(),
26849 )
26850 .await
26851 .unwrap_err();
26852 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26853 let without_controller = process_request_inner(
26854 &shared,
26855 7,
26856 ClientRole::Operator,
26857 index_request.clone(),
26858 )
26859 .await
26860 .unwrap_err();
26861 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
26862 let acquired = process_request_inner(
26863 &shared,
26864 7,
26865 ClientRole::Operator,
26866 NodeRequest::AcquireController { lease_ms: 5_000 },
26867 )
26868 .await
26869 .unwrap();
26870 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
26871 let indexed = process_request_inner(
26872 &shared,
26873 7,
26874 ClientRole::Operator,
26875 index_request,
26876 )
26877 .await
26878 .unwrap();
26879 assert!(indexed.native_session_index_contract_is_valid());
26880 let NodeResponse::NativeSessionIndexed {
26881 selection: echoed_selection,
26882 record,
26883 } = indexed else {
26884 panic!("native session index returned a different response");
26885 };
26886 assert_eq!(echoed_selection, selection);
26887 let recataloged = process_request_inner(
26888 &shared,
26889 7,
26890 ClientRole::Operator,
26891 NodeRequest::CatalogNativeSessions {
26892 route,
26893 limit: 2,
26894 },
26895 )
26896 .await
26897 .unwrap();
26898 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
26899 panic!("native catalog returned a different response after indexing");
26900 };
26901 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
26902
26903 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
26904 let external_catalog = process_request_inner(
26905 &shared,
26906 7,
26907 ClientRole::Operator,
26908 NodeRequest::CatalogNativeSessions {
26909 route: unregistered_route.clone(),
26910 limit: 2,
26911 },
26912 )
26913 .await
26914 .unwrap();
26915 let external_json = serde_json::to_string(&external_catalog).unwrap();
26916 assert!(!external_json.contains("outside-session"));
26917 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
26918 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
26919 assert!(!external_json.contains("\"session_id\""));
26920 assert!(!external_json.contains("\"cwd\""));
26921 assert!(!external_json.contains("\"path\""));
26922 let NodeResponse::NativeSessionsCataloged {
26923 route: echoed_route,
26924 entries: external_entries,
26925 summary: Some(external_summary),
26926 } = external_catalog
26927 else {
26928 panic!("unregistered native catalog returned a different response");
26929 };
26930 assert_eq!(echoed_route, unregistered_route);
26931 assert_eq!(external_entries.len(), 1);
26932 assert_eq!(external_entries[0].record_id, None);
26933 let external_group = external_entries[0]
26934 .external_group
26935 .as_ref()
26936 .expect("unregistered session must have an external group");
26937 assert_eq!(
26938 external_group.kind,
26939 gate4agent_types::NativeSessionExternalGroupKind::Project,
26940 );
26941 assert!(external_group.group_id.starts_with("external-"));
26942 assert_eq!(
26943 external_group.display_name,
26944 outside_root.file_name().unwrap().to_string_lossy(),
26945 );
26946 let external_selection = NativeSessionSelection {
26947 route: unregistered_route,
26948 catalog_revision: external_summary.catalog_revision,
26949 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
26950 selection_id: external_entries[0].selection_id.clone(),
26951 };
26952 let external_preview = process_request_inner(
26953 &shared,
26954 7,
26955 ClientRole::Operator,
26956 NodeRequest::PreviewNativeSession {
26957 selection: external_selection.clone(),
26958 message_limit: 1,
26959 },
26960 )
26961 .await
26962 .unwrap();
26963 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
26964 assert!(!external_preview_json.contains("outside-session"));
26965 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
26966 assert!(!external_preview_json.contains("\"session_id\""));
26967 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
26968 panic!("unregistered native preview returned a different response");
26969 };
26970 assert_eq!(preview.messages.len(), 1);
26971 let external_index = process_request_inner(
26972 &shared,
26973 7,
26974 ClientRole::Operator,
26975 NodeRequest::IndexNativeSession {
26976 selection: external_selection,
26977 display_name: "External Claude".to_owned(),
26978 },
26979 )
26980 .await
26981 .unwrap_err();
26982 assert_eq!(
26983 external_index.code,
26984 NodeFailureCode::WorkspaceRegistrationRequired,
26985 );
26986 let record_response = process_request_inner(
26987 &shared,
26988 7,
26989 ClientRole::Operator,
26990 NodeRequest::PreviewSessionRecord {
26991 record_id: record.record_id.clone(),
26992 message_limit: 2,
26993 },
26994 )
26995 .await
26996 .unwrap();
26997 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
26998 panic!("record preview returned a different response");
26999 };
27000 assert_eq!(record_id, record.record_id);
27001 assert_eq!(preview.messages.len(), 2);
27002 let encoded = serde_json::to_string(&preview).unwrap();
27003 assert!(!encoded.contains("stable-session"));
27004 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27005 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27006 panic!("record preview history summary returned a different response");
27007 };
27008 let summaries = events
27009 .iter()
27010 .filter_map(|envelope| match &envelope.event {
27011 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27012 if record_id == &record.record_id =>
27013 {
27014 Some(*summary)
27015 }
27016 _ => None,
27017 })
27018 .collect::<Vec<_>>();
27019 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27020 let summary_json = serde_json::to_string(&summaries).unwrap();
27021 for private in [
27022 "stable-session",
27023 "raw user text must not cross the catalog wire",
27024 "raw answer",
27025 "Workspace review",
27026 "claude-sonnet",
27027 ] {
27028 assert!(!summary_json.contains(private), "{private}");
27029 }
27030 let identity = record.provider_session.clone().unwrap();
27031 assert_eq!(
27032 shared.revalidate_session_record_preview(&record, &identity),
27033 Ok(())
27034 );
27035 shared.remove_record_memory(&record.record_id);
27036 assert_eq!(
27037 shared
27038 .revalidate_session_record_preview(&record, &identity)
27039 .unwrap_err()
27040 .code,
27041 NodeFailureCode::UnknownSessionRecord
27042 );
27043 let released = process_request_inner(
27044 &shared,
27045 7,
27046 ClientRole::Operator,
27047 NodeRequest::ReleaseController,
27048 )
27049 .await
27050 .unwrap();
27051 assert!(matches!(released, NodeResponse::Controller { controller: None }));
27052 assert!(shared.controller_state().is_none());
27053 std::fs::remove_dir_all(workspace_root).unwrap();
27054 std::fs::remove_dir_all(history_root).unwrap();
27055 std::fs::remove_dir_all(outside_root).unwrap();
27056 }
27057
27058 #[tokio::test]
27059 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27060 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27061 std::fs::create_dir_all(&history_root).unwrap();
27062 let history = NativeHistoryConfig::new(vec![
27063 NativeHistoryRoot::new(
27064 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27065 HistorySourceLayout::SingleNdjson,
27066 history_root.clone(),
27067 )
27068 .unwrap(),
27069 ])
27070 .unwrap();
27071 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27072 let held = catalog.lock().unwrap();
27073 let result = timeout(
27074 Duration::from_secs(1),
27075 run_native_session_catalog_operation(
27076 Arc::clone(&catalog),
27077 "native session catalog",
27078 |_| Ok::<(), std::convert::Infallible>(()),
27079 ),
27080 )
27081 .await
27082 .expect("concurrent native catalog request must not wait for the operation deadline")
27083 .unwrap_err();
27084 assert_eq!(result.code, NodeFailureCode::BackendBusy);
27085 drop(held);
27086 std::fs::remove_dir_all(history_root).unwrap();
27087 }
27088
27089 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27090 let unique = std::time::SystemTime::now()
27091 .duration_since(std::time::UNIX_EPOCH)
27092 .unwrap()
27093 .as_nanos();
27094 std::env::temp_dir().join(format!(
27095 "gate4agent-node-{label}-{}-{unique}",
27096 std::process::id(),
27097 ))
27098 }
27099
27100 fn git_snapshot_for_parser() -> GitSnapshot {
27101 GitSnapshot {
27102 is_repository: true,
27103 branch: Some("main".to_owned()),
27104 status: Vec::new(),
27105 recent_commits: Vec::new(),
27106 worktrees: Vec::new(),
27107 managed_worktree: None,
27108 truncated: false,
27109 diagnostic: None,
27110 }
27111 }
27112
27113 #[tokio::test]
27114 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27115 let root = temporary_workspace_root("managed-git-scope");
27116 std::fs::create_dir_all(&root).unwrap();
27117 run_test_git(&root, &["init"]);
27118 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27119 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27120 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27121 run_test_git(&root, &["add", "seed.txt"]);
27122 run_test_git(&root, &["commit", "-m", "seed"]);
27123 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27124
27125 let workspace_id = WorkspaceId::new("managed-a").unwrap();
27126 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27127 let catalog = active_registry().unwrap();
27128 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27129 let shared = NodeShared::new(
27130 handle,
27131 "fixture-token".to_owned(),
27132 NodeId::new("node-1").unwrap(),
27133 vec![workspace],
27134 vec![agent("claude")],
27135 );
27136 let mut lease = ManagedWorktreeLeaseRecord {
27137 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27138 source_workspace_id: WorkspaceId::new("primary").unwrap(),
27139 workspace_id: workspace_id.clone(),
27140 profile_id: WorktreeProfileId::new("default").unwrap(),
27141 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27142 target_root: root.to_string_lossy().into_owned(),
27143 branch: "gate4agent/a".to_owned(),
27144 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27145 expected_head: None,
27146 retention: ManagedWorktreeRetention::Retain,
27147 state: ManagedWorktreeLeaseState::InUse,
27148 session_holders: vec![ManagedWorktreeSessionHolder {
27149 incarnation_id: shared.incarnation_id,
27150 instance_id: AgentInstanceId(7),
27151 generation: SessionGeneration(1),
27152 }],
27153 record_holders: Vec::new(),
27154 cleanup_failure: None,
27155 created_at_unix_ms: 1,
27156 updated_at_unix_ms: 1,
27157 };
27158 *shared
27159 .managed_worktrees
27160 .lock()
27161 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27162 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27163
27164 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27165 let scope = inspection.git.managed_worktree.unwrap();
27166 assert_eq!(scope.lease_id, lease.lease_id);
27167 assert_eq!(scope.branch, "gate4agent/a");
27168 assert_eq!(scope.active_session_count, 1);
27169
27170 lease.branch = "gate4agent/b".to_owned();
27171 *shared
27172 .managed_worktrees
27173 .lock()
27174 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27175 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27176 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27177 assert!(mismatch.git.managed_worktree.is_none());
27178 std::fs::remove_dir_all(root).unwrap();
27179 }
27180
27181 #[tokio::test]
27182 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27183 let root = temporary_workspace_root("workspace-file-write-controller");
27184 std::fs::create_dir_all(root.join("src")).unwrap();
27185 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27186 let workspace_id = WorkspaceId::new("primary").unwrap();
27187 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27188 let catalog = active_registry().unwrap();
27189 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27190 let shared = NodeShared::new(
27191 handle,
27192 "fixture-token".to_owned(),
27193 NodeId::new("node-1").unwrap(),
27194 vec![workspace],
27195 vec![agent("claude")],
27196 );
27197 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27198 let expected_revision = workspace_file_revision(b"old\n");
27199 let request = NodeRequest::WriteWorkspaceFile {
27200 workspace_id: workspace_id.clone(),
27201 path: path.clone(),
27202 expected_revision: expected_revision.clone(),
27203 text: "new\n".to_owned(),
27204 };
27205 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27206 .await
27207 .unwrap_err();
27208 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27209 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27210 .await
27211 .unwrap_err();
27212 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27213 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27214 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27215 let NodeResponse::WorkspaceFileWritten { file } = response else {
27216 panic!("workspace file write returned another response");
27217 };
27218 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27219 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27220 let stale = process_request_inner(
27221 &shared,
27222 77,
27223 ClientRole::Operator,
27224 NodeRequest::WriteWorkspaceFile {
27225 workspace_id,
27226 path,
27227 expected_revision,
27228 text: "lost update\n".to_owned(),
27229 },
27230 )
27231 .await
27232 .unwrap_err();
27233 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27234 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27235 std::fs::remove_dir_all(root).unwrap();
27236 }
27237
27238 #[tokio::test]
27239 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27240 let root = temporary_workspace_root("workspace-entry-create-controller");
27241 std::fs::create_dir_all(root.join("src")).unwrap();
27242 let workspace_id = WorkspaceId::new("primary").unwrap();
27243 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27244 let catalog = active_registry().unwrap();
27245 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27246 let shared = NodeShared::new(
27247 handle,
27248 "fixture-token".to_owned(),
27249 NodeId::new("node-1").unwrap(),
27250 vec![workspace],
27251 vec![agent("claude")],
27252 );
27253 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27254 let request = NodeRequest::CreateWorkspaceFile {
27255 workspace_id: workspace_id.clone(),
27256 path: path.clone(),
27257 };
27258 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27259 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27260 }));
27261 assert!(!request_uses_unnegotiated_capability(
27262 &request,
27263 &baseline_capabilities().unwrap(),
27264 ));
27265 assert!(request_uses_unnegotiated_capability(&request, &[]));
27266 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27267 .await
27268 .unwrap_err();
27269 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27270 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27271 .await
27272 .unwrap_err();
27273 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27274 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27275
27276 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27277 .await
27278 .unwrap();
27279 let NodeResponse::WorkspaceFileCreated { file } = response else {
27280 panic!("workspace file create returned another response");
27281 };
27282 assert_eq!(file.workspace_id, workspace_id);
27283 assert_eq!(file.path, path);
27284 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27285 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27286
27287 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27288 .await
27289 .unwrap_err();
27290 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27291
27292 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27293 let response = process_request_inner(
27294 &shared,
27295 77,
27296 ClientRole::Operator,
27297 NodeRequest::CreateWorkspaceDirectory {
27298 workspace_id: workspace_id.clone(),
27299 path: directory_path.clone(),
27300 },
27301 )
27302 .await
27303 .unwrap();
27304 assert_eq!(
27305 response,
27306 NodeResponse::WorkspaceDirectoryCreated {
27307 workspace_id: workspace_id.clone(),
27308 entry: WorkspaceEntry {
27309 relative_path: directory_path,
27310 kind: WorkspaceEntryKind::Directory,
27311 },
27312 },
27313 );
27314 assert!(root.join("src/nested").is_dir());
27315
27316 let missing_parent = process_request_inner(
27317 &shared,
27318 77,
27319 ClientRole::Operator,
27320 NodeRequest::CreateWorkspaceFile {
27321 workspace_id: workspace_id.clone(),
27322 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27323 },
27324 )
27325 .await
27326 .unwrap_err();
27327 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27328 let non_directory_parent = process_request_inner(
27329 &shared,
27330 77,
27331 ClientRole::Operator,
27332 NodeRequest::CreateWorkspaceFile {
27333 workspace_id,
27334 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27335 },
27336 )
27337 .await
27338 .unwrap_err();
27339 assert_eq!(
27340 non_directory_parent.code,
27341 NodeFailureCode::RepositoryParentNotDirectory,
27342 );
27343 std::fs::remove_dir_all(root).unwrap();
27344 }
27345
27346 #[tokio::test]
27347 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27348 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27349 std::fs::create_dir_all(root.join("src")).unwrap();
27350 #[cfg(windows)]
27351 let root = std::fs::canonicalize(root).unwrap();
27352 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27353 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27354 let barrier = Arc::new(std::sync::Barrier::new(2));
27355 let done = Arc::new(AtomicBool::new(false));
27356 let task_commit_state = Arc::clone(&commit_state);
27357 let task_barrier = Arc::clone(&barrier);
27358 let task_done = Arc::clone(&done);
27359 let task_root = root.clone();
27360 let task = tokio::task::spawn_blocking(move || {
27361 task_barrier.wait();
27362 let result = create_workspace_file_on_disk(
27363 &task_root,
27364 &path,
27365 &task_commit_state,
27366 );
27367 task_done.store(true, Ordering::Release);
27368 result
27369 });
27370
27371 let error = settle_workspace_entry_create(
27372 task,
27373 Arc::clone(&commit_state),
27374 Duration::from_millis(10),
27375 "fixture deadline",
27376 "fixture task failed",
27377 )
27378 .await
27379 .unwrap_err();
27380 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27381 assert_eq!(
27382 commit_state.load(Ordering::Acquire),
27383 WORKSPACE_ENTRY_CREATE_CANCELED,
27384 );
27385
27386 barrier.wait();
27387 timeout(Duration::from_secs(1), async {
27388 while !done.load(Ordering::Acquire) {
27389 tokio::task::yield_now().await;
27390 }
27391 })
27392 .await
27393 .expect("canceled blocking create did not settle");
27394 assert!(!root.join("src/late.rs").exists());
27395 std::fs::remove_dir_all(root).unwrap();
27396 }
27397
27398}