1use crate::git_worktree::{
2 create_worktree as create_git_worktree,
3 create_worktree_with_timeout as create_git_worktree_with_timeout,
4 list_worktrees as list_git_worktrees,
5 list_worktrees_with_deadline as list_git_worktrees_with_deadline,
6 paths_equal as worktree_paths_equal, remove_worktree as remove_git_worktree,
7 removal_lookup_path as normalize_worktree_removal_target,
8 resolve_base_commit_with_timeout,
9 run_git_read_bounded, GitCommandOutput, GitWorktreeError, GitWorktreeErrorKind,
10 NativeGitWorktreeSnapshot,
11};
12use crate::host_directory_browser::{
13 browse_host_directories, HostDirectoryBrowseError, HostDirectoryBrowseErrorKind,
14};
15use crate::environment_profiles::{
16 EnvironmentProfileBinding, NodeEnvironmentProfile, MAX_NODE_ENVIRONMENT_PROFILES,
17};
18use crate::bundle_catalog::{BundleCatalog, NodeBundle};
19use self::bundle_delivery::{DeliveryStore, DeliveryStoreError};
20use self::context_pack_store::ContextPackStore;
21use crate::bundle_provider::{
22 bundle_launch_arguments, validate_bundle_binding, BundleProviderLayout,
23};
24use crate::context_pack::{
25 ContextPackCatalog, ContextPackRepository, ContextPackRepositoryFileSource,
26 ContextPackSelectedFileSkipReason, NodeContextPack, CONTEXT_PACK_SELECTED_FILES,
27};
28use crate::harness_mcp_proxy::{
29 HarnessMcpProxyError, HarnessMcpProxyRegistry, PreparedHarnessMcpSpawn,
30 ReviewedHarnessMcpProgram,
31};
32use crate::session_environment::{
33 MaterializationId, MaterializationOwner, MaterializationOwnershipRecord,
34 MaterializationState, NodeSecretResolver, NodeSessionMaterializationProfile,
35 SessionEnvironmentMaterializeError, SessionEnvironmentMaterializer,
36};
37use crate::worktree_service::{
38 exact_created_worktree, exact_owned_worktree, ManagedWorktreeLeaseRecord,
39 ManagedWorktreeProfile, ManagedWorktreeRegistry, ManagedWorktreeSessionHolder,
40};
41use crate::session_registry::{
42 self, validate_display_name, LoadedNodeState, ManagedWorktreeSpawnReplayRecordV10,
43 ManagedWorktreeSpawnReplayStateV10, MAX_MANAGED_SESSION_RECORDS,
44 MAX_MANAGED_WORKTREE_SPAWN_REPLAYS,
45};
46#[cfg(unix)]
47use crate::workspace_file_unix::{
48 create_workspace_directory as create_workspace_directory_on_disk,
49 create_workspace_file as create_workspace_file_on_disk,
50 read_workspace_file as read_workspace_file_from_disk,
51 write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
52 WorkspaceFileReadError, WorkspaceFileReadErrorKind,
53 WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
54 WORKSPACE_ENTRY_CREATE_PENDING,
55};
56#[cfg(windows)]
57use crate::workspace_file_windows::{
58 create_workspace_directory as create_workspace_directory_on_disk,
59 create_workspace_file as create_workspace_file_on_disk,
60 read_workspace_file as read_workspace_file_from_disk,
61 write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
62 WorkspaceFileReadError, WorkspaceFileReadErrorKind,
63 WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
64 WORKSPACE_ENTRY_CREATE_PENDING,
65};
66use crate::platform;
67use crate::provider_runtime::{
68 require_policy, ProviderRuntimeAdmissionError, ProviderRuntimeMonitor,
69 ProviderRuntimeRequirement,
70};
71use crate::spawn_spec::SpawnProfileRegistry;
72use crate::standalone_workspace::{
73 prepare_standalone_workspace, StandaloneWorkspaceError, StandaloneWorkspaceErrorKind,
74};
75use crate::protocol::{
76 read_json_frame_limited_body_timeout, write_json_frame, write_json_frame_limited,
77 validate_node_negotiated_handshake_capacity, validate_provider_contract_manifest,
78 provider_id_is_legacy, AdapterContractRevision, AgentStreamChunkKindV1, AgentStreamChunkV1,
79 AgentStreamInteractionOptionV1, AgentStreamNamedIdV1,
80 CapabilityId, ClientFrame, ClientRole,
81 ControllerState, DeliveryBlobChunkHexV1, DeliveryBlobDigestV1,
82 DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryStageId,
83 AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
84 AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, FrameError,
85 GitCommitDetails, GitDiff, GitDiffMode, GitDiffRequest, GitHistoryPage,
86 GitObjectId, GitCommitSummary, GitSignatureStatus, GitSnapshot, GitStatusEntry,
87 GitWorktreeSnapshot, HarnessMcpActivationDigest, HarnessMcpReservationId,
88 HostDirectoryListing,
89 NodeCompatibilitySupport, NodeEvent,
90 NodeEventEnvelope, NodeFailure, NodeFailureCode, NodeHello, NodeId, NodeIncarnationId,
91 NegotiatedNodeCompatibility, NodeRequest, NodeResponse, NodeSnapshot, OpaqueHostPath,
92 ProtocolRange,
93 ProviderAdapterContractSupport,
94 ProviderContractRevision, ProviderContractSupport, RepositoryPath, RequestEnvelope,
95 ProviderRuntimeStatuses, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
96 ContextPackLineageReceipt, ContextPackBytesRead, ResolvedContextPackReceipt,
97 ResolvedHarnessMcpProxyReceiptV1, ResolvedSpawnReceipt,
98 ResolvedSpawnSpec, ResponseEnvelope,
99 ServerChallenge, ServerFrame, SessionAddress, SessionAgentProgress, SessionKey, SessionMode,
100 ManagedSessionRecord,
101 ManagedSessionState, ManagedWorktreeCleanupFailure, ManagedWorktreeLeaseId,
102 SessionRecordRetentionConfig,
103 ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState, ManagedWorktreeRetention,
104 ManagedWorktreeSpawnReceipt, ManagedWorktreeSpawnRequest,
105 ManagedWorktreeSpawnRequestV2, SessionRecordId,
106 NativeSessionCatalogRoute, NativeSessionSelection, SessionTaskBindingV1,
107 SessionTaskTargetV1, TaskId,
108 BlockAuthorityV1, HarnessMcpLaunchV1, SessionHistorySummaryV1, MAX_ACP_BLOCKED_HELP_BYTES, MAX_ACP_BLOCKED_REASON_BYTES,
109 StateSchemaSupport, WorkspaceEntry, WorkspaceEntryKind, WorktreeProfileId,
110 SpawnContextId, SpawnEnvironmentProfileId, SpawnIdempotencyKey,
111 SpawnProfileDefaults, SpawnRequiredCapabilities, SpawnSpec, SpawnSpecResolveError,
112 WorkspaceFileContent, WorkspaceFileRevision,
113 WorkspaceFileRead, WorkspaceId, WorkspaceInspection, WorkspaceInspectionTruncationV1,
114 WorkspaceSnapshot,
115 WorktreeServiceMode,
116 LaunchInventory, ManagedWorktreeProfileSummary, SpawnProfileSummary,
117 WorktreeProfileInventory, MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
118 DEFAULT_CONTROLLER_LEASE_MS,
119 MAX_CONTROLLER_LEASE_MS, MIN_CONTROLLER_LEASE_MS, NODE_COMPATIBILITY_METADATA_CAPABILITY,
120 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
121 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, NODE_REPOSITORY_PATH_CAPABILITY,
122 NODE_PROVIDER_ID_OPEN_CAPABILITY,
123 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
124 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY, NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
125 NODE_HISTORY_CONTEXT_PACK_CAPABILITY, NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
126 NODE_NATIVE_SESSION_CATALOG_CAPABILITY, NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
127 NODE_NATIVE_SESSION_INDEX_CAPABILITY, NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
128 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
129 NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
130 NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
131 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
132 NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
133 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
134 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
135 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
136 NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
137 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
138 NODE_AGENT_STREAM_EVENTS_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY,
139 NODE_GIT_READ_CAPABILITY, NODE_WORKSPACE_FILE_READ_CAPABILITY,
140 NODE_WORKSPACE_FILE_WRITE_CAPABILITY, NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
141 NODE_WORKTREE_SELECTION_CAPABILITY,
142 BUILD_STAMP, MAX_NODE_CLIENT_FRAME_BYTES, MAX_NODE_HELLO_FRAME_BYTES,
143 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS, MAX_AGENT_PROGRESS_ENTRY_BYTES,
144 MAX_NODE_TERMINAL_BYTES, MAX_NODE_TEXT_BYTES,
145 MAX_REPOSITORY_PATH_BYTES,
146 MAX_GIT_DIFF_BYTES, MAX_GIT_HISTORY_COMMITS, MAX_WORKSPACE_FILE_BYTES,
147 MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE,
148 MAX_WORKSPACE_ROOT_BYTES, NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10,
149 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY, SPAWN_RUNTIME_RAW_PTY_LIFECYCLE,
150 SPAWN_RUNTIME_SEMANTIC_READINESS, SPAWN_RUNTIME_SEMANTIC_RESUME,
151 SPAWN_RUNTIME_STRUCTURED_PROMPT,
152};
153#[cfg(test)]
154use crate::protocol::WorktreeProfileRevision;
155use ring::digest::{digest, SHA256};
156use crate::protocol::correlation::{
157 interaction_correlation, sanitize_progress_tool_label, tool_class_label, tool_correlation,
158 truncate_text,
159};
160use gate4agent_catalog::{builtin_registry, AgentRegistry};
161use gate4agent_handle::{EventSubscription, Gate4AgentHandle, PortDispatchError};
162use gate4agent_runtime_native::{
163 NativeMcpServerLaunchOverlay, NativeInstanceLaunchOverlay,
164 NativeLaunchEnvironmentOverlay,
165 NativeHistoryConfig, NativeLaunchProfileControl, NativeRuntime, NativeRuntimeConfig,
166 NativeSessionCatalogAuthority, NativeSessionCatalogError, NativeSessionPreviewError,
167 ScopedNativeSessionCatalogEntry,
168};
169use gate4agent_runtime_native::shell_efficiency::ShellEfficiencyProfile;
170use std::sync::OnceLock;
171use gate4agent_runtime_native::tick_profile::TickProfileSnapshot;
172use drive_loop_profile::DriveLoopProfiler;
173#[cfg(test)]
174use gate4agent_runtime_native::{HistorySourceLayout, NativeHistoryRoot};
175use gate4agent_node_wire::{
176 auth_proof, negotiated_auth_proof, proofs_match, random_incarnation_id, random_nonce,
177 AuthDirection, OwnerOnlyLocalListener,
178};
179use gate4agent_types::{
180 AdapterBinding, AdapterFamily, AgentId, AgentInstanceId, AgentSpec, ApprovalLevel,
181 CommandEnvelope, CommandId,
182 validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
183 HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
184 HostDecisionAuthority as ProviderHostDecisionAuthority,
185 HostRequestDecision as ProviderHostRequestDecision,
186 InputAction,
187 PromptFraming, PromptPayload, ResumeLaunchRequest,
188 ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
189 ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
190 ProviderSnapshot, ProviderStopReason,
191 ResumeTarget,
192 SessionGeneration, StartRequest,
193 TerminalControl, TerminalText,
194 SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
195 HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
196};
197use std::collections::{BTreeMap, VecDeque};
198use std::ffi::OsString;
199use std::io;
200use std::path::{Path, PathBuf};
201use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
202use std::sync::{Arc, Mutex, RwLock};
203use std::time::{Duration, Instant};
204use thiserror::Error;
205use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
206use tokio::task::{AbortHandle, JoinSet};
207use tokio::time::{sleep, timeout};
208
209mod http_api;
210
211mod browser_bridge;
212
213mod drive_loop_profile;
214
215#[path = "bundle_delivery.rs"]
216mod bundle_delivery;
217
218#[path = "context_pack_store.rs"]
219mod context_pack_store;
220
221#[cfg(windows)]
222pub use crate::platform::DEFAULT_NODE_ENDPOINT;
223
224const NODE_EVENT_HISTORY_MAX: usize = 4_096;
225const NODE_BROADCAST_CAPACITY: usize = 1_024;
226const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
227const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
233const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
240const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
241const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
242const MAX_PREAUTH_CONNECTIONS: usize = 32;
243const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
244const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
245const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
246const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
247const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
248const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
256const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
264const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
271const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
276const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
277const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
278const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
279const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
280const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
281const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
282const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
283const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
284const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
285const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
286const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
287const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
288const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
291const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
298const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
301const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
302const GIT_STATUS_MAX_ENTRIES: usize = 128;
303const GIT_COMMIT_MAX_ENTRIES: usize = 12;
304const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
305const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
306const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
307const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
308const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
309const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
310const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
311const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
312const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
313const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
314 "provider-session-identity-allocation-failed";
315const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
316const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
317const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
318const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
319const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
320const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
321const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
322 "durable-state-path-semantics-unsupported";
323
324fn opaque_windows_path(value: String) -> OpaqueHostPath {
325 OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
326}
327
328fn agent_progress_from_provider_snapshot(
329 address: SessionAddress,
330 provider: &ProviderSnapshot,
331) -> Option<SessionAgentProgress> {
332 let mut truncated = false;
333 let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
334 let mut active_tool_labels = Vec::with_capacity(
335 provider
336 .active_tools
337 .len()
338 .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
339 );
340 for tool in &provider.active_tools {
341 if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
342 truncated = true;
343 break;
344 }
345 if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
346 active_tool_labels.push(label);
347 }
348 }
349 let attention = provider
350 .interactions
351 .iter()
352 .find(|interaction| {
353 matches!(
354 interaction.status,
355 ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
356 )
357 })
358 .map(|interaction| AgentProgressAttentionV1 {
359 kind: match interaction.interaction_kind {
360 ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
361 ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
362 },
363 tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
364 });
365 let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
366 let usage = (provider.completed_turns > 0
367 || provider.usage.input_tokens > 0
368 || provider.usage.output_tokens > 0
369 || provider.usage.cache_read_tokens > 0
370 || provider.usage.cache_write_tokens > 0
371 || provider.usage.reasoning_tokens > 0)
372 .then_some(AgentProgressUsageV1 {
373 input_tokens: provider.usage.input_tokens,
374 output_tokens: provider.usage.output_tokens,
375 cache_read_tokens: provider.usage.cache_read_tokens,
376 cache_write_tokens: provider.usage.cache_write_tokens,
377 reasoning_tokens: provider.usage.reasoning_tokens,
378 });
379 let progress = AgentProgressV1 {
380 provider_sequence: provider.sequence,
381 activity: provider.activity,
382 completed_turns: provider.completed_turns,
383 usage,
384 current: AgentProgressCurrentV1::from(provider.activity),
385 active_tool_labels,
386 active_tool_count,
387 attention,
388 subagent_count,
389 last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
390 gap_count: provider.gap_count,
391 stale: provider.stale,
392 truncated,
393 };
394 let entry = SessionAgentProgress { address, progress };
395 serde_json::to_vec(&entry)
396 .ok()
397 .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
398 .map(|_| entry)
399}
400
401fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
402 match u32::try_from(count) {
403 Ok(count) => count,
404 Err(_) => {
405 *truncated = true;
406 u32::MAX
407 }
408 }
409}
410
411fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
412 if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
413 return None;
414 }
415 Some(match event {
416 ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
417 ProviderEvent::SessionIdentityObserved { .. } => {
418 AgentProgressEventKindV1::SessionIdentityObserved
419 }
420 ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
421 ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
422 ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
423 ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
424 ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
425 ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
426 ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
427 ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
428 ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
429 ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
430 ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
431 ProviderEvent::InteractionRequested { .. } => {
432 AgentProgressEventKindV1::InteractionRequested
433 }
434 ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
435 ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
436 ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
437 ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
438 ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
439 ProviderEvent::UnrecognizedNotification { .. } => {
440 AgentProgressEventKindV1::UnrecognizedNotification
441 }
442 ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
443 ProviderEvent::UserMessage { .. }
454 | ProviderEvent::Plan { .. }
455 | ProviderEvent::AvailableCommandsUpdated { .. }
456 | ProviderEvent::ModeChanged { .. }
457 | ProviderEvent::SessionInfoUpdated { .. }
458 | ProviderEvent::UsageUpdated { .. }
459 | ProviderEvent::ConfigOptionsUpdated { .. } => {
460 AgentProgressEventKindV1::UnrecognizedNotification
461 }
462 })
463}
464
465fn provider_family_is_live(family: AdapterFamily) -> bool {
478 match family {
479 AdapterFamily::PtySemantic
480 | AdapterFamily::Hook
481 | AdapterFamily::ManagedHook
482 | AdapterFamily::Pipe
483 | AdapterFamily::OneShot
484 | AdapterFamily::Acp => true,
485 AdapterFamily::History
486 | AdapterFamily::Resume
487 | AdapterFamily::SessionOptions
488 | AdapterFamily::CapabilityProbe => false,
489 }
490}
491
492fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
493 match by {
494 ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
495 ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
496 ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
497 ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
498 }
499}
500
501fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
513 match authority {
514 BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
515 BlockAuthorityV1::HostPolicy => "denied by host policy",
516 BlockAuthorityV1::HostDeadline => {
517 "denied by host policy after the operator deadline expired"
518 }
519 BlockAuthorityV1::Operator => "declined by the operator",
520 BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
521 BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
522 BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
523 BlockAuthorityV1::ProviderRefusal => "refused by the provider",
524 BlockAuthorityV1::UserRejected => "rejected by the user",
525 BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
526 BlockAuthorityV1::Unknown => "blocked; no reason was reported",
527 }
528}
529
530fn host_request_denied_block(
533 method: &str,
534 by: ProviderHostDecisionAuthority,
535 reason: Option<&str>,
536) -> (String, BlockAuthorityV1, String) {
537 let authority = block_authority(by);
538 let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
539 let (reason_text, _) =
543 truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
544 (tool_class_label(method), authority, reason_text)
545}
546
547fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
566 match kind? {
567 "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
568 "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
569 _ => None,
570 }
571}
572
573fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
585 let trimmed = text.trim();
586 let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
587 return (trimmed.to_owned(), None);
588 };
589 let tail = after_reason.trim_start();
590 let Some((reason, help)) = tail.split_once(". ") else {
591 let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
595 return (reason.to_owned(), None);
596 };
597 let help = help.trim();
598 if help.is_empty() {
599 (reason.trim().to_owned(), None)
600 } else {
601 (reason.trim().to_owned(), Some(help.to_owned()))
602 }
603}
604
605fn tool_class_from_denial_help(help: Option<&str>) -> String {
615 help.and_then(|text| text.split("add a ").nth(1))
616 .and_then(|tail| tail.split(' ').next())
617 .filter(|word| !word.is_empty())
618 .map(str::to_owned)
619 .unwrap_or_else(|| "Tool".to_owned())
620}
621
622fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
630 fn names_quota(s: &str) -> bool {
631 let lower = s.to_ascii_lowercase();
632 lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
633 }
634 vendor_code.is_some_and(names_quota) || names_quota(message)
635}
636
637fn provider_session_end_block(
653 stop_reason: &ProviderStopReason,
654) -> Option<(BlockAuthorityV1, String, String)> {
655 match stop_reason {
656 ProviderStopReason::Refusal => Some((
657 BlockAuthorityV1::ProviderRefusal,
658 "refusal".to_owned(),
659 "the provider ended the turn with stopReason: refusal".to_owned(),
660 )),
661 ProviderStopReason::ProviderError { message, vendor_code, .. }
662 if provider_error_is_quota(vendor_code.as_deref(), message) =>
663 {
664 let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
665 Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
666 }
667 _ => None,
668 }
669}
670
671fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
712 let ControlEventKind::ProviderEvent {
713 sequence: provider_sequence,
714 source,
715 source_sequence,
716 event: provider_event,
717 } = &event.event
718 else {
719 return None;
720 };
721 if !provider_family_is_live(source.family) {
722 return None;
723 }
724 let kind = match provider_event {
725 ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
726 text: text.clone(),
727 is_delta: *is_delta,
728 },
729 ProviderEvent::Thinking { text } => {
730 AgentStreamChunkKindV1::Thinking { text: text.clone() }
731 }
732 ProviderEvent::InteractionRequested {
733 interaction_kind,
734 tool_name,
735 title,
736 prompt,
737 options,
738 ..
739 } => AgentStreamChunkKindV1::InteractionPrompt {
740 correlation_id: interaction_correlation(
741 event.instance_id,
742 event.generation,
743 *provider_sequence,
744 ),
745 interaction_kind: *interaction_kind,
746 tool_name: tool_name.clone(),
747 title: title.clone(),
756 prompt: prompt.clone(),
757 options: options
758 .iter()
759 .map(|option| AgentStreamInteractionOptionV1 {
760 option_id: option.option_id.clone(),
761 name: option.name.clone(),
762 kind: option.kind.clone(),
763 })
764 .collect(),
765 },
766 ProviderEvent::ConfigOptionsUpdated { options } => {
767 AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
768 }
769 ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
780 current: Some(mode_id.clone()),
781 available: available
782 .iter()
783 .map(|mode| AgentStreamNamedIdV1 {
784 id: mode.id.clone(),
785 name: mode.name.clone(),
786 description: mode.description.clone(),
787 })
788 .collect(),
789 },
790 ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
794 let ProviderHostRequestDecision::Denied { by } = decision else {
795 return None;
796 };
797 let (tool_class, authority, reason_text) =
798 host_request_denied_block(method, *by, reason.as_deref());
799 AgentStreamChunkKindV1::Blocked {
800 correlation_id: None,
801 tool_class,
802 authority,
803 reason_kind: None,
804 reason: reason_text,
805 help: None,
806 }
807 }
808 ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
812 let (authority, reason_kind) =
813 provider_tool_block_authority(non_execution_kind.as_deref())?;
814 let (reason, help) = split_provider_denial_text(output);
815 let (reason, _) =
816 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
817 let help = help.map(|help| {
818 truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
819 });
820 AgentStreamChunkKindV1::Blocked {
821 correlation_id: Some(tool_correlation(event, source, id)),
822 tool_class: tool_class_from_denial_help(help.as_deref()),
823 authority,
824 reason_kind: Some(reason_kind.to_owned()),
825 reason,
826 help,
827 }
828 }
829 ProviderEvent::SessionEnded { stop_reason, .. } => {
833 let (authority, reason_kind, reason) =
834 stop_reason.as_ref().and_then(provider_session_end_block)?;
835 let (reason, _) =
836 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
837 AgentStreamChunkKindV1::Blocked {
838 correlation_id: None,
839 tool_class: "Session".to_owned(),
840 authority,
841 reason_kind: Some(reason_kind),
842 reason,
843 help: None,
844 }
845 }
846 ProviderEvent::SessionStarted { .. }
847 | ProviderEvent::SessionIdentityObserved { .. }
848 | ProviderEvent::TurnStarted { .. }
849 | ProviderEvent::WorkingObserved
850 | ProviderEvent::ToolStarted { .. }
851 | ProviderEvent::TurnCompleted { .. }
852 | ProviderEvent::ContextWindowUsage { .. }
853 | ProviderEvent::TurnInterrupted
854 | ProviderEvent::Error { .. }
855 | ProviderEvent::Ready
856 | ProviderEvent::InteractionResolved { .. }
857 | ProviderEvent::SubagentStarted { .. }
858 | ProviderEvent::SubagentStopped { .. }
859 | ProviderEvent::RateLimited { .. }
860 | ProviderEvent::UnrecognizedNotification { .. }
861 | ProviderEvent::UserMessage { .. }
862 | ProviderEvent::Plan { .. }
863 | ProviderEvent::AvailableCommandsUpdated { .. }
864 | ProviderEvent::SessionInfoUpdated { .. }
865 | ProviderEvent::UsageUpdated { .. } => return None,
866 };
867 Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
868}
869
870async fn run_native_session_catalog_operation<T, E, F>(
871 catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
872 operation: &'static str,
873 execute: F,
874) -> Result<T, NodeFailure>
875where
876 T: Send + 'static,
877 E: std::fmt::Display + Send + 'static,
878 F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
879{
880 let task = tokio::task::spawn_blocking(move || {
881 let mut catalog = match catalog.try_lock() {
882 Ok(catalog) => catalog,
883 Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
884 Err(std::sync::TryLockError::WouldBlock) => {
885 return Err(failure(
886 NodeFailureCode::BackendBusy,
887 "native session catalog operation is already running",
888 ));
889 }
890 };
891 execute(&mut catalog).map_err(|error| {
892 failure(
893 NodeFailureCode::BackendOperationFailed,
894 &format!("{operation} failed: {error}"),
895 )
896 })
897 });
898 timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
899 .await
900 .map_err(|_| {
901 failure(
902 NodeFailureCode::BackendBusy,
903 &format!("{operation} exceeded its bounded deadline"),
904 )
905 })?
906 .map_err(|_| {
907 failure(
908 NodeFailureCode::BackendOperationFailed,
909 &format!("{operation} task failed"),
910 )
911 })?
912}
913
914fn windows_path_text(path: &OpaqueHostPath) -> &str {
915 path.as_utf8()
916 .expect("Windows node only creates UTF-8 opaque host paths")
917}
918
919fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
920 path.as_utf8()
921 .map(str::to_owned)
922 .ok_or_else(|| failure(
923 NodeFailureCode::InvalidRequest,
924 "this node requires UTF-8 host paths",
925 ))
926}
927
928fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
929 GitWorktreeSnapshot {
930 path: opaque_windows_path(worktree.path),
931 head: worktree.head,
932 branch: worktree.branch,
933 is_bare: worktree.is_bare,
934 is_main: worktree.is_main,
935 locked: worktree.locked,
936 lock_reason: worktree.lock_reason,
937 prunable: worktree.prunable,
938 prunable_reason: worktree.prunable_reason,
939 workspace_id: worktree.workspace_id.take(),
940 }
941}
942
943#[derive(Clone, Debug, Eq, PartialEq)]
944pub struct WorkspaceConfig {
945 workspace_id: WorkspaceId,
946 canonical_root: String,
947 worktree_service_mode: WorktreeServiceMode,
948 managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
949}
950
951impl WorkspaceConfig {
952 pub fn new(
953 workspace_id: WorkspaceId,
954 root: impl AsRef<Path>,
955 ) -> Result<Self, NodeServerError> {
956 let root = root.as_ref();
957 if !root.is_absolute() {
958 return Err(NodeServerError::InvalidWorkspaceRoot {
959 workspace_id,
960 path: root.to_string_lossy().into_owned(),
961 message: "path is not absolute".to_owned(),
962 });
963 }
964 let canonical = std::fs::canonicalize(root).map_err(|error| {
965 NodeServerError::InvalidWorkspaceRoot {
966 workspace_id: workspace_id.clone(),
967 path: root.to_string_lossy().into_owned(),
968 message: error.to_string(),
969 }
970 })?;
971 if !canonical.is_dir() {
972 return Err(NodeServerError::InvalidWorkspaceRoot {
973 workspace_id,
974 path: root.to_string_lossy().into_owned(),
975 message: "path is not a directory".to_owned(),
976 });
977 }
978 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
979 NodeServerError::InvalidWorkspaceRoot {
980 workspace_id: workspace_id.clone(),
981 path: path.to_string_lossy().into_owned(),
982 message: "canonical path is not valid Unicode".to_owned(),
983 }
984 })?;
985 let canonical_root = platform::normalize_canonical_root(canonical_root);
986 validate_workspace_root(&workspace_id, &canonical_root)?;
987 Ok(Self {
988 workspace_id,
989 canonical_root,
990 worktree_service_mode: WorktreeServiceMode::Manual,
991 managed_worktree_profiles: BTreeMap::new(),
992 })
993 }
994
995 pub fn workspace_id(&self) -> &WorkspaceId {
996 &self.workspace_id
997 }
998
999 pub fn canonical_root(&self) -> &str {
1000 &self.canonical_root
1001 }
1002
1003 pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1004 self.worktree_service_mode = mode;
1005 self
1006 }
1007
1008 pub fn with_managed_worktree_profile(
1009 mut self,
1010 profile: ManagedWorktreeProfile,
1011 ) -> Result<Self, NodeServerError> {
1012 profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1013 NodeServerError::InvalidManagedWorktreeProfile {
1014 workspace_id: self.workspace_id.clone(),
1015 profile_id: profile.profile_id().clone(),
1016 message,
1017 }
1018 })?;
1019 if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1020 return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1021 workspace_id: self.workspace_id,
1022 profile_id: profile.profile_id().clone(),
1023 });
1024 }
1025 if self.managed_worktree_profiles.len()
1026 == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1027 {
1028 return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1029 workspace_id: self.workspace_id,
1030 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1031 });
1032 }
1033 self.managed_worktree_profiles
1034 .insert(profile.profile_id().clone(), profile);
1035 Ok(self)
1036 }
1037}
1038
1039#[derive(Clone)]
1040pub struct NodeServerConfig {
1041 pub endpoint: String,
1042 api_listen: Option<std::net::SocketAddr>,
1043 bridge_listen: Option<std::net::SocketAddr>,
1046 bridge_token: Option<String>,
1049 bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1052 call_home: Option<std::net::SocketAddr>,
1055 pub node_id: NodeId,
1056 pub workspaces: Vec<WorkspaceConfig>,
1057 access_token: String,
1058 pub runtime: NativeRuntimeConfig,
1059 session_record_retention: SessionRecordRetentionConfig,
1060 state_path: Option<PathBuf>,
1061 spawn_profiles: SpawnProfileRegistry,
1062 session_environment: Option<NodeSessionEnvironmentConfig>,
1063 history: Option<NativeHistoryConfig>,
1064 harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1065 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1069 #[cfg(feature = "fixture")]
1070 fixture_raw_pty_runtime: bool,
1071}
1072
1073#[derive(Clone)]
1074struct NodeSessionEnvironmentConfig {
1075 root: PathBuf,
1076 resolver: Arc<dyn NodeSecretResolver>,
1077}
1078
1079impl NodeServerConfig {
1080 pub fn new(
1081 endpoint: impl Into<String>,
1082 access_token: impl Into<String>,
1083 node_id: NodeId,
1084 workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1085 ) -> Result<Self, NodeServerError> {
1086 let endpoint = endpoint.into();
1087 let access_token = access_token.into();
1088 if !platform::validate_endpoint(&endpoint) {
1089 return Err(NodeServerError::InvalidEndpoint);
1090 }
1091 if access_token.is_empty() || access_token.len() > 4_096 {
1092 return Err(NodeServerError::InvalidAccessToken);
1093 }
1094 let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1095 if workspaces.is_empty() {
1096 return Err(NodeServerError::NoWorkspaces);
1097 }
1098 let mut workspace_ids = BTreeMap::new();
1099 let mut workspace_roots = BTreeMap::new();
1100 for workspace in &workspaces {
1101 if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1102 && workspace.managed_worktree_profiles.is_empty()
1103 {
1104 return Err(NodeServerError::ManagedWorktreeProfileRequired(
1105 workspace.workspace_id.clone(),
1106 ));
1107 }
1108 if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1109 && !workspace.managed_worktree_profiles.is_empty()
1110 {
1111 return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1112 workspace.workspace_id.clone(),
1113 ));
1114 }
1115 if workspace_ids
1116 .insert(workspace.workspace_id.clone(), ())
1117 .is_some()
1118 {
1119 return Err(NodeServerError::DuplicateWorkspaceId(
1120 workspace.workspace_id.clone(),
1121 ));
1122 }
1123 let root_key = platform::root_identity(&workspace.canonical_root);
1124 if let Some(existing) = workspace_roots.insert(
1125 root_key,
1126 workspace.workspace_id.clone(),
1127 ) {
1128 return Err(NodeServerError::DuplicateWorkspaceRoot {
1129 first: existing,
1130 second: workspace.workspace_id.clone(),
1131 root: workspace.canonical_root.clone(),
1132 });
1133 }
1134 }
1135 Ok(Self {
1136 endpoint,
1137 api_listen: None,
1138 bridge_listen: None,
1139 bridge_token: None,
1140 bridge_underlay: None,
1141 call_home: None,
1142 node_id,
1143 workspaces,
1144 access_token,
1145 runtime: NativeRuntimeConfig::default(),
1146 session_record_retention: SessionRecordRetentionConfig::default(),
1147 state_path: None,
1148 spawn_profiles: SpawnProfileRegistry::default(),
1149 session_environment: None,
1150 history: None,
1151 harness_mcp_helper: None,
1152 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1153 #[cfg(feature = "fixture")]
1154 fixture_raw_pty_runtime: false,
1155 })
1156 }
1157
1158 pub fn with_api_listen(
1159 mut self,
1160 api_listen: std::net::SocketAddr,
1161 ) -> Result<Self, NodeServerError> {
1162 if !api_listen.ip().is_loopback() {
1163 return Err(NodeServerError::InvalidApiListen(api_listen));
1164 }
1165 self.api_listen = Some(api_listen);
1166 Ok(self)
1167 }
1168
1169 pub fn with_bridge_listen(
1173 mut self,
1174 bridge_listen: std::net::SocketAddr,
1175 ) -> Result<Self, NodeServerError> {
1176 if !bridge_listen.ip().is_loopback() {
1177 return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1178 }
1179 self.bridge_listen = Some(bridge_listen);
1180 Ok(self)
1181 }
1182
1183 pub fn with_bridge_token(
1186 mut self,
1187 bridge_token: impl Into<String>,
1188 ) -> Result<Self, NodeServerError> {
1189 let bridge_token = bridge_token.into();
1190 if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1191 return Err(NodeServerError::InvalidBridgeToken);
1192 }
1193 self.bridge_token = Some(bridge_token);
1194 Ok(self)
1195 }
1196
1197 pub fn with_bridge_underlay(
1202 mut self,
1203 bind: std::net::SocketAddr,
1204 transport_key: [u8; 32],
1205 underlay_token: impl Into<String>,
1206 ) -> Result<Self, NodeServerError> {
1207 if self.bridge_listen.is_none() {
1208 return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1209 }
1210 if self.bridge_token.is_none() {
1211 return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1212 }
1213 let underlay_token = underlay_token.into();
1214 if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1215 return Err(NodeServerError::InvalidBridgeUnderlayToken);
1216 }
1217 self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1219 bind,
1220 transport_key,
1221 underlay_token,
1222 });
1223 Ok(self)
1224 }
1225
1226 pub fn with_call_home(
1247 mut self,
1248 relay: std::net::SocketAddr,
1249 ) -> Result<Self, NodeServerError> {
1250 if !relay.ip().is_loopback() || relay.port() == 0 {
1251 return Err(NodeServerError::InvalidCallHome(relay));
1252 }
1253 self.call_home = Some(relay);
1254 Ok(self)
1255 }
1256
1257 pub fn with_state_path(
1258 mut self,
1259 state_path: impl Into<PathBuf>,
1260 ) -> Result<Self, NodeServerError> {
1261 let state_path = state_path.into();
1262 if !state_path.is_absolute() || state_path.file_name().is_none() {
1263 return Err(NodeServerError::InvalidStatePath(
1264 state_path.to_string_lossy().into_owned(),
1265 ));
1266 }
1267 self.state_path = Some(state_path);
1268 Ok(self)
1269 }
1270
1271 pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1272 self.spawn_profiles = spawn_profiles;
1273 self
1274 }
1275
1276 pub fn with_session_record_retention(
1282 mut self,
1283 retention: SessionRecordRetentionConfig,
1284 ) -> Self {
1285 self.session_record_retention = retention;
1286 self
1287 }
1288
1289 pub fn with_session_environment_materialization(
1290 mut self,
1291 root: impl Into<PathBuf>,
1292 resolver: Arc<dyn NodeSecretResolver>,
1293 ) -> Result<Self, NodeServerError> {
1294 let root = root.into();
1295 if !root.is_absolute() {
1296 return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1297 }
1298 self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1299 Ok(self)
1300 }
1301
1302 pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1305 self.history = Some(history);
1306 self
1307 }
1308
1309 pub fn with_harness_mcp_helper(
1311 mut self,
1312 helper_program: impl Into<PathBuf>,
1313 ) -> Result<Self, NodeServerError> {
1314 #[cfg(not(windows))]
1315 {
1316 let _ = helper_program;
1317 return Err(NodeServerError::InvalidHarnessMcpHelper);
1318 }
1319 #[cfg(windows)]
1320 {
1321 self.harness_mcp_helper = Some(
1322 ReviewedHarnessMcpProgram::review(helper_program.into())
1323 .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1324 );
1325 Ok(self)
1326 }
1327 }
1328
1329 pub fn with_network_allowlist_catalog(
1334 mut self,
1335 catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1336 ) -> Result<Self, NodeServerError> {
1337 if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1338 return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1339 max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1340 });
1341 }
1342 self.network_allowlist_catalog = catalog;
1343 Ok(self)
1344 }
1345
1346 pub fn with_network_allowlist_catalog_file(
1350 self,
1351 path: impl AsRef<std::path::Path>,
1352 ) -> Result<Self, NodeServerError> {
1353 let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1354 .map_err(NodeServerError::from)?;
1355 self.with_network_allowlist_catalog(catalog)
1356 }
1357}
1358
1359pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1360 platform::default_state_path(node_id.as_str())
1361 .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1362}
1363
1364pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1365 platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1366}
1367
1368fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1369 let parent = state_path.parent()?;
1370 let mut name = state_path.file_name()?.to_os_string();
1371 name.push(".delivery-store");
1372 Some(parent.join(name))
1373}
1374
1375fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1376 let parent = state_path.parent()?;
1377 let mut name = state_path.file_name()?.to_os_string();
1378 name.push(".context-pack-store");
1379 Some(parent.join(name))
1380}
1381
1382pub struct NodeServer {
1383 config: NodeServerConfig,
1384 runtime: NativeRuntime,
1385 shared: Arc<NodeShared>,
1386 events: EventSubscription,
1387 state_path_lock: Option<session_registry::StatePathLock>,
1388}
1389
1390#[cfg(feature = "fixture")]
1391#[derive(Clone, Default)]
1392pub struct SpawnManagedWorktreeV2FailureProbe {
1393 latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1394}
1395
1396#[cfg(feature = "fixture")]
1397impl SpawnManagedWorktreeV2FailureProbe {
1398 pub fn latest_code(&self) -> Option<NodeFailureCode> {
1399 *self
1400 .latest_code
1401 .lock()
1402 .unwrap_or_else(|poisoned| poisoned.into_inner())
1403 }
1404
1405 fn record(&self, code: Option<NodeFailureCode>) {
1406 *self
1407 .latest_code
1408 .lock()
1409 .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1410 }
1411}
1412
1413#[cfg(all(feature = "fixture", windows))]
1414struct IsolatedCodexHomeFixtureEnvironment;
1415
1416#[cfg(all(feature = "fixture", windows))]
1417impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1418 for IsolatedCodexHomeFixtureEnvironment
1419{
1420 fn resolve_child_environment(
1421 &self,
1422 ) -> Result<
1423 Vec<gate4agent_catalog::EnvMutation>,
1424 gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1425 > {
1426 Ok(vec![gate4agent_catalog::EnvMutation {
1427 key: OsString::from("USERPROFILE"),
1428 value: None,
1429 }])
1430 }
1431}
1432
1433impl NodeServer {
1434 pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1435 let catalog = active_registry()?;
1436 Self::new_with_registry(config, catalog)
1437 }
1438
1439 #[cfg(feature = "fixture")]
1440 pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1441 let mut spec = gate4agent_testkit::interactive_agent_spec();
1442 spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1443 spec.display_name = "Controlled Claude PTY fixture".to_owned();
1444 Self::new_fixture_with_spec(config, spec)
1445 }
1446
1447 #[cfg(feature = "fixture")]
1448 pub fn new_clean_exit_fixture(
1449 config: NodeServerConfig,
1450 fixture_root: PathBuf,
1451 started_marker: PathBuf,
1452 release_signal: PathBuf,
1453 ) -> Result<Self, NodeServerError> {
1454 let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1455 &fixture_root,
1456 &started_marker,
1457 &release_signal,
1458 )
1459 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1460 spec.id = AgentId::new("claude")
1461 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1462 spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1463 Self::new_fixture_with_spec(config, spec)
1464 }
1465
1466 #[cfg(all(feature = "fixture", windows))]
1476 pub fn new_durable_context_pack_clean_exit_fixture(
1477 config: NodeServerConfig,
1478 fixture_root: PathBuf,
1479 started_marker: PathBuf,
1480 release_signal: PathBuf,
1481 session_id: &str,
1482 ) -> Result<Self, NodeServerError> {
1483 let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1484 &fixture_root,
1485 &started_marker,
1486 &release_signal,
1487 session_id,
1488 )
1489 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1490 let source_id = AgentId::new("claude")
1491 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1492 let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1493 NodeServerError::Registry(
1494 "durable context pack clean-exit fixture source adapter is unavailable"
1495 .to_owned(),
1496 )
1497 })?;
1498 source_spec.capabilities.adapters.history =
1499 source_provider.capabilities.adapters.history.clone();
1500 source_spec.id = source_id;
1501 source_spec.display_name =
1502 "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1503 source_spec.detection.command =
1509 "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1510 source_spec.detection.aliases.clear();
1511
1512 let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1513 target_spec.id = AgentId::new("codex")
1514 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1515 target_spec.display_name =
1516 "Controlled Codex durable context-pack target fixture".to_owned();
1517 target_spec.detection.command =
1518 "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1519 target_spec.detection.aliases.clear();
1520
1521 let catalog = AgentRegistry::new([source_spec, target_spec])
1522 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1523 let mut server = Self::new_with_registry(config, catalog)?;
1524 Arc::get_mut(&mut server.shared)
1525 .expect("fixture Node shared state must remain uniquely owned before run")
1526 .fixture_semantic_hook_policy = true;
1527 Ok(server)
1528 }
1529
1530 #[cfg(feature = "fixture")]
1531 pub fn new_monitoring_hook_fixture(
1532 config: NodeServerConfig,
1533 ) -> Result<Self, NodeServerError> {
1534 let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1535 spec.id = AgentId::new("claude")
1536 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1537 let mut server = Self::new_fixture_with_spec(config, spec)?;
1538 Arc::get_mut(&mut server.shared)
1539 .expect("fixture Node shared state must remain uniquely owned before run")
1540 .fixture_semantic_hook_policy = true;
1541 Ok(server)
1542 }
1543
1544 #[cfg(all(feature = "fixture", windows))]
1545 pub fn new_provider_bundle_argv_fixture(
1546 config: NodeServerConfig,
1547 agent_id: AgentId,
1548 proof_path: PathBuf,
1549 ) -> Result<Self, NodeServerError> {
1550 let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1551 Self::new_fixture_with_spec(config, spec)
1552 }
1553
1554 #[cfg(all(feature = "fixture", windows))]
1555 pub fn new_provider_bundle_argv_hold_fixture(
1556 config: NodeServerConfig,
1557 agent_id: AgentId,
1558 proof_path: PathBuf,
1559 release_signal: PathBuf,
1560 ) -> Result<Self, NodeServerError> {
1561 let spec = Self::provider_bundle_argv_fixture_spec(
1562 agent_id,
1563 proof_path,
1564 Some(release_signal),
1565 )?;
1566 Self::new_fixture_with_spec(config, spec)
1567 }
1568
1569 #[cfg(all(feature = "fixture", windows))]
1570 fn provider_bundle_argv_fixture_spec(
1571 agent_id: AgentId,
1572 proof_path: PathBuf,
1573 release_signal: Option<PathBuf>,
1574 ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1575 if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1576 return Err(NodeServerError::Registry(
1577 "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1578 ));
1579 }
1580 let mut spec = gate4agent_testkit::interactive_agent_spec();
1581 spec.id = agent_id.clone();
1582 spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1583 let provider = builtin_registry()
1584 .get(&agent_id)
1585 .ok_or_else(|| {
1586 NodeServerError::Registry(
1587 "provider bundle argv fixture adapter is unavailable".to_owned(),
1588 )
1589 })?;
1590 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1591 if !proof_path.is_absolute()
1592 || !proof_path
1593 .parent()
1594 .is_some_and(|parent| parent.is_dir())
1595 {
1596 return Err(NodeServerError::Registry(
1597 "provider bundle argv proof path requires an existing absolute parent"
1598 .to_owned(),
1599 ));
1600 }
1601 if let Some(release_signal) = release_signal.as_ref() {
1602 if !release_signal.is_absolute()
1603 || !release_signal
1604 .parent()
1605 .is_some_and(|parent| parent.is_dir())
1606 {
1607 return Err(NodeServerError::Registry(
1608 "provider bundle argv release path requires an existing absolute parent"
1609 .to_owned(),
1610 ));
1611 }
1612 if release_signal.exists() {
1613 return Err(NodeServerError::Registry(
1614 "provider bundle argv release path must not exist".to_owned(),
1615 ));
1616 }
1617 let proof_parent = std::fs::canonicalize(
1618 proof_path
1619 .parent()
1620 .expect("validated provider bundle proof parent"),
1621 )
1622 .map_err(|_| {
1623 NodeServerError::Registry(
1624 "provider bundle argv proof parent is unavailable".to_owned(),
1625 )
1626 })?;
1627 let release_parent = std::fs::canonicalize(
1628 release_signal
1629 .parent()
1630 .expect("validated provider bundle release parent"),
1631 )
1632 .map_err(|_| {
1633 NodeServerError::Registry(
1634 "provider bundle argv release parent is unavailable".to_owned(),
1635 )
1636 })?;
1637 if proof_parent != release_parent {
1638 return Err(NodeServerError::Registry(
1639 "provider bundle argv release path must share the proof parent".to_owned(),
1640 ));
1641 }
1642 }
1643 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1644 NodeServerError::Registry(
1645 "provider bundle argv proof path is not valid Unicode".to_owned(),
1646 )
1647 })?;
1648 let release_signal = release_signal.map(|release_signal| {
1649 release_signal.into_os_string().into_string().map_err(|_| {
1650 NodeServerError::Registry(
1651 "provider bundle argv release path is not valid Unicode".to_owned(),
1652 )
1653 })
1654 }).transpose()?;
1655 let script = spec
1656 .launch
1657 .fixed_args
1658 .pop()
1659 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1660 let bundle_validation = if agent_id.as_str() == "codex" {
1661 "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') }"
1662 } else if agent_id.as_str() == "claude" {
1663 "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--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 }"
1664 } else {
1665 "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 }"
1666 };
1667 if let Some(release_signal) = release_signal {
1668 spec.launch.fixed_args.push(format!(
1669 "& {{ 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} }}",
1670 ));
1671 spec.launch.fixed_args.push(proof_path);
1672 spec.launch.fixed_args.push(release_signal);
1673 } else {
1674 spec.launch.fixed_args.push(format!(
1675 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1676 ));
1677 spec.launch.fixed_args.push(proof_path);
1678 }
1679 Ok(spec)
1680 }
1681
1682 #[cfg(all(feature = "fixture", windows))]
1683 pub fn new_context_pack_fixture(
1684 config: NodeServerConfig,
1685 proof_path: PathBuf,
1686 ) -> Result<Self, NodeServerError> {
1687 let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1688 Self::new_with_registry(config, catalog)
1689 }
1690
1691 #[cfg(all(feature = "fixture", windows))]
1692 pub fn new_monitoring_context_pack_fixture(
1693 config: NodeServerConfig,
1694 proof_path: PathBuf,
1695 ) -> Result<Self, NodeServerError> {
1696 let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1697 let mut server = Self::new_with_registry(config, catalog)?;
1698 Arc::get_mut(&mut server.shared)
1699 .expect("fixture Node shared state must remain uniquely owned before run")
1700 .fixture_semantic_hook_policy = true;
1701 Ok(server)
1702 }
1703
1704 #[cfg(all(feature = "fixture", windows))]
1705 pub fn install_isolated_codex_home_environment_fixture(
1706 &mut self,
1707 profile_id: SpawnEnvironmentProfileId,
1708 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1709 ) -> Result<(), NodeServerError> {
1710 let provider = AgentId::new("codex")
1711 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1712 let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1713 gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1714 "{}-pty",
1715 profile_id.as_str(),
1716 ))
1717 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1718 provider.clone(),
1719 TransportKind::Pty,
1720 vec![OsString::from("USERPROFILE")],
1721 Arc::new(IsolatedCodexHomeFixtureEnvironment),
1722 )
1723 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1724 let materialization = NodeSessionMaterializationProfile::new(
1725 Vec::new(),
1726 vec![crate::session_environment::NodeSessionPathBinding::new(
1727 "CODEX_HOME",
1728 crate::session_environment::NodeSessionPathClass::ProviderHome,
1729 )
1730 .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1731 Vec::new(),
1732 )
1733 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1734 self.install_environment_profile(
1735 NodeEnvironmentProfile::new_with_materialization(
1736 profile_id,
1737 profile_revision,
1738 provider,
1739 [native_profile],
1740 Some(materialization),
1741 )
1742 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1743 )
1744 }
1745
1746 #[cfg(all(feature = "fixture", windows))]
1747 pub fn new_context_only_proof_fixture(
1748 config: NodeServerConfig,
1749 target_provider: AgentId,
1750 proof_path: PathBuf,
1751 ) -> Result<Self, NodeServerError> {
1752 if target_provider.as_str() != "kimi" {
1753 return Err(NodeServerError::Registry(
1754 "context-only proof fixture requires Kimi".to_owned(),
1755 ));
1756 }
1757 let catalog = Self::context_pack_fixture_catalog(
1758 proof_path,
1759 Some(&target_provider),
1760 false,
1761 )?;
1762 Self::new_with_registry(config, catalog)
1763 }
1764
1765 #[cfg(all(feature = "fixture", windows))]
1766 fn context_pack_fixture_catalog(
1767 proof_path: PathBuf,
1768 context_only_target: Option<&AgentId>,
1769 monitoring_claude_source: bool,
1770 ) -> Result<AgentRegistry, NodeServerError> {
1771 if !proof_path.is_absolute()
1772 || !proof_path
1773 .parent()
1774 .is_some_and(|parent| parent.is_dir())
1775 {
1776 return Err(NodeServerError::Registry(
1777 "context pack proof path requires an existing absolute parent".to_owned(),
1778 ));
1779 }
1780 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1781 NodeServerError::Registry(
1782 "context pack proof path is not valid Unicode".to_owned(),
1783 )
1784 })?;
1785 let providers = ["claude", "codex", "grok", "kimi"];
1786 let builtins = builtin_registry();
1787 let mut specs = Vec::with_capacity(providers.len());
1788 for provider_id in providers {
1789 let agent_id = AgentId::new(provider_id)
1790 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1791 let provider = builtins.get(&agent_id).ok_or_else(|| {
1792 NodeServerError::Registry(
1793 "context pack fixture provider adapter is unavailable".to_owned(),
1794 )
1795 })?;
1796 let monitored_provider =
1797 monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1798 let mut spec = if monitored_provider {
1799 gate4agent_testkit::monitoring_hook_agent_spec()
1800 } else {
1801 gate4agent_testkit::interactive_agent_spec()
1802 };
1803 spec.id = agent_id.clone();
1804 spec.display_name = format!("Controlled {agent_id} context pack fixture");
1805 spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1806 spec.detection.aliases.clear();
1807 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1808 if provider_id == "codex" {
1809 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1810 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1811 })?;
1812 let validation = r#"
1813$bundleArgs = @($bundleArgs)
1814if ($bundleArgs.Count -ne 0) { exit 91 }
1815$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1816$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1817$cwd = (Get-Location).ProviderPath
1818if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1819$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1820if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1821$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1822if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1823$sha = [Security.Cryptography.SHA256]::Create()
1824try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1825if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1826if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1827$contextPath = Join-Path $contextRoot 'context-pack.json'
1828if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1829$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1830if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1831$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1832$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1833$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1834$separator = [IO.Path]::DirectorySeparatorChar
1835if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1836if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1837$rawContext = [IO.File]::ReadAllText($contextPath)
1838if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1839try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1840$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1841$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1842foreach ($property in @($document.PSObject.Properties.Name)) {
1843 if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1844}
1845foreach ($property in $requiredTopLevel) {
1846 if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1847}
1848if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1849if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1850$messages = @($document.retained_messages)
1851if ($messages.Count -eq 0) { exit 107 }
1852foreach ($message in $messages) {
1853 $messageProperties = @($message.PSObject.Properties.Name)
1854 foreach ($property in $messageProperties) {
1855 if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1856 }
1857 if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1858}
1859$sha = [Security.Cryptography.SHA256]::Create()
1860try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1861[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))
1862$rawProof = [IO.File]::ReadAllText($proofPath)
1863if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1864[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1865"#;
1866 spec.launch.fixed_args.push(format!(
1867 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1868 ));
1869 spec.launch.fixed_args.push(proof_path.clone());
1870 } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1871 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1872 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1873 })?;
1874 let validation = r#"
1875$bundleArgs = @($bundleArgs)
1876if ($bundleArgs.Count -ne 0) { exit 91 }
1877$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1878$cwd = (Get-Location).ProviderPath
1879if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1880$contextPath = Join-Path $contextRoot 'context-pack.json'
1881if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1882$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1883if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1884$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1885$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1886$separator = [IO.Path]::DirectorySeparatorChar
1887if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1888try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1889if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1890if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1891$messages = @($document.retained_messages)
1892if ($messages.Count -eq 0) { exit 99 }
1893$hasUser = $false
1894$hasAssistant = $false
1895foreach ($message in $messages) {
1896 if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1897 if ([string]$message.role -ceq 'user') { $hasUser = $true }
1898 elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1899 else { exit 101 }
1900}
1901if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1902$sha = [Security.Cryptography.SHA256]::Create()
1903try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1904[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1905[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1906"#;
1907 spec.launch.fixed_args.push(format!(
1908 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1909 ));
1910 spec.launch.fixed_args.push(proof_path.clone());
1911 }
1912 specs.push(spec);
1913 }
1914 let catalog = AgentRegistry::new(specs)
1915 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1916 Ok(catalog)
1917 }
1918
1919 #[cfg(feature = "fixture")]
1920 pub fn new_exact_launcher_fixture(
1921 config: NodeServerConfig,
1922 agent_id: AgentId,
1923 launcher: String,
1924 ) -> Result<Self, NodeServerError> {
1925 if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1926 return Err(NodeServerError::Registry(
1927 "exact launcher fixture requires a supported provider".to_owned(),
1928 ));
1929 }
1930 if launcher.contains('\0') {
1931 return Err(NodeServerError::Registry(
1932 "exact launcher fixture path contains NUL".to_owned(),
1933 ));
1934 }
1935 let launcher_path = Path::new(&launcher);
1936 if !launcher_path.is_absolute() {
1937 return Err(NodeServerError::Registry(
1938 "exact launcher fixture path must be absolute".to_owned(),
1939 ));
1940 }
1941 let metadata = std::fs::metadata(launcher_path).map_err(|_| {
1942 NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
1943 })?;
1944 if !metadata.is_file() {
1945 return Err(NodeServerError::Registry(
1946 "exact launcher fixture path must be a regular file".to_owned(),
1947 ));
1948 }
1949 let mut spec = builtin_registry()
1950 .get(&agent_id)
1951 .ok_or_else(|| {
1952 NodeServerError::Registry(
1953 "exact launcher fixture provider is unavailable".to_owned(),
1954 )
1955 })?
1956 .clone();
1957 spec.launch.program = launcher;
1958 Self::new_fixture_with_spec(config, spec)
1959 }
1960
1961 #[cfg(feature = "fixture")]
1970 fn h3b_provider_spec_with_program_and_args(
1971 agent_id: AgentId,
1972 provider_program: PathBuf,
1973 fixed_args: Vec<OsString>,
1974 ) -> Result<AgentSpec, NodeServerError> {
1975 if !provider_program.is_absolute() || fixed_args.len() > 32 {
1976 return Err(NodeServerError::Registry(
1977 "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
1978 ));
1979 }
1980 let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
1981 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
1982 })?;
1983 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
1984 return Err(NodeServerError::Registry(
1985 "H3B fixture provider must be an exact regular file".to_owned(),
1986 ));
1987 }
1988 let program = provider_program.into_os_string().into_string().map_err(|_| {
1989 NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
1990 })?;
1991 let fixed_args = fixed_args.into_iter().map(|argument| {
1992 argument.into_string().map_err(|_| NodeServerError::Registry(
1993 "H3B fixture argument is not Unicode".to_owned(),
1994 ))
1995 }).collect::<Result<Vec<_>, _>>()?;
1996 let argument_bytes = fixed_args.iter()
1997 .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
1998 if program.contains('\0')
1999 || fixed_args.iter().any(|argument| argument.contains('\0'))
2000 || !matches!(argument_bytes, Some(total) if total <= 65_536)
2001 {
2002 return Err(NodeServerError::Registry(
2003 "H3B fixture provider or arguments are invalid".to_owned(),
2004 ));
2005 }
2006 let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2007 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2008 })?.clone();
2009 let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2010 .ok_or_else(|| NodeServerError::Registry(
2011 "H3B fixture provider basename is unavailable".to_owned(),
2012 ))?.to_owned();
2013 spec.detection.command = process_name.clone();
2014 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2015 name: process_name,
2016 }];
2017 spec.launch.program = program;
2018 spec.launch.fixed_args = fixed_args;
2019 Ok(spec)
2020 }
2021
2022 #[cfg(feature = "fixture")]
2023 pub fn new_harness_mcp_proxy_fixture(
2024 config: NodeServerConfig,
2025 provider_program: PathBuf,
2026 fixed_args: Vec<OsString>,
2027 harness_mcp_program: PathBuf,
2028 ) -> Result<Self, NodeServerError> {
2029 let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2030 let agent_id = AgentId::new("codex")
2031 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2032 let mut spec =
2033 Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2034 spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2035 config.fixture_raw_pty_runtime = true;
2036 Self::new_fixture_with_spec(config, spec)
2037 }
2038
2039 #[cfg(feature = "fixture")]
2063 pub fn new_harness_mcp_acp_launcher_fixture(
2064 config: NodeServerConfig,
2065 helper_program: PathBuf,
2066 provider: AgentId,
2067 program: PathBuf,
2068 args: Vec<OsString>,
2069 ) -> Result<Self, NodeServerError> {
2070 if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2071 return Err(NodeServerError::Registry(
2072 "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2073 ));
2074 }
2075 let config = config.with_harness_mcp_helper(helper_program)?;
2076 let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2077 let launch = spec.launch.clone();
2078 let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2079 NodeServerError::Registry(
2080 "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2081 .to_owned(),
2082 )
2083 })?;
2084 acp.launch_override = Some(launch);
2092 spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2093 Self::new_fixture_with_spec(config, spec)
2094 }
2095
2096 #[cfg(feature = "fixture")]
2097 pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2098 let mut spec = gate4agent_testkit::interactive_agent_spec();
2099 let claude_id = AgentId::new("claude")
2100 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2101 let claude = builtin_registry()
2102 .get(&claude_id)
2103 .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2104 spec.id = claude_id;
2105 spec.display_name = "Resumable Claude PTY fixture".to_owned();
2106 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2107 name: "claude".to_owned(),
2108 }];
2109 spec.capabilities.transports.pty_adapter = claude
2110 .capabilities
2111 .transports
2112 .pty_adapter
2113 .clone();
2114 spec.capabilities.adapters.resume = claude
2115 .capabilities
2116 .adapters
2117 .resume
2118 .clone();
2119 let script = spec
2120 .launch
2121 .fixed_args
2122 .pop()
2123 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2124 spec.launch.fixed_args.push(format!(
2125 "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2126 ));
2127 Self::new_fixture_with_spec(config, spec)
2128 }
2129
2130 #[cfg(feature = "fixture")]
2131 pub fn new_cmd_cwd_fixture(
2132 config: NodeServerConfig,
2133 command: String,
2134 ) -> Result<Self, NodeServerError> {
2135 let mut spec = gate4agent_testkit::interactive_agent_spec();
2136 spec.id = AgentId::new("claude")
2137 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2138 spec.display_name = "Controlled cmd cwd fixture".to_owned();
2139 spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2140 spec.launch.program = command;
2141 spec.launch.fixed_args.clear();
2142 Self::new_fixture_with_spec(config, spec)
2143 }
2144
2145 #[cfg(feature = "fixture")]
2146 fn new_fixture_with_spec(
2147 config: NodeServerConfig,
2148 spec: gate4agent_types::AgentSpec,
2149 ) -> Result<Self, NodeServerError> {
2150 let catalog = AgentRegistry::new([spec])
2151 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2152 Self::new_with_registry(config, catalog)
2153 }
2154
2155 fn new_with_registry(
2156 config: NodeServerConfig,
2157 catalog: AgentRegistry,
2158 ) -> Result<Self, NodeServerError> {
2159 for profile in config.spawn_profiles.iter() {
2160 if catalog.get(&profile.provider).is_none() {
2161 return Err(NodeServerError::Registry(
2162 "spawn profile references an unavailable provider".to_owned(),
2163 ));
2164 }
2165 }
2166 let input_settle_timeout_ms = catalog
2167 .iter()
2168 .map(|spec| spec.readiness.timeout_ms)
2169 .max()
2170 .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2171 .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2172 .max(MUTATION_SETTLE_TIMEOUT_MS);
2173 let (provider_contracts, provider_adapter_contracts) =
2174 provider_contract_manifest(&catalog)?;
2175 let compatibility_support = node_compatibility_support_for_manifest(
2176 &provider_contracts,
2177 &provider_adapter_contracts,
2178 )?;
2179 validate_node_negotiated_handshake_capacity(&compatibility_support)
2180 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2181 let enabled_providers = provider_contracts
2182 .iter()
2183 .map(|contract| contract.provider.clone())
2184 .collect();
2185 #[cfg(feature = "fixture")]
2186 let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2187 #[cfg(not(feature = "fixture"))]
2188 let fixture_raw_pty_runtime = false;
2189 let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2190 let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2191 crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2192 })).expect("fixture provider catalog remains bounded");
2193 (None, statuses)
2194 } else {
2195 let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2196 let statuses = monitor.collect();
2197 (Some(monitor), statuses)
2198 };
2199 let state_path_lock =
2200 session_registry::StatePathLock::acquire(config.state_path.as_deref())
2201 .map_err(|error| {
2202 durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2203 })?;
2204 let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2205 Some(state_path) => {
2206 let delivery_root = delivery_store_root_for_state_path(state_path)
2207 .ok_or_else(|| {
2208 NodeServerError::DeliveryStore(
2209 "delivery store root could not be derived from state path".to_owned(),
2210 )
2211 })?;
2212 let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2213 let detail = match &error {
2214 DeliveryStoreError::Storage(io_error) => {
2215 format!("{error}: {io_error}")
2216 }
2217 _ => error.to_string(),
2218 };
2219 NodeServerError::DeliveryStore(detail)
2220 })?;
2221 (Some(store), bundles)
2222 }
2223 None => (None, Vec::new()),
2224 };
2225 let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2226 NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2227 })?;
2228 let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2229 Some(state_path) => {
2230 let context_pack_root = context_pack_store_root_for_state_path(state_path)
2231 .ok_or(NodeServerError::ContextPackStore)?;
2232 let (store, packs) = ContextPackStore::open(context_pack_root)
2233 .map_err(|_| NodeServerError::ContextPackStore)?;
2234 (Some(store), packs)
2235 }
2236 None => (None, Vec::new()),
2237 };
2238 let mut durable_context_catalog = ContextPackCatalog::default();
2239 for pack in durable_context_packs {
2240 durable_context_catalog
2241 .insert(pack)
2242 .map_err(|_| NodeServerError::ContextPackStore)?;
2243 }
2244 let session_environment_materializer = config
2245 .session_environment
2246 .as_ref()
2247 .map(|environment| {
2248 SessionEnvironmentMaterializer::new(
2249 environment.root.clone(),
2250 Arc::clone(&environment.resolver),
2251 )
2252 .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2253 })
2254 .transpose()?;
2255 let native_session_catalog = config.history.clone().map(|history| {
2256 Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2257 });
2258 let (handle, runtime) = match config.history.clone() {
2259 Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2260 None => NativeRuntime::new(catalog, config.runtime),
2261 };
2262 let native_launch_profile_control = runtime.native_launch_profile_control();
2263 let harness_mcp_registry = config
2264 .harness_mcp_helper
2265 .clone()
2266 .map(HarnessMcpProxyRegistry::new);
2267 let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2268 let incarnation_id = random_incarnation_id()
2269 .map_err(NodeServerError::IncarnationIdentity)?;
2270 let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2271 .map_err(durable_state_load_error)?;
2272 let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2273 let managed_worktree_tombstones =
2274 std::mem::take(&mut loaded.managed_worktree_tombstones);
2275 let materializations = std::mem::take(&mut loaded.materializations);
2276 let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2277 let (workspaces, records, persistence_warning) =
2278 merge_durable_state(&config.workspaces, loaded)?;
2279 let mut shared = NodeShared::new_with_incarnation(
2280 handle,
2281 config.access_token.clone(),
2282 config.node_id.clone(),
2283 incarnation_id,
2284 workspaces,
2285 enabled_providers,
2286 provider_runtime_statuses,
2287 provider_runtime_monitor,
2288 provider_contracts,
2289 provider_adapter_contracts,
2290 config.spawn_profiles.clone(),
2291 Some(native_launch_profile_control),
2292 native_session_catalog,
2293 session_environment_materializer,
2294 config.state_path.clone(),
2295 records,
2296 managed_worktrees,
2297 managed_worktree_tombstones,
2298 materializations,
2299 persistence_warning,
2300 input_settle_timeout_ms,
2301 );
2302 shared.harness_mcp_registry = harness_mcp_registry.clone();
2303 shared.session_record_retention = config.session_record_retention;
2304 shared.network_allowlist_catalog =
2305 RwLock::new(config.network_allowlist_catalog.clone());
2306 shared.bundle_catalog = RwLock::new(delivered_catalog);
2307 shared.delivery_store = Mutex::new(delivery_store);
2308 shared.context_catalog = RwLock::new(durable_context_catalog);
2309 shared.context_pack_store = Mutex::new(context_pack_store);
2310 shared.managed_spawn_replays = Mutex::new(
2311 managed_spawn_replays
2312 .into_iter()
2313 .map(|record| (record.idempotency_key.clone(), record))
2314 .collect(),
2315 );
2316 let shared = Arc::new(shared);
2317 if let Some(registry) = harness_mcp_registry {
2318 let weak = Arc::downgrade(&shared);
2319 registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2320 Some(shared) => shared.publish_transient(event),
2321 None => false,
2322 }));
2323 }
2324 if shared.persistence_error().is_none() {
2325 shared.persist_state().map_err(|error| {
2326 durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2327 })?;
2328 }
2329 Ok(Self {
2330 config,
2331 runtime,
2332 shared,
2333 events,
2334 state_path_lock,
2335 })
2336 }
2337
2338 pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2339 NodeShutdownHandle {
2340 shared: Arc::clone(&self.shared),
2341 }
2342 }
2343
2344 #[cfg(feature = "fixture")]
2345 pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2346 &self,
2347 ) -> SpawnManagedWorktreeV2FailureProbe {
2348 self.shared
2349 .fixture_spawn_managed_worktree_v2_failure_probe
2350 .clone()
2351 }
2352
2353 pub fn install_environment_profile(
2358 &mut self,
2359 profile: NodeEnvironmentProfile,
2360 ) -> Result<(), NodeServerError> {
2361 if !self.shared.enabled_providers.contains(profile.provider()) {
2362 return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2363 profile.provider().clone(),
2364 ));
2365 }
2366 let (binding, native_profiles, materialization) = profile.into_parts();
2367 if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2368 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2369 }
2370 let mut profiles = self
2371 .shared
2372 .environment_profiles
2373 .write()
2374 .unwrap_or_else(|poisoned| poisoned.into_inner());
2375 if profiles.contains_key(&binding.id) {
2376 return Err(NodeServerError::DuplicateEnvironmentProfile(
2377 binding.id.clone(),
2378 ));
2379 }
2380 if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2381 return Err(NodeServerError::EnvironmentProfileCapacity {
2382 max: MAX_NODE_ENVIRONMENT_PROFILES,
2383 });
2384 }
2385 for native_id in binding.native_profile_ids() {
2386 if profiles.values().any(|current| {
2387 current.native_profile_ids().any(|current_id| current_id == native_id)
2388 }) {
2389 return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2390 native_id.to_string(),
2391 ));
2392 }
2393 }
2394
2395 let mut installed = Vec::with_capacity(native_profiles.len());
2396 for native_profile in native_profiles {
2397 let native_id = native_profile.id().clone();
2398 if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2399 for installed_id in installed.into_iter().rev() {
2400 let _ = self.runtime.remove_native_launch_profile(&installed_id);
2401 }
2402 return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2403 }
2404 installed.push(native_id);
2405 }
2406 if let Some(materialization) = materialization {
2407 self.shared
2408 .environment_materialization_profiles
2409 .write()
2410 .unwrap_or_else(|poisoned| poisoned.into_inner())
2411 .insert(binding.id.clone(), materialization);
2412 }
2413 profiles.insert(binding.id.clone(), binding);
2414 Ok(())
2415 }
2416
2417 pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2419 if self.shared.session_environment_materializer.is_none() {
2420 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2421 }
2422 let mut catalog = self
2423 .shared
2424 .bundle_catalog
2425 .write()
2426 .unwrap_or_else(|poisoned| poisoned.into_inner());
2427 catalog
2428 .insert_idempotent(bundle)
2429 .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2430 Ok(())
2431 }
2432
2433 pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2434 let shutdown = self.shutdown_handle();
2435 let run = self.run();
2436 tokio::pin!(run);
2437 tokio::select! {
2438 result = &mut run => result,
2439 signal = wait_for_ctrl_signal() => {
2440 signal?;
2441 let shutdown_result = shutdown.request_shutdown().await;
2442 let run_result = run.await;
2443 shutdown_result.and(run_result)
2444 }
2445 }
2446 }
2447
2448 pub async fn run(self) -> Result<(), NodeServerError> {
2449 let NodeServer {
2450 config,
2451 mut runtime,
2452 shared,
2453 events,
2454 state_path_lock: _state_path_lock,
2455 } = self;
2456 shared.reconcile_materializations();
2457 shared.reconcile_managed_worktrees().await;
2458 shared.retire_unavailable_session_records().await;
2459 shared.reconcile_context_pack_exports().await;
2460 let endpoint = config.endpoint.clone();
2464 let call_home = config.call_home;
2465 let api_listen = config.api_listen;
2466 let bridge_listen = config.bridge_listen;
2467 let bridge_underlay = config.bridge_underlay;
2468 let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2469 .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2470 let accept_shared = Arc::clone(&shared);
2471 let api_shared = Arc::clone(&shared);
2472 let bridge_shared = Arc::clone(&shared);
2473 let shutdown_shared = Arc::clone(&shared);
2474 let shutdown_timeout = Duration::from_millis(
2475 config.runtime.provider_shutdown_timeout_ms.max(1),
2476 );
2477 let result = {
2478 let runtime_loop = drive_runtime_until_shutdown(
2479 &mut runtime,
2480 events,
2481 Arc::clone(&shared),
2482 shutdown_timeout,
2483 );
2484 let accept_loop = accept_connections(&endpoint, call_home, accept_shared);
2485 let api_loop = http_api::run(api_listen, api_shared);
2486 let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2487 tokio::pin!(runtime_loop);
2488 tokio::pin!(accept_loop);
2489 tokio::pin!(api_loop);
2490 tokio::pin!(bridge_loop);
2491 tokio::select! {
2492 runtime_result = &mut runtime_loop => {
2493 let shutdown_result = shutdown_shared
2494 .begin_shutdown()
2495 .await
2496 .map_err(NodeServerError::ShutdownDispatch);
2497 let (accept_result, api_result, bridge_result) =
2498 tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2499 match runtime_result {
2500 Err(error) => Err(error),
2501 Ok(()) => shutdown_result
2502 .and(accept_result)
2503 .and(api_result.map_err(NodeServerError::HttpApi))
2504 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2505 }
2506 }
2507 accept_result = &mut accept_loop => {
2508 let shutdown_result = shutdown_shared
2509 .begin_shutdown()
2510 .await
2511 .map_err(NodeServerError::ShutdownDispatch);
2512 let (runtime_result, api_result, bridge_result) =
2513 tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2514 match accept_result {
2515 Err(error) => Err(error),
2516 Ok(()) => shutdown_result
2517 .and(runtime_result)
2518 .and(api_result.map_err(NodeServerError::HttpApi))
2519 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2520 }
2521 }
2522 api_result = &mut api_loop => {
2523 let shutdown_result = shutdown_shared
2524 .begin_shutdown()
2525 .await
2526 .map_err(NodeServerError::ShutdownDispatch);
2527 let (runtime_result, accept_result, bridge_result) =
2528 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2529 match api_result {
2530 Err(error) => Err(NodeServerError::HttpApi(error)),
2531 Ok(()) => shutdown_result
2532 .and(runtime_result)
2533 .and(accept_result)
2534 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2535 }
2536 }
2537 bridge_result = &mut bridge_loop => {
2538 let shutdown_result = shutdown_shared
2539 .begin_shutdown()
2540 .await
2541 .map_err(NodeServerError::ShutdownDispatch);
2542 let (runtime_result, accept_result, api_result) =
2543 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2544 match bridge_result {
2545 Err(error) => Err(NodeServerError::BridgeApi(error)),
2546 Ok(()) => shutdown_result
2547 .and(runtime_result)
2548 .and(accept_result)
2549 .and(api_result.map_err(NodeServerError::HttpApi)),
2550 }
2551 }
2552 }
2553 };
2554 if let Ok(mut guard) = shared.delivery_store.lock() {
2561 *guard = None;
2562 }
2563 if let Ok(mut guard) = shared.context_pack_store.lock() {
2564 *guard = None;
2565 }
2566 result
2567 }
2568}
2569
2570async fn drive_runtime_until_shutdown(
2571 runtime: &mut NativeRuntime,
2572 events: EventSubscription,
2573 shared: Arc<NodeShared>,
2574 shutdown_timeout: Duration,
2575) -> Result<(), NodeServerError> {
2576 let _ = shared
2580 .shell_efficiency_profile
2581 .set(runtime.shell_efficiency_profile());
2582 let mut shutdown_started = None;
2583 let mut last_retention_sweep = Instant::now();
2584 loop {
2585 let runtime_tick_start = Instant::now();
2592 let tick_result = runtime.tick().await;
2593 let runtime_tick_elapsed = runtime_tick_start.elapsed();
2594
2595 let event_drain_start = Instant::now();
2596 for outcome in &tick_result.command_outcomes {
2604 if let Err(error) = &outcome.result {
2605 shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2606 tracing::warn!(
2607 command_id = outcome.command_id.0,
2608 cause = %error,
2609 "control command rejected"
2610 );
2611 shared.record_rejected_command(
2612 outcome.command_id,
2613 error.to_string(),
2614 error.is_unsupported_transport(),
2615 );
2616 }
2617 }
2618 let mut events_drained: u64 = 0;
2619 while let Ok(event) = events.try_recv() {
2620 events_drained += 1;
2621 let clean_exit = matches!(
2622 event.event,
2623 ControlEventKind::Exited { exit_code: Some(0), forced: false },
2624 );
2625 let instance_id = event.instance_id;
2626 let generation = event.generation;
2627 if clean_exit {
2639 if let Some((record_id, session)) =
2640 shared.managed_record_export_target_for_instance(instance_id, generation)
2641 {
2642 let export_shared = Arc::clone(&shared);
2643 tokio::spawn(async move {
2644 export_shared
2645 .reconcile_context_pack_export_for_record(&record_id, session)
2646 .await;
2647 });
2648 }
2649 }
2650 shared.publish_control(event);
2651 }
2652 let event_drain_elapsed = event_drain_start.elapsed();
2653
2654 let publish_terminal_frames_start = Instant::now();
2655 shared.publish_terminal_frames();
2656 let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2657
2658 shared
2664 .native_session_gauge
2665 .store(runtime.active_native_sessions(), Ordering::Relaxed);
2666 *shared
2667 .runtime_tick_profile
2668 .lock()
2669 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2670
2671 let remainder_start = Instant::now();
2672 if shared.shutdown.load(Ordering::Acquire) {
2673 let started = *shutdown_started.get_or_insert_with(Instant::now);
2674 let snapshot = shared.handle.snapshot();
2675 let sessions_settled = snapshot.sessions.iter().all(|session| {
2676 !matches!(
2677 session.status,
2678 gate4agent_types::SessionStatus::Starting
2679 | gate4agent_types::SessionStatus::Running
2680 | gate4agent_types::SessionStatus::Stopping
2681 )
2682 });
2683 if runtime.active_native_sessions() == 0 && sessions_settled {
2684 return Ok(());
2685 }
2686 if started.elapsed() >= shutdown_timeout {
2687 return Err(NodeServerError::ShutdownTimedOut {
2688 active_native_sessions: runtime.active_native_sessions(),
2689 });
2690 }
2691 }
2692 let remainder_elapsed = remainder_start.elapsed();
2693
2694 {
2695 let mut profile = shared
2696 .drive_loop_profile
2697 .lock()
2698 .unwrap_or_else(|poisoned| poisoned.into_inner());
2699 profile.record_runtime_tick(runtime_tick_elapsed);
2700 profile.record_event_drain(event_drain_elapsed);
2701 profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2702 profile.record_remainder(remainder_elapsed);
2703 profile.note_iteration(Instant::now());
2704 }
2705
2706 shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2718 if tick_result.observations_applied == 0 && events_drained == 0 {
2719 shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2720 }
2721
2722 if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2727 shared.retire_unavailable_session_records().await;
2728 last_retention_sweep = Instant::now();
2729 }
2730
2731 sleep(Duration::from_millis(10)).await;
2732 }
2733}
2734
2735#[derive(Clone)]
2736pub struct NodeShutdownHandle {
2737 shared: Arc<NodeShared>,
2738}
2739
2740impl NodeShutdownHandle {
2741 pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2742 self.shared
2743 .begin_shutdown()
2744 .await
2745 .map_err(NodeServerError::ShutdownDispatch)
2746 }
2747}
2748
2749async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2750 #[cfg(windows)]
2751 {
2752 let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2753 let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2754 return tokio::select! {
2755 signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2756 signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2757 };
2758 }
2759 #[cfg(unix)]
2760 {
2761 let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2762 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2763 tokio::select! {
2764 signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2765 signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2766 }
2767 }
2768}
2769
2770fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2783 let specs = builtin_registry()
2784 .iter()
2785 .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2786 .cloned()
2787 .collect::<Vec<_>>();
2788 AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2789}
2790
2791fn provider_contract_manifest(
2792 catalog: &AgentRegistry,
2793) -> Result<
2794 (
2795 Vec<ProviderContractSupport>,
2796 Vec<ProviderAdapterContractSupport>,
2797 ),
2798 NodeServerError,
2799> {
2800 let mut provider_contracts = Vec::new();
2801 let mut provider_adapter_contracts = Vec::new();
2802 for spec in catalog.iter() {
2803 let provider = spec.id.clone();
2804 let revision = ProviderContractRevision::new(spec.revision.clone())
2805 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2806 provider_contracts.push(ProviderContractSupport {
2807 provider: provider.clone(),
2808 revision,
2809 });
2810 for (family, binding) in declared_adapter_bindings(spec)? {
2811 let revision = AdapterContractRevision::new(binding.revision.clone())
2812 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2813 provider_adapter_contracts.push(ProviderAdapterContractSupport {
2814 provider: provider.clone(),
2815 family,
2816 adapter_id: binding.id.clone(),
2817 revision,
2818 });
2819 }
2820 }
2821 validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2822 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2823 Ok((provider_contracts, provider_adapter_contracts))
2824}
2825
2826fn declared_adapter_bindings(
2827 spec: &AgentSpec,
2828) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2829 let mut bindings = Vec::new();
2830 push_declared_binding(
2831 &mut bindings,
2832 AdapterFamily::PtySemantic,
2833 spec.capabilities.transports.pty_adapter.as_ref(),
2834 )?;
2835 push_declared_binding(
2836 &mut bindings,
2837 AdapterFamily::Pipe,
2838 spec.capabilities.adapters.pty_sidecar.as_ref(),
2839 )?;
2840 let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2841 let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2842 gate4agent_types::PipeProtocol::SemanticNdjson
2843 | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2844 gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2845 });
2846 push_declared_binding(
2847 &mut bindings,
2848 AdapterFamily::Pipe,
2849 pipe_transport
2850 .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2851 .map(|transport| &transport.adapter),
2852 )?;
2853 push_declared_binding(
2854 &mut bindings,
2855 AdapterFamily::Acp,
2856 spec.capabilities
2857 .transports
2858 .acp
2859 .as_ref()
2860 .map(|transport| &transport.adapter),
2861 )?;
2862 push_declared_binding(
2863 &mut bindings,
2864 AdapterFamily::Hook,
2865 spec.capabilities.adapters.hook.as_ref(),
2866 )?;
2867 push_declared_binding(
2868 &mut bindings,
2869 AdapterFamily::ManagedHook,
2870 spec.capabilities.adapters.managed_hook.as_ref(),
2871 )?;
2872 push_declared_binding(
2873 &mut bindings,
2874 AdapterFamily::OneShot,
2875 pipe_transport
2876 .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
2877 .map(|transport| &transport.adapter),
2878 )?;
2879 push_declared_binding(
2880 &mut bindings,
2881 AdapterFamily::OneShot,
2882 spec.capabilities.adapters.one_shot.as_ref(),
2883 )?;
2884 push_declared_binding(
2885 &mut bindings,
2886 AdapterFamily::History,
2887 spec.capabilities.adapters.history.as_ref(),
2888 )?;
2889 push_declared_binding(
2890 &mut bindings,
2891 AdapterFamily::Resume,
2892 spec.capabilities.adapters.resume.as_ref(),
2893 )?;
2894 push_declared_binding(
2895 &mut bindings,
2896 AdapterFamily::SessionOptions,
2897 spec.capabilities.adapters.session_options.as_ref(),
2898 )?;
2899 push_declared_binding(
2900 &mut bindings,
2901 AdapterFamily::CapabilityProbe,
2902 spec.capabilities.adapters.capability_probe.as_ref(),
2903 )?;
2904 Ok(bindings)
2905}
2906
2907fn push_declared_binding<'a>(
2908 bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
2909 family: AdapterFamily,
2910 binding: Option<&'a AdapterBinding>,
2911) -> Result<(), NodeServerError> {
2912 let Some(binding) = binding else {
2913 return Ok(());
2914 };
2915 if let Some((_, existing)) = bindings
2916 .iter()
2917 .find(|(existing_family, _)| *existing_family == family)
2918 {
2919 if *existing == binding {
2920 return Ok(());
2921 }
2922 return Err(NodeServerError::ProviderContractManifest(format!(
2923 "provider declares conflicting {family:?} adapter bindings"
2924 )));
2925 }
2926 bindings.push((family, binding));
2927 Ok(())
2928}
2929
2930fn merge_durable_state(
2931 configured: &[WorkspaceConfig],
2932 mut loaded: LoadedNodeState,
2933) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
2934 let mut workspaces = configured
2935 .iter()
2936 .cloned()
2937 .map(|workspace| (workspace.workspace_id.clone(), workspace))
2938 .collect::<BTreeMap<_, _>>();
2939 let mut roots = workspaces
2940 .values()
2941 .map(|workspace| {
2942 (
2943 platform::root_identity(&workspace.canonical_root),
2944 workspace.workspace_id.clone(),
2945 )
2946 })
2947 .collect::<BTreeMap<_, _>>();
2948 let mut warning = loaded
2949 .warning
2950 .take()
2951 .map(|message| session_registry::sanitized_persistence_summary(&message));
2952
2953 for (workspace_id, canonical_root) in loaded.workspaces {
2954 if let Some(existing) = workspaces.get(&workspace_id) {
2955 if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
2956 return Err(NodeServerError::DurableState(io::Error::new(
2957 io::ErrorKind::InvalidData,
2958 DURABLE_STATE_CONFLICT_ERROR,
2959 )));
2960 }
2961 continue;
2962 }
2963 if roots.get(&platform::root_identity(&canonical_root)).is_some() {
2964 return Err(NodeServerError::DurableState(io::Error::new(
2965 io::ErrorKind::InvalidData,
2966 DURABLE_STATE_CONFLICT_ERROR,
2967 )));
2968 }
2969 if !Path::new(&canonical_root).is_dir() {
2970 warning.get_or_insert_with(|| {
2971 session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
2972 });
2973 continue;
2974 }
2975 let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
2976 NodeServerError::DurableState(io::Error::new(
2977 io::ErrorKind::InvalidData,
2978 DURABLE_STATE_CONFLICT_ERROR,
2979 ))
2980 })?;
2981 roots.insert(
2982 platform::root_identity(&workspace.canonical_root),
2983 workspace.workspace_id.clone(),
2984 );
2985 workspaces.insert(workspace_id, workspace);
2986 }
2987
2988 for record in &mut loaded.records {
2989 match workspaces.get(&record.workspace_id) {
2990 Some(workspace)
2991 if platform::roots_equal(
2992 &workspace.canonical_root,
2993 windows_path_text(&record.canonical_root),
2994 ) => {}
2995 Some(_) => {
2996 return Err(NodeServerError::DurableState(io::Error::new(
2997 io::ErrorKind::InvalidData,
2998 DURABLE_STATE_CONFLICT_ERROR,
2999 )));
3000 }
3001 None => {
3002 record.state = ManagedSessionState::Unavailable;
3003 record.active_session = None;
3004 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3005 }
3006 }
3007 }
3008 Ok((workspaces.into_values().collect(), loaded.records, warning))
3009}
3010
3011struct RejectedCommandReason {
3014 message: String,
3015 unsupported_transport: bool,
3022 expires_at: Instant,
3023}
3024
3025struct NodeShared {
3026 handle: Gate4AgentHandle,
3027 access_token: String,
3028 node_id: NodeId,
3029 incarnation_id: NodeIncarnationId,
3030 started_at_unix_ms: u64,
3031 workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3032 worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3033 managed_worktree_profiles:
3034 BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3035 managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3036 enabled_providers: Vec<AgentId>,
3037 provider_runtime_statuses: ProviderRuntimeStatuses,
3038 provider_runtime_status_updates:
3039 RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3040 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3041 provider_contracts: Vec<ProviderContractSupport>,
3042 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3043 spawn_profiles: SpawnProfileRegistry,
3044 environment_profiles:
3045 RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3046 environment_materialization_profiles:
3047 RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3048 network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3056 #[cfg(feature = "dig2-station-probe")]
3060 browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3061 bundle_catalog: RwLock<BundleCatalog>,
3062 delivery_store: Mutex<Option<DeliveryStore>>,
3063 context_pack_store: Mutex<Option<ContextPackStore>>,
3064 context_catalog: RwLock<ContextPackCatalog>,
3065 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3066 harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3067 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3068 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3069 materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3070 spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3071 managed_spawn_replays:
3072 Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3073 controller: Mutex<Option<ControllerLease>>,
3074 history: Mutex<NodeEventHistory>,
3075 event_tx: broadcast::Sender<NodeEventEnvelope>,
3076 terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3077 agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3085 harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3094 terminal_frame_watermarks:
3095 Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3096 next_connection_id: AtomicU64,
3097 next_instance_id: AtomicU64,
3098 next_command_id: AtomicU64,
3099 shutdown: AtomicBool,
3100 shutdown_notify: Notify,
3101 preauth_slots: Arc<Semaphore>,
3102 authenticated_slots: Arc<Semaphore>,
3103 inspection_slots: Arc<Semaphore>,
3104 mutation_gate: AsyncMutex<()>,
3105 session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3106 session_records: Mutex<SessionRecords>,
3107 state_path: Option<PathBuf>,
3108 persistence_error: RwLock<Option<String>>,
3109 state_transaction: Mutex<()>,
3110 input_settle_timeout_ms: u64,
3111 drive_loop_profile: Mutex<DriveLoopProfiler>,
3115 runtime_tick_profile: Mutex<TickProfileSnapshot>,
3121 shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3133 native_session_gauge: AtomicUsize,
3140 connection_loop_iterations: AtomicU64,
3151 connection_events_sent: AtomicU64,
3152 rejected_commands_total: AtomicU64,
3161 rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3181 drive_loop_iterations_total: AtomicU64,
3182 drive_loop_iterations_idle: AtomicU64,
3183 session_record_retention: SessionRecordRetentionConfig,
3187 retired_records_total: AtomicU64,
3193 #[cfg(feature = "fixture")]
3194 fixture_semantic_hook_policy: bool,
3195 #[cfg(feature = "fixture")]
3196 fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3197}
3198
3199impl Drop for NodeShared {
3200 fn drop(&mut self) {
3201 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3202 let instances = registry.shutdown();
3203 if let Some(control) = self.native_launch_profile_control.as_ref() {
3204 for instance_id in instances {
3205 control.clear_native_mcp_server_launch_overlay(instance_id);
3206 }
3207 }
3208 }
3209 }
3210}
3211
3212#[derive(Clone, Debug, Eq, PartialEq)]
3213struct SessionBinding {
3214 workspace_id: WorkspaceId,
3215 generation: SessionGeneration,
3216 runtime_policy: ProviderRuntimePolicy,
3217 pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3218 record_id: Option<SessionRecordId>,
3219 managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3220 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3221 bundle: Option<ResolvedBundleReceipt>,
3222 context: Option<ResolvedContextPackReceipt>,
3223 materialization_id: Option<MaterializationId>,
3224}
3225
3226#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3227enum SpawnRecordPolicy {
3228 ProviderIdentityOnly,
3229 Always,
3230}
3231
3232struct NativeEnvironmentSelectionGuard {
3233 control: Option<NativeLaunchProfileControl>,
3234 instance_id: AgentInstanceId,
3235}
3236
3237struct NativeInstanceOverlayGuard {
3238 control: Option<NativeLaunchProfileControl>,
3239 instance_id: AgentInstanceId,
3240}
3241
3242struct NativeHarnessMcpOverlayGuard {
3243 control: NativeLaunchProfileControl,
3244 instance_id: AgentInstanceId,
3245}
3246
3247impl NativeHarnessMcpOverlayGuard {
3248 fn retain(self) { std::mem::forget(self); }
3249}
3250
3251impl Drop for NativeHarnessMcpOverlayGuard {
3252 fn drop(&mut self) {
3253 self.control
3254 .clear_native_mcp_server_launch_overlay(self.instance_id);
3255 }
3256}
3257
3258impl NativeInstanceOverlayGuard {
3259 fn retain(mut self) {
3260 self.control = None;
3261 }
3262}
3263
3264impl Drop for NativeInstanceOverlayGuard {
3265 fn drop(&mut self) {
3266 if let Some(control) = self.control.as_ref() {
3267 control.clear_native_instance_launch_overlay(self.instance_id);
3268 }
3269 }
3270}
3271
3272enum PreparedNativeLaunchOverlay {
3273 Environment(NativeLaunchEnvironmentOverlay),
3274 Instance(NativeInstanceLaunchOverlay),
3275}
3276
3277impl NativeEnvironmentSelectionGuard {
3278 fn retain(mut self) {
3279 self.control = None;
3280 }
3281}
3282
3283impl Drop for NativeEnvironmentSelectionGuard {
3284 fn drop(&mut self) {
3285 if let Some(control) = self.control.as_ref() {
3286 control.clear_native_launch_profile_selection(self.instance_id);
3287 }
3288 }
3289}
3290
3291struct SessionMaterializationGuard<'a> {
3292 shared: &'a NodeShared,
3293 id: Option<MaterializationId>,
3294 provider_home: Option<PathBuf>,
3297}
3298
3299impl SessionMaterializationGuard<'_> {
3300 fn id(&self) -> Option<&MaterializationId> {
3301 self.id.as_ref()
3302 }
3303
3304 fn provider_home(&self) -> Option<&Path> {
3305 self.provider_home.as_deref()
3306 }
3307
3308 fn retain(mut self) {
3309 self.id = None;
3310 }
3311}
3312
3313impl Drop for SessionMaterializationGuard<'_> {
3314 fn drop(&mut self) {
3315 if let Some(id) = self.id.take() {
3316 let _ = self.shared.cleanup_materialization(&id);
3317 }
3318 }
3319}
3320
3321struct SessionRecords {
3322 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3323}
3324
3325struct SpawnIdempotencyCache {
3326 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3327}
3328
3329struct SpawnIdempotencyEntry {
3330 value: SpawnIdempotencyValue,
3331 expires_at: Instant,
3332}
3333
3334enum SpawnIdempotencyValue {
3335 Standard {
3336 spec: SpawnSpec,
3337 result: Result<ResolvedSpawnReceipt, NodeFailure>,
3338 },
3339 Managed {
3340 request: ManagedWorktreeSpawnRequest,
3341 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3342 },
3343 HarnessMcp,
3344}
3345
3346enum DurableManagedSpawnReplayDecision {
3347 Reserved,
3348 PendingLinked,
3349 Committed(ManagedWorktreeSpawnReceipt),
3350}
3351
3352impl SpawnIdempotencyValue {
3353 fn references_context(&self, context_id: &SpawnContextId) -> bool {
3354 match self {
3355 Self::Standard { result, .. } => result
3356 .as_ref()
3357 .ok()
3358 .and_then(|receipt| receipt.context.as_ref()),
3359 Self::Managed { result, .. } => result
3360 .as_ref()
3361 .ok()
3362 .and_then(|receipt| receipt.spawn.context.as_ref()),
3363 Self::HarnessMcp => None,
3364 }
3365 .is_some_and(|receipt| &receipt.id == context_id)
3366 }
3367}
3368
3369#[derive(Clone, Copy)]
3370struct ControllerLease {
3371 connection_id: u64,
3372 expires_at: Instant,
3373}
3374
3375struct NodeEventHistory {
3376 last_sequence: u64,
3377 replay_floor_sequence: u64,
3378 events: VecDeque<NodeEventEnvelope>,
3379 record_providers: BTreeMap<SessionRecordId, AgentId>,
3380 removed_record_providers: BTreeMap<u64, AgentId>,
3381}
3382
3383impl NodeEventHistory {
3384 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
3385 Self {
3386 last_sequence: 0,
3387 replay_floor_sequence: 1,
3388 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
3389 record_providers,
3390 removed_record_providers: BTreeMap::new(),
3391 }
3392 }
3393}
3394
3395impl NodeShared {
3396 fn new_with_incarnation(
3397 handle: Gate4AgentHandle,
3398 access_token: String,
3399 node_id: NodeId,
3400 incarnation_id: NodeIncarnationId,
3401 workspaces: Vec<WorkspaceConfig>,
3402 enabled_providers: Vec<AgentId>,
3403 provider_runtime_statuses: ProviderRuntimeStatuses,
3404 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3405 provider_contracts: Vec<ProviderContractSupport>,
3406 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3407 spawn_profiles: SpawnProfileRegistry,
3408 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3409 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3410 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3411 state_path: Option<PathBuf>,
3412 records: Vec<ManagedSessionRecord>,
3413 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
3414 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
3415 materializations: Vec<MaterializationOwnershipRecord>,
3416 persistence_warning: Option<String>,
3417 input_settle_timeout_ms: u64,
3418 ) -> Self {
3419 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
3420 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
3421 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
3422 let (harness_mcp_proxy_event_tx, _) =
3423 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
3424 let record_providers = records
3425 .iter()
3426 .map(|record| (record.record_id.clone(), record.provider.clone()))
3427 .collect();
3428 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
3429 .iter()
3430 .map(|workspace| {
3431 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
3432 })
3433 .collect();
3434 for lease in &managed_worktrees {
3435 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
3436 }
3437 let managed_worktree_profiles = workspaces.iter()
3438 .map(|workspace| (
3439 workspace.workspace_id.clone(),
3440 workspace.managed_worktree_profiles.clone(),
3441 ))
3442 .collect();
3443 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
3444 managed_worktrees,
3445 managed_worktree_tombstones,
3446 ).expect("durable managed worktree registry was validated while loading");
3447 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
3448 managed_worktrees.reattach_record_holders(
3449 &managed_worktree_record_holders(&records),
3450 unix_time_ms(),
3451 );
3452 Self {
3453 handle,
3454 access_token,
3455 node_id,
3456 incarnation_id,
3457 started_at_unix_ms: std::time::SystemTime::now()
3458 .duration_since(std::time::UNIX_EPOCH)
3459 .unwrap_or_default()
3460 .as_millis()
3461 .min(u64::MAX as u128) as u64,
3462 workspaces: RwLock::new(
3463 workspaces
3464 .into_iter()
3465 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
3466 .collect(),
3467 ),
3468 worktree_service_modes,
3469 managed_worktree_profiles,
3470 managed_worktrees: Mutex::new(managed_worktrees),
3471 enabled_providers,
3472 provider_runtime_statuses,
3473 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
3474 provider_runtime_monitor,
3475 provider_contracts,
3476 provider_adapter_contracts,
3477 spawn_profiles,
3478 environment_profiles: RwLock::new(BTreeMap::new()),
3479 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
3480 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
3481 #[cfg(feature = "dig2-station-probe")]
3482 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
3483 bundle_catalog: RwLock::new(BundleCatalog::default()),
3484 delivery_store: Mutex::new(None),
3485 context_pack_store: Mutex::new(None),
3486 context_catalog: RwLock::new(ContextPackCatalog::default()),
3487 native_launch_profile_control,
3488 harness_mcp_registry: None,
3489 native_session_catalog,
3490 session_environment_materializer,
3491 materializations: Mutex::new(
3492 materializations
3493 .into_iter()
3494 .map(|record| (record.id().clone(), record))
3495 .collect(),
3496 ),
3497 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
3498 entries: BTreeMap::new(),
3499 }),
3500 managed_spawn_replays: Mutex::new(BTreeMap::new()),
3501 controller: Mutex::new(None),
3502 history: Mutex::new(NodeEventHistory::new(record_providers)),
3503 event_tx,
3504 terminal_event_tx,
3505 agent_stream_event_tx,
3506 harness_mcp_proxy_event_tx,
3507 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
3508 next_connection_id: AtomicU64::new(1),
3509 next_instance_id: AtomicU64::new(1),
3510 next_command_id: AtomicU64::new(1),
3511 shutdown: AtomicBool::new(false),
3512 shutdown_notify: Notify::new(),
3513 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
3514 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
3515 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
3516 mutation_gate: AsyncMutex::new(()),
3517 session_bindings: Mutex::new(BTreeMap::new()),
3518 session_records: Mutex::new(SessionRecords {
3519 records: records
3520 .into_iter()
3521 .map(|record| (record.record_id.clone(), record))
3522 .collect(),
3523 }),
3524 state_path,
3525 persistence_error: RwLock::new(
3526 persistence_warning
3527 .map(|message| session_registry::sanitized_persistence_summary(&message)),
3528 ),
3529 state_transaction: Mutex::new(()),
3530 input_settle_timeout_ms,
3531 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
3532 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
3533 shell_efficiency_profile: OnceLock::new(),
3534 native_session_gauge: AtomicUsize::new(0),
3535 connection_loop_iterations: AtomicU64::new(0),
3536 connection_events_sent: AtomicU64::new(0),
3537 rejected_commands_total: AtomicU64::new(0),
3538 rejected_command_reasons: Mutex::new(BTreeMap::new()),
3539 drive_loop_iterations_total: AtomicU64::new(0),
3540 drive_loop_iterations_idle: AtomicU64::new(0),
3541 session_record_retention: SessionRecordRetentionConfig::default(),
3542 retired_records_total: AtomicU64::new(0),
3543 #[cfg(feature = "fixture")]
3544 fixture_semantic_hook_policy: false,
3545 #[cfg(feature = "fixture")]
3546 fixture_spawn_managed_worktree_v2_failure_probe:
3547 SpawnManagedWorktreeV2FailureProbe::default(),
3548 }
3549 }
3550
3551 #[cfg(test)]
3552 fn new(
3553 handle: Gate4AgentHandle,
3554 access_token: String,
3555 node_id: NodeId,
3556 workspaces: Vec<WorkspaceConfig>,
3557 enabled_providers: Vec<AgentId>,
3558 ) -> Self {
3559 Self::new_with_incarnation(
3560 handle,
3561 access_token,
3562 node_id,
3563 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
3564 workspaces,
3565 enabled_providers,
3566 ProviderRuntimeStatuses::default(),
3567 None,
3568 Vec::new(),
3569 Vec::new(),
3570 SpawnProfileRegistry::default(),
3571 None,
3572 None,
3573 None,
3574 None,
3575 Vec::new(),
3576 Vec::new(),
3577 Vec::new(),
3578 Vec::new(),
3579 None,
3580 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
3581 )
3582 }
3583
3584 fn persistence_error(&self) -> Option<String> {
3585 self.persistence_error
3586 .read()
3587 .unwrap_or_else(|poisoned| poisoned.into_inner())
3588 .clone()
3589 }
3590
3591 async fn admit_provider_runtime(
3592 &self,
3593 provider: &AgentId,
3594 requirement: ProviderRuntimeRequirement,
3595 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
3596 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
3597 let pipe_transport = monitor.supports_pipe_transport(provider);
3598 let acp_transport = monitor.supports_acp_transport(provider);
3599 let provider = provider.clone();
3600 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
3601 let (status, admission) = timeout(
3602 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
3603 refresh,
3604 )
3605 .await
3606 .map_err(|_| failure(
3607 NodeFailureCode::BackendBusy,
3608 "provider runtime probe exceeded its bounded deadline",
3609 ))?
3610 .map_err(|_| failure(
3611 NodeFailureCode::BackendOperationFailed,
3612 "provider runtime probe task failed",
3613 ))?;
3614 if let Some(status) = status {
3615 self
3616 .provider_runtime_status_updates
3617 .write()
3618 .unwrap_or_else(|poisoned| poisoned.into_inner())
3619 .insert(status.provider().clone(), status);
3620 }
3621 admission.and_then(|policy| {
3622 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
3623 })
3624 } else {
3625 crate::provider_runtime::admit_status(
3631 &self.provider_runtime_statuses,
3632 provider,
3633 false,
3634 false,
3635 requirement,
3636 )
3637 };
3638 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
3639 admission.map_err(|error| match error {
3640 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
3641 NodeFailureCode::BackendOperationFailed,
3642 "provider launcher is unavailable",
3643 ),
3644 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
3645 NodeFailureCode::UnsupportedCapability,
3646 "provider semantic capability is not verified",
3647 ),
3648 ProviderRuntimeAdmissionError::ProbeBusy => failure(
3649 NodeFailureCode::BackendBusy,
3650 "provider runtime probe is already in progress",
3651 ),
3652 })
3653 }
3654
3655 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
3661 match self.provider_runtime_monitor.as_deref() {
3662 Some(monitor) => monitor.supports_pipe_transport(provider),
3663 None => false,
3664 }
3665 }
3666
3667 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
3673 match self.provider_runtime_monitor.as_deref() {
3674 Some(monitor) => monitor.supports_acp_transport(provider),
3675 None => false,
3676 }
3677 }
3678
3679 fn admit_declared_hook_semantics(
3695 &self,
3696 provider: &AgentId,
3697 policy: ProviderRuntimePolicy,
3698 ) -> ProviderRuntimePolicy {
3699 if policy.hook_semantics || !policy.raw_pty_lifecycle {
3700 return policy;
3701 }
3702 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
3703 contract.provider == *provider && contract.family == AdapterFamily::Hook
3704 });
3705 if !declares_hook {
3706 return policy;
3707 }
3708 ProviderRuntimePolicy::new(
3709 policy.raw_pty_lifecycle,
3710 policy.semantic_readiness,
3711 policy.structured_prompt,
3712 policy.provider_session_identity,
3713 policy.semantic_resume,
3714 true,
3715 )
3716 .expect("granting hook semantics over a raw-PTY policy is internally valid")
3717 }
3718
3719 fn resolve_network_allowlist_overlay_args(
3734 &self,
3735 provider: &AgentId,
3736 mode: SessionMode,
3737 approval_level: ApprovalLevel,
3738 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
3739 provider_home: Option<&Path>,
3740 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
3741 let Some(allowlist_id) = network_allowlist else {
3742 return Ok(Vec::new());
3743 };
3744 let catalog = self
3745 .network_allowlist_catalog
3746 .read()
3747 .unwrap_or_else(|poisoned| poisoned.into_inner());
3748 let Some(entry) = catalog.get(allowlist_id) else {
3749 return Err(failure(
3750 NodeFailureCode::UnknownNetworkAllowlist,
3751 "spawn network allowlist is unavailable on this node",
3752 ));
3753 };
3754
3755 if provider.as_str() == "claude" && !entry.permits.is_empty() {
3757 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
3758 |message| {
3759 failure(
3760 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3761 &message,
3762 )
3763 },
3764 )?;
3765 if !matches!(mode, SessionMode::Pty) {
3766 return Err(failure(
3767 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3768 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
3769 ));
3770 }
3771 let Some(home) = provider_home else {
3772 return Err(failure(
3773 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3774 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
3775 ));
3776 };
3777 let domains =
3778 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
3779 if domains.is_empty() {
3780 return Err(failure(
3781 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3782 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
3783 ));
3784 }
3785 let settings_path = home.join(
3786 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
3787 );
3788 let json =
3789 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
3790 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
3791 |_| {
3792 failure(
3793 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3794 "Claude station network settings sidecar could not be written under ProviderHome",
3795 )
3796 },
3797 )?;
3798 let overlay =
3799 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
3800 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
3801 }
3802
3803 let Some(native) = entry.provider_native.as_ref() else {
3804 return Ok(Vec::new());
3805 };
3806 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
3807 provider.as_str(),
3808 approval_level,
3809 native,
3810 )
3811 .map_err(|message| {
3812 failure(
3813 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3814 &message,
3815 )
3816 })?;
3817 if overlay.is_empty() {
3818 return Ok(Vec::new());
3819 }
3820 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
3821 return Err(failure(
3822 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3823 "Codex network_access -c overlay requires PTY or ACP transport",
3824 ));
3825 }
3826 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
3827 }
3828
3829 fn resolve_codex_network_access_overlay_args(
3831 &self,
3832 provider: &AgentId,
3833 mode: SessionMode,
3834 approval_level: ApprovalLevel,
3835 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
3836 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
3837 self.resolve_network_allowlist_overlay_args(
3838 provider,
3839 mode,
3840 approval_level,
3841 network_allowlist,
3842 None,
3843 )
3844 }
3845
3846 fn resolve_environment_profile(
3847 &self,
3848 resolved: &ResolvedSpawnSpec,
3849 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
3850 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
3856 let catalog = self
3857 .network_allowlist_catalog
3858 .read()
3859 .unwrap_or_else(|poisoned| poisoned.into_inner());
3860 let Some(entry) = catalog.get(allowlist_id) else {
3861 return Err(failure(
3862 NodeFailureCode::UnknownNetworkAllowlist,
3863 "spawn network allowlist is unavailable on this node",
3864 ));
3865 };
3866 if let Some(native) = entry.provider_native.as_ref() {
3869 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
3870 resolved.provider.as_str(),
3871 resolved.approval_level,
3872 native,
3873 ) {
3874 return Err(failure(
3875 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3876 &message,
3877 ));
3878 }
3879 }
3880 }
3881 #[cfg(feature = "dig2-station-probe")]
3890 if resolved.browser_profile_id.is_some() {
3891 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
3892 match probe_station_reachable() {
3893 Ok(()) => {}
3894 Err(StationProbeError::UnsupportedPlatform)
3895 | Err(StationProbeError::InvalidPipeSuffix) => {
3896 return Err(failure(
3897 NodeFailureCode::BrowserStationProbeUnavailable,
3898 "dig2browser station probe unavailable on this platform",
3899 ));
3900 }
3901 Err(StationProbeError::Unreachable) => {
3902 return Err(failure(
3903 NodeFailureCode::BrowserStationUnreachable,
3904 "dig2browser station named-pipe is unreachable",
3905 ));
3906 }
3907 }
3908 }
3909 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
3910 return Ok(None);
3911 };
3912 let profiles = self
3913 .environment_profiles
3914 .read()
3915 .unwrap_or_else(|poisoned| poisoned.into_inner());
3916 let binding = profiles.get(profile_id).ok_or_else(|| {
3917 failure(
3918 NodeFailureCode::UnknownEnvironmentProfile,
3919 "spawn environment profile is unavailable on this node",
3920 )
3921 })?;
3922 if binding.provider != resolved.provider
3923 || binding.native_profile_id(resolved.mode).is_none()
3924 {
3925 return Err(failure(
3926 NodeFailureCode::EnvironmentProfileBindingMismatch,
3927 "spawn environment profile does not match the provider and mode",
3928 ));
3929 }
3930 Ok(Some(ResolvedEnvironmentProfileReceipt {
3934 profile_id: binding.id.clone(),
3935 profile_revision: binding.revision.clone(),
3936 network_allowlist: resolved.network_allowlist.clone(),
3937 browser_profile_id: resolved.browser_profile_id.clone(),
3938 }))
3939 }
3940
3941 fn resolve_bundle(
3942 &self,
3943 resolved: &ResolvedSpawnSpec,
3944 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3945 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
3946 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
3947 return Ok(None);
3948 };
3949 let catalog = self.bundle_catalog
3950 .read()
3951 .unwrap_or_else(|poisoned| poisoned.into_inner());
3952 let bundle = catalog.get(bundle_id).ok_or_else(|| {
3953 failure(
3954 NodeFailureCode::UnknownBundle,
3955 "spawn bundle is unavailable on this node",
3956 )
3957 })?;
3958 self.bundle_layout(
3959 &resolved.provider,
3960 resolved.mode,
3961 environment_profile,
3962 bundle,
3963 )?;
3964 Ok(Some(bundle.receipt()))
3965 }
3966
3967 fn begin_delivery_stage(
3968 &self,
3969 manifest: DeliveryBundleManifestV2,
3970 ) -> Result<NodeResponse, NodeFailure> {
3971 let mut store = self
3972 .delivery_store
3973 .lock()
3974 .unwrap_or_else(|poisoned| poisoned.into_inner());
3975 let store = store.as_mut().ok_or_else(|| {
3976 failure(
3977 NodeFailureCode::DeliveryStageStorageFailed,
3978 "node delivery store is unavailable",
3979 )
3980 })?;
3981 let begun = store.begin(manifest).map_err(delivery_failure)?;
3982 Ok(NodeResponse::DeliveryStageBegun {
3983 stage_id: begun.stage_id,
3984 manifest_digest: begun.manifest_digest,
3985 missing_blobs: begun.missing_blobs,
3986 })
3987 }
3988
3989 fn put_delivery_blob_chunk(
3990 &self,
3991 stage_id: DeliveryStageId,
3992 blob_digest: DeliveryBlobDigestV1,
3993 offset: u64,
3994 chunk_hex: DeliveryBlobChunkHexV1,
3995 ) -> Result<NodeResponse, NodeFailure> {
3996 let mut store = self
3997 .delivery_store
3998 .lock()
3999 .unwrap_or_else(|poisoned| poisoned.into_inner());
4000 let store = store.as_mut().ok_or_else(|| {
4001 failure(
4002 NodeFailureCode::DeliveryStageStorageFailed,
4003 "node delivery store is unavailable",
4004 )
4005 })?;
4006 let accepted = store
4007 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4008 .map_err(delivery_failure)?;
4009 Ok(NodeResponse::DeliveryBlobChunkAccepted {
4010 stage_id: accepted.stage_id,
4011 blob_digest: accepted.blob_digest,
4012 next_offset: accepted.next_offset,
4013 })
4014 }
4015
4016 fn commit_delivery_stage(
4017 &self,
4018 stage_id: DeliveryStageId,
4019 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4020 let mut store = self
4021 .delivery_store
4022 .lock()
4023 .unwrap_or_else(|poisoned| poisoned.into_inner());
4024 let store = store.as_mut().ok_or_else(|| {
4025 failure(
4026 NodeFailureCode::DeliveryStageStorageFailed,
4027 "node delivery store is unavailable",
4028 )
4029 })?;
4030 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4031 let mut catalog = self
4032 .bundle_catalog
4033 .write()
4034 .unwrap_or_else(|poisoned| poisoned.into_inner());
4035 let mut next_catalog = catalog.clone();
4036 next_catalog
4037 .insert_idempotent(prepared.bundle().clone())
4038 .map_err(|_| {
4039 failure(
4040 NodeFailureCode::DeliveryStageConflict,
4041 "delivery bundle identity conflicts with installed content",
4042 )
4043 })?;
4044 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4045 *catalog = next_catalog;
4046 Ok(receipt)
4047 }
4048
4049 fn abort_delivery_stage(
4050 &self,
4051 stage_id: &DeliveryStageId,
4052 ) -> Result<(), NodeFailure> {
4053 let mut store = self
4054 .delivery_store
4055 .lock()
4056 .unwrap_or_else(|poisoned| poisoned.into_inner());
4057 let store = store.as_mut().ok_or_else(|| {
4058 failure(
4059 NodeFailureCode::DeliveryStageStorageFailed,
4060 "node delivery store is unavailable",
4061 )
4062 })?;
4063 store.abort(stage_id).map_err(delivery_failure)
4064 }
4065
4066 fn resolve_context(
4067 &self,
4068 resolved: &ResolvedSpawnSpec,
4069 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4070 let Some(context_id) = resolved.context_id.as_ref() else {
4071 return Ok(None);
4072 };
4073 self.context_catalog
4074 .read()
4075 .unwrap_or_else(|poisoned| poisoned.into_inner())
4076 .get(context_id)
4077 .map(|context| context.receipt().clone())
4078 .ok_or_else(|| {
4079 failure(
4080 NodeFailureCode::UnknownContextPack,
4081 "spawn context pack is unavailable on this node",
4082 )
4083 })
4084 .map(Some)
4085 }
4086
4087 fn bundle_layout(
4088 &self,
4089 provider: &AgentId,
4090 mode: SessionMode,
4091 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4092 bundle: &NodeBundle,
4093 ) -> Result<BundleProviderLayout, NodeFailure> {
4094 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4095 failure(
4096 NodeFailureCode::BundleBindingMismatch,
4097 "spawn bundle does not match the provider and mode",
4098 )
4099 })?;
4100 if layout != BundleProviderLayout::Codex {
4101 return Ok(layout);
4102 }
4103 let profile = environment_profile.and_then(|receipt| {
4104 let profiles = self
4105 .environment_profiles
4106 .read()
4107 .unwrap_or_else(|poisoned| poisoned.into_inner());
4108 let binding = profiles.get(&receipt.profile_id)?;
4109 if binding.revision != receipt.profile_revision
4110 || &binding.provider != provider
4111 || binding.native_profile_id(mode).is_none()
4112 {
4113 return None;
4114 }
4115 self.environment_materialization_profiles
4116 .read()
4117 .unwrap_or_else(|poisoned| poisoned.into_inner())
4118 .get(&receipt.profile_id)
4119 .cloned()
4120 });
4121 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4122 return Err(failure(
4123 NodeFailureCode::BundleBindingMismatch,
4124 "Codex bundle requires an exact isolated CODEX_HOME profile",
4125 ));
4126 }
4127 Ok(layout)
4128 }
4129
4130 fn select_environment_profile(
4131 &self,
4132 instance_id: AgentInstanceId,
4133 provider: &AgentId,
4134 mode: SessionMode,
4135 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4136 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4137 let Some(environment_profile) = environment_profile else {
4138 return Ok(None);
4139 };
4140 let native_profile_id = {
4141 let profiles = self
4142 .environment_profiles
4143 .read()
4144 .unwrap_or_else(|poisoned| poisoned.into_inner());
4145 let binding = profiles
4146 .get(&environment_profile.profile_id)
4147 .ok_or_else(|| {
4148 failure(
4149 NodeFailureCode::UnknownEnvironmentProfile,
4150 "session environment profile is unavailable on this node",
4151 )
4152 })?;
4153 if &binding.revision != &environment_profile.profile_revision
4154 || &binding.provider != provider
4155 {
4156 return Err(failure(
4157 NodeFailureCode::EnvironmentProfileBindingMismatch,
4158 "session environment profile revision or provider changed",
4159 ));
4160 }
4161 binding.native_profile_id(mode).cloned().ok_or_else(|| {
4162 failure(
4163 NodeFailureCode::EnvironmentProfileBindingMismatch,
4164 "session environment profile does not support this mode",
4165 )
4166 })?
4167 };
4168 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4169 failure(
4170 NodeFailureCode::BackendOperationFailed,
4171 "native environment profile controller is unavailable",
4172 )
4173 })?;
4174 control
4175 .select_native_launch_profile(instance_id, native_profile_id)
4176 .map_err(|_| {
4177 failure(
4178 NodeFailureCode::BackendOperationFailed,
4179 "native environment profile selection failed",
4180 )
4181 })?;
4182 Ok(Some(NativeEnvironmentSelectionGuard {
4183 control: Some(control),
4184 instance_id,
4185 }))
4186 }
4187
4188 fn install_prepared_launch_overlay(
4189 &self,
4190 instance_id: AgentInstanceId,
4191 overlay: PreparedNativeLaunchOverlay,
4192 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4193 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4194 failure(
4195 NodeFailureCode::BackendOperationFailed,
4196 "native launch profile controller is unavailable",
4197 )
4198 })?;
4199 match overlay {
4200 PreparedNativeLaunchOverlay::Environment(overlay) => {
4201 control
4202 .install_native_launch_environment_overlay(instance_id, overlay)
4203 .map_err(|_| failure(
4204 NodeFailureCode::BackendOperationFailed,
4205 "session environment overlay installation failed",
4206 ))?;
4207 Ok(None)
4208 }
4209 PreparedNativeLaunchOverlay::Instance(overlay) => {
4210 control
4211 .install_native_instance_launch_overlay(instance_id, overlay)
4212 .map_err(|_| failure(
4213 NodeFailureCode::BundleBindingMismatch,
4214 "session bundle launch overlay installation failed",
4215 ))?;
4216 Ok(Some(NativeInstanceOverlayGuard {
4217 control: Some(control),
4218 instance_id,
4219 }))
4220 }
4221 }
4222 }
4223
4224 fn environment_materialization_profile(
4225 &self,
4226 environment_profile: &ResolvedEnvironmentProfileReceipt,
4227 ) -> Option<NodeSessionMaterializationProfile> {
4228 self.environment_materialization_profiles
4229 .read()
4230 .unwrap_or_else(|poisoned| poisoned.into_inner())
4231 .get(&environment_profile.profile_id)
4232 .cloned()
4233 }
4234
4235 fn materialization_profile(
4236 &self,
4237 provider: &AgentId,
4238 mode: SessionMode,
4239 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4240 bundle: Option<&ResolvedBundleReceipt>,
4241 context: Option<&ResolvedContextPackReceipt>,
4242 ) -> Result<Option<(
4243 NodeSessionMaterializationProfile,
4244 bool,
4245 Option<BundleProviderLayout>,
4246 )>, NodeFailure> {
4247 let environment = environment_profile
4248 .and_then(|receipt| self.environment_materialization_profile(receipt));
4249 let (bundle, bundle_layout) = match bundle {
4250 Some(receipt) => {
4251 let catalog = self.bundle_catalog.read()
4252 .unwrap_or_else(|poisoned| poisoned.into_inner());
4253 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4254 NodeFailureCode::UnknownBundle,
4255 "session bundle is unavailable on this node",
4256 ))?;
4257 if installed.receipt() != *receipt {
4258 return Err(failure(
4259 NodeFailureCode::BundleBindingMismatch,
4260 "session bundle revision or digest changed",
4261 ));
4262 }
4263 let layout = self.bundle_layout(
4264 provider,
4265 mode,
4266 environment_profile,
4267 installed,
4268 )?;
4269 (Some(installed.clone()), Some(layout))
4270 }
4271 None => (None, None),
4272 };
4273 let has_environment_overlay = environment.is_some() || context.is_some();
4274 let profile = match (environment, bundle.as_ref(), bundle_layout) {
4275 (None, None, None) => None,
4276 (Some(profile), None, None) => Some(Ok(profile)),
4277 (None, Some(bundle), Some(layout)) => {
4278 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4279 }
4280 (Some(profile), Some(bundle), Some(layout)) => {
4281 Some(profile.with_bundle(bundle, layout))
4282 }
4283 _ => {
4284 return Err(failure(
4285 NodeFailureCode::BundleBindingMismatch,
4286 "session bundle provider layout is unavailable",
4287 ));
4288 }
4289 };
4290 let profile = profile
4291 .transpose()
4292 .map_err(|_| failure(
4293 NodeFailureCode::BundleMaterializationFailed,
4294 "session bundle materialization profile is invalid",
4295 ))?;
4296 let profile = match (profile, context) {
4297 (Some(profile), Some(receipt)) => {
4298 let catalog = self.context_catalog
4299 .read()
4300 .unwrap_or_else(|poisoned| poisoned.into_inner());
4301 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4302 NodeFailureCode::UnknownContextPack,
4303 "session context pack is unavailable on this node",
4304 ))?;
4305 if pack.receipt() != receipt {
4306 return Err(failure(
4307 NodeFailureCode::ContextPackMaterializationFailed,
4308 "session context pack identity changed",
4309 ));
4310 }
4311 profile.with_context(pack)
4312 }
4313 (None, Some(receipt)) => {
4314 let catalog = self.context_catalog
4315 .read()
4316 .unwrap_or_else(|poisoned| poisoned.into_inner());
4317 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4318 NodeFailureCode::UnknownContextPack,
4319 "session context pack is unavailable on this node",
4320 ))?;
4321 if pack.receipt() != receipt {
4322 return Err(failure(
4323 NodeFailureCode::ContextPackMaterializationFailed,
4324 "session context pack identity changed",
4325 ));
4326 }
4327 NodeSessionMaterializationProfile::from_context(pack)
4328 }
4329 (Some(profile), None) => Ok(profile),
4330 (None, None) => return Ok(None),
4331 }
4332 .map_err(|_| failure(
4333 NodeFailureCode::ContextPackMaterializationFailed,
4334 "session context pack materialization profile is invalid",
4335 ))?;
4336 Ok(Some((profile, has_environment_overlay, bundle_layout)))
4337 }
4338
4339 fn prepare_session_materialization<'a>(
4340 &'a self,
4341 address: &SessionAddress,
4342 provider: &AgentId,
4343 mode: SessionMode,
4344 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4345 bundle: Option<&ResolvedBundleReceipt>,
4346 context: Option<&ResolvedContextPackReceipt>,
4347 managed_lease_id: Option<ManagedWorktreeLeaseId>,
4348 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
4349 let Some((profile, has_environment_overlay, bundle_layout)) =
4350 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
4351 else {
4352 return Ok(None);
4353 };
4354 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4355 failure(
4356 NodeFailureCode::BackendOperationFailed,
4357 "session environment materializer is unavailable",
4358 )
4359 })?;
4360 let materialization_failure_code = if context.is_some() {
4361 NodeFailureCode::ContextPackMaterializationFailed
4362 } else if bundle.is_some() {
4363 NodeFailureCode::BundleMaterializationFailed
4364 } else {
4365 NodeFailureCode::BackendOperationFailed
4366 };
4367 let id = MaterializationId::new(format!(
4368 "mat-{}-{}-{}",
4369 self.incarnation_id,
4370 address.session.instance_id.0,
4371 address.session.generation.0,
4372 ))
4373 .map_err(|_| {
4374 failure(
4375 NodeFailureCode::BackendOperationFailed,
4376 "session environment materialization identity failed",
4377 )
4378 })?;
4379 let owner = MaterializationOwner::Session {
4380 incarnation_id: self.incarnation_id,
4381 instance_id: address.session.instance_id,
4382 generation: address.session.generation,
4383 };
4384 let mut ownership = materializer
4385 .begin(
4386 id.clone(),
4387 environment_profile.cloned(),
4388 bundle.cloned(),
4389 context.cloned(),
4390 owner,
4391 managed_lease_id,
4392 &profile,
4393 unix_time_ms(),
4394 )
4395 .map_err(|_| {
4396 failure(
4397 materialization_failure_code,
4398 "session materialization preparation failed",
4399 )
4400 })?;
4401 {
4402 let _transaction = self
4403 .state_transaction
4404 .lock()
4405 .unwrap_or_else(|poisoned| poisoned.into_inner());
4406 let duplicate = self
4407 .materializations
4408 .lock()
4409 .unwrap_or_else(|poisoned| poisoned.into_inner())
4410 .insert(id.clone(), ownership.clone());
4411 if duplicate.is_some() {
4412 return Err(failure(
4413 NodeFailureCode::BackendOperationFailed,
4414 "session environment materialization identity conflict",
4415 ));
4416 }
4417 if let Err(error) = self.persist_state_locked() {
4418 self.materializations
4419 .lock()
4420 .unwrap_or_else(|poisoned| poisoned.into_inner())
4421 .remove(&id);
4422 return Err(persistence_failure(error));
4423 }
4424 }
4425 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
4426 Ok(environment) => environment,
4427 Err(_) => {
4428 let transaction = self
4429 .state_transaction
4430 .lock()
4431 .unwrap_or_else(|poisoned| poisoned.into_inner());
4432 self.materializations
4433 .lock()
4434 .unwrap_or_else(|poisoned| poisoned.into_inner())
4435 .insert(id.clone(), ownership.clone());
4436 if self.persist_state_locked().is_err() {
4437 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4438 }
4439 drop(transaction);
4440 if ownership.state() == MaterializationState::CleanupRequired {
4441 let _ = self.cleanup_materialization(&id);
4442 }
4443 return Err(failure(
4444 materialization_failure_code,
4445 "session materialization failed",
4446 ));
4447 }
4448 };
4449 {
4450 let _transaction = self
4451 .state_transaction
4452 .lock()
4453 .unwrap_or_else(|poisoned| poisoned.into_inner());
4454 self.materializations
4455 .lock()
4456 .unwrap_or_else(|poisoned| poisoned.into_inner())
4457 .insert(id.clone(), ownership.clone());
4458 if let Err(error) = self.persist_state_locked() {
4459 drop(_transaction);
4460 let _ = self.cleanup_materialization(&id);
4461 return Err(persistence_failure(error));
4462 }
4463 }
4464 let guard = SessionMaterializationGuard {
4465 shared: self,
4466 id: Some(id),
4467 provider_home: Some(ownership.provider_home().to_path_buf()),
4468 };
4469 let bundle_arguments = match bundle {
4470 Some(receipt) => {
4471 let catalog = self.bundle_catalog.read()
4472 .unwrap_or_else(|poisoned| poisoned.into_inner());
4473 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4474 NodeFailureCode::UnknownBundle,
4475 "session bundle is unavailable on this node",
4476 ))?;
4477 if installed.receipt() != *receipt {
4478 return Err(failure(
4479 NodeFailureCode::BundleBindingMismatch,
4480 "session bundle revision or digest changed",
4481 ));
4482 }
4483 let layout = bundle_layout.ok_or_else(|| failure(
4484 NodeFailureCode::BundleBindingMismatch,
4485 "materialized bundle provider layout is unavailable",
4486 ))?;
4487 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
4488 failure(
4489 NodeFailureCode::BundleMaterializationFailed,
4490 "session bundle byte revalidation failed",
4491 )
4492 })?;
4493 Some(bundle_launch_arguments(
4494 layout,
4495 ownership.bundle_root(),
4496 ownership.provider_home(),
4497 ).map_err(|_| failure(
4498 NodeFailureCode::BundleBindingMismatch,
4499 "session bundle launch binding is invalid",
4500 ))?)
4501 }
4502 None => None,
4503 };
4504 if let Some(receipt) = context {
4505 materializer
4506 .revalidate_context(&ownership, receipt)
4507 .map_err(|_| failure(
4508 NodeFailureCode::ContextPackMaterializationFailed,
4509 "session context pack byte revalidation failed",
4510 ))?;
4511 }
4512 let transport = match mode {
4513 SessionMode::Pty => TransportKind::Pty,
4514 SessionMode::Inline => TransportKind::Pipe,
4515 SessionMode::Acp => TransportKind::Acp,
4516 };
4517 let overlay = if let Some(extra_args) = bundle_arguments {
4518 Some(PreparedNativeLaunchOverlay::Instance(
4519 NativeInstanceLaunchOverlay::new(
4520 provider.clone(),
4521 transport,
4522 if has_environment_overlay {
4523 environment
4524 } else {
4525 Vec::new()
4526 },
4527 extra_args,
4528 )
4529 .map_err(|_| {
4530 failure(
4531 NodeFailureCode::BundleBindingMismatch,
4532 "session bundle launch overlay is invalid",
4533 )
4534 })?,
4535 ))
4536 } else if has_environment_overlay {
4537 let context_only = environment_profile.is_none()
4538 && bundle.is_none()
4539 && context.is_some();
4540 let overlay = if context_only {
4541 NativeLaunchEnvironmentOverlay::new_context_root(
4542 provider.clone(),
4543 transport,
4544 environment,
4545 )
4546 .map_err(|_| {
4547 failure(
4548 NodeFailureCode::ContextPackMaterializationFailed,
4549 "context-only launch overlay is invalid",
4550 )
4551 })?
4552 } else {
4553 NativeLaunchEnvironmentOverlay::new(
4554 provider.clone(),
4555 transport,
4556 environment,
4557 )
4558 .map_err(|_| {
4559 failure(
4560 NodeFailureCode::BackendOperationFailed,
4561 "session environment overlay is invalid",
4562 )
4563 })?
4564 };
4565 Some(PreparedNativeLaunchOverlay::Environment(overlay))
4566 } else {
4567 None
4568 };
4569 Ok(Some((overlay, guard)))
4570 }
4571
4572 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
4573 let Some(mut ownership) = self
4574 .materializations
4575 .lock()
4576 .unwrap_or_else(|poisoned| poisoned.into_inner())
4577 .get(id)
4578 .cloned()
4579 else {
4580 return Ok(());
4581 };
4582 if ownership.state() != MaterializationState::CleanupRequired {
4583 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
4584 failure(
4585 NodeFailureCode::BackendOperationFailed,
4586 "session environment cleanup state is invalid",
4587 )
4588 })?;
4589 }
4590 {
4591 let _transaction = self
4592 .state_transaction
4593 .lock()
4594 .unwrap_or_else(|poisoned| poisoned.into_inner());
4595 let previous = self
4596 .materializations
4597 .lock()
4598 .unwrap_or_else(|poisoned| poisoned.into_inner())
4599 .insert(id.clone(), ownership.clone());
4600 if let Err(error) = self.persist_state_locked() {
4601 let mut materializations = self
4602 .materializations
4603 .lock()
4604 .unwrap_or_else(|poisoned| poisoned.into_inner());
4605 if let Some(previous) = previous {
4606 materializations.insert(id.clone(), previous);
4607 }
4608 return Err(persistence_failure(error));
4609 }
4610 }
4611 let cleanup = self
4612 .session_environment_materializer
4613 .as_ref()
4614 .ok_or(())
4615 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
4616 if cleanup.is_err() {
4617 let _ = ownership.mark_recovery_required(unix_time_ms());
4618 self.materializations
4619 .lock()
4620 .unwrap_or_else(|poisoned| poisoned.into_inner())
4621 .insert(id.clone(), ownership);
4622 if self.persist_state().is_err() {
4623 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4624 }
4625 return Err(failure(
4626 NodeFailureCode::BackendOperationFailed,
4627 "session environment cleanup requires recovery",
4628 ));
4629 }
4630 let _transaction = self
4631 .state_transaction
4632 .lock()
4633 .unwrap_or_else(|poisoned| poisoned.into_inner());
4634 self.materializations
4635 .lock()
4636 .unwrap_or_else(|poisoned| poisoned.into_inner())
4637 .remove(id);
4638 if let Err(error) = self.persist_state_locked() {
4639 self.materializations
4640 .lock()
4641 .unwrap_or_else(|poisoned| poisoned.into_inner())
4642 .insert(id.clone(), ownership);
4643 return Err(persistence_failure(error));
4644 }
4645 Ok(())
4646 }
4647
4648 fn resolve_record_materialization(
4649 &self,
4650 record_id: &SessionRecordId,
4651 provider: &AgentId,
4652 mode: SessionMode,
4653 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4654 bundle: Option<&ResolvedBundleReceipt>,
4655 context: Option<&ResolvedContextPackReceipt>,
4656 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
4657 let ownership = {
4658 let materializations = self
4659 .materializations
4660 .lock()
4661 .unwrap_or_else(|poisoned| poisoned.into_inner());
4662 let mut matches = materializations.values().filter(|record| {
4663 record.owner()
4664 == &MaterializationOwner::Record {
4665 record_id: record_id.clone(),
4666 }
4667 });
4668 let ownership = matches.next().cloned();
4669 if matches.next().is_some() {
4670 return Err(failure(
4671 NodeFailureCode::BackendOperationFailed,
4672 "managed session has conflicting environment ownership",
4673 ));
4674 }
4675 ownership
4676 };
4677 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
4678 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4679 failure(
4680 NodeFailureCode::ContextPackMaterializationFailed,
4681 "session context materializer is unavailable",
4682 )
4683 })?;
4684 let pack = materializer
4685 .revalidate_context(ownership, receipt)
4686 .map_err(|_| {
4687 self.mark_materialization_recovery_required(ownership.id());
4688 failure(
4689 NodeFailureCode::ContextPackMaterializationFailed,
4690 "managed session context pack revalidation failed",
4691 )
4692 })?;
4693 self.context_catalog
4694 .write()
4695 .unwrap_or_else(|poisoned| poisoned.into_inner())
4696 .insert(pack)
4697 .map_err(|_| failure(
4698 NodeFailureCode::ContextPackMaterializationFailed,
4699 "managed session context pack catalog restore failed",
4700 ))?;
4701 }
4702 let materialization = self.materialization_profile(
4703 provider,
4704 mode,
4705 environment_profile,
4706 bundle,
4707 context,
4708 )?;
4709 match (ownership, materialization) {
4710 (None, None) => Ok(None),
4711 (None, Some(_)) | (Some(_), None) => Err(failure(
4712 NodeFailureCode::BackendOperationFailed,
4713 "managed session materialization is unavailable",
4714 )),
4715 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
4716 if ownership.environment_profile() != environment_profile
4717 || ownership.bundle() != bundle
4718 || ownership.context() != context
4719 || ownership.state() != MaterializationState::Ready
4720 {
4721 self.mark_materialization_recovery_required(ownership.id());
4722 return Err(failure(
4723 NodeFailureCode::BackendOperationFailed,
4724 "managed session environment ownership requires recovery",
4725 ));
4726 }
4727 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4728 failure(
4729 NodeFailureCode::BackendOperationFailed,
4730 "session environment materializer is unavailable",
4731 )
4732 })?;
4733 if materializer.revalidate(&ownership).is_err() {
4734 self.mark_materialization_recovery_required(ownership.id());
4735 return Err(failure(
4736 NodeFailureCode::BackendOperationFailed,
4737 "managed session environment revalidation failed",
4738 ));
4739 }
4740 let bundle_arguments = if let Some(bundle_receipt) = bundle {
4741 let catalog = self.bundle_catalog.read()
4742 .unwrap_or_else(|poisoned| poisoned.into_inner());
4743 let Some(installed) = catalog.get(&bundle_receipt.id) else {
4744 return Err(failure(
4745 NodeFailureCode::UnknownBundle,
4746 "managed session bundle is unavailable",
4747 ));
4748 };
4749 let layout = bundle_layout.ok_or_else(|| failure(
4750 NodeFailureCode::BundleBindingMismatch,
4751 "record bundle provider layout is unavailable",
4752 ))?;
4753 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
4754 drop(catalog);
4755 self.mark_materialization_recovery_required(ownership.id());
4756 return Err(failure(
4757 NodeFailureCode::BundleMaterializationFailed,
4758 "managed session bundle revalidation failed",
4759 ));
4760 }
4761 Some(bundle_launch_arguments(
4762 layout,
4763 ownership.bundle_root(),
4764 ownership.provider_home(),
4765 ).map_err(|_| failure(
4766 NodeFailureCode::BundleBindingMismatch,
4767 "managed session bundle launch binding is invalid",
4768 ))?)
4769 } else {
4770 None
4771 };
4772 if !has_environment_materialization {
4773 let overlay = bundle_arguments.map(|extra_args| {
4774 NativeInstanceLaunchOverlay::new(
4775 provider.clone(),
4776 TransportKind::Pty,
4777 Vec::new(),
4778 extra_args,
4779 )
4780 .map(PreparedNativeLaunchOverlay::Instance)
4781 .map_err(|_| failure(
4782 NodeFailureCode::BundleBindingMismatch,
4783 "managed session bundle launch overlay is invalid",
4784 ))
4785 }).transpose()?;
4786 return Ok(Some((ownership.id().clone(), overlay)));
4787 }
4788 let environment = match materializer.resolve_environment(&ownership, &profile) {
4789 Ok(environment) => environment,
4790 Err(
4791 SessionEnvironmentMaterializeError::SecretUnavailable
4792 | SessionEnvironmentMaterializeError::SecretDenied
4793 | SessionEnvironmentMaterializeError::InvalidSecretValue,
4794 ) => {
4795 return Err(failure(
4796 NodeFailureCode::BackendOperationFailed,
4797 "managed session environment resolution failed",
4798 ));
4799 }
4800 Err(
4801 SessionEnvironmentMaterializeError::InvalidRoot
4802 | SessionEnvironmentMaterializeError::OwnershipMismatch
4803 | SessionEnvironmentMaterializeError::Filesystem(_)
4804 | SessionEnvironmentMaterializeError::Record(_),
4805 ) => {
4806 self.mark_materialization_recovery_required(ownership.id());
4807 return Err(failure(
4808 NodeFailureCode::BackendOperationFailed,
4809 "managed session environment resolution failed",
4810 ));
4811 }
4812 };
4813 let transport = match mode {
4814 SessionMode::Pty => TransportKind::Pty,
4815 SessionMode::Inline => TransportKind::Pipe,
4816 SessionMode::Acp => TransportKind::Acp,
4817 };
4818 let overlay = match bundle_arguments {
4819 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
4820 NativeInstanceLaunchOverlay::new(
4821 provider.clone(),
4822 transport,
4823 environment,
4824 extra_args,
4825 )
4826 .map_err(|_| failure(
4827 NodeFailureCode::BundleBindingMismatch,
4828 "managed session bundle launch overlay is invalid",
4829 ))?,
4830 ),
4831 None => PreparedNativeLaunchOverlay::Environment(
4832 NativeLaunchEnvironmentOverlay::new(
4833 provider.clone(),
4834 transport,
4835 environment,
4836 )
4837 .map_err(|_| {
4838 failure(
4839 NodeFailureCode::BackendOperationFailed,
4840 "managed session environment overlay is invalid",
4841 )
4842 })?,
4843 ),
4844 };
4845 Ok(Some((ownership.id().clone(), Some(overlay))))
4846 }
4847 }
4848 }
4849
4850 fn destroy_record_materialization(
4851 &self,
4852 record_id: &SessionRecordId,
4853 ) -> Result<bool, NodeFailure> {
4854 let ids = self
4855 .materializations
4856 .lock()
4857 .unwrap_or_else(|poisoned| poisoned.into_inner())
4858 .values()
4859 .filter_map(|record| {
4860 (record.owner()
4861 == &MaterializationOwner::Record {
4862 record_id: record_id.clone(),
4863 })
4864 .then(|| record.id().clone())
4865 })
4866 .collect::<Vec<_>>();
4867 if ids.len() > 1 {
4868 return Err(failure(
4869 NodeFailureCode::BackendOperationFailed,
4870 "managed session has conflicting environment ownership",
4871 ));
4872 }
4873 let Some(id) = ids.first().cloned() else {
4874 return Ok(false);
4875 };
4876 let (terminal_record, mut cleanup_ownership) = {
4877 let _transaction = self
4878 .state_transaction
4879 .lock()
4880 .unwrap_or_else(|poisoned| poisoned.into_inner());
4881 let original_record = self.record(record_id)?;
4882 let mut terminal_record = original_record.clone();
4883 terminal_record.provider_session = None;
4884 terminal_record.active_session = None;
4885 terminal_record.state = ManagedSessionState::Unavailable;
4886 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
4887 terminal_record.updated_at_unix_ms = unix_time_ms();
4888 let original_ownership = self
4889 .materializations
4890 .lock()
4891 .unwrap_or_else(|poisoned| poisoned.into_inner())
4892 .get(&id)
4893 .cloned()
4894 .ok_or_else(|| {
4895 failure(
4896 NodeFailureCode::BackendOperationFailed,
4897 "managed session environment ownership disappeared during cleanup",
4898 )
4899 })?;
4900 let mut cleanup_ownership = original_ownership.clone();
4901 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
4902 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
4903 failure(
4904 NodeFailureCode::BackendOperationFailed,
4905 "managed session environment cleanup state is invalid",
4906 )
4907 })?;
4908 }
4909 self.session_records
4910 .lock()
4911 .unwrap_or_else(|poisoned| poisoned.into_inner())
4912 .records
4913 .insert(record_id.clone(), terminal_record.clone());
4914 self.materializations
4915 .lock()
4916 .unwrap_or_else(|poisoned| poisoned.into_inner())
4917 .insert(id.clone(), cleanup_ownership.clone());
4918 if let Err(error) = self.persist_state_locked() {
4919 self.session_records
4920 .lock()
4921 .unwrap_or_else(|poisoned| poisoned.into_inner())
4922 .records
4923 .insert(record_id.clone(), original_record);
4924 self.materializations
4925 .lock()
4926 .unwrap_or_else(|poisoned| poisoned.into_inner())
4927 .insert(id.clone(), original_ownership);
4928 return Err(persistence_failure(error));
4929 }
4930 (terminal_record, cleanup_ownership)
4931 };
4932 let cleanup = self
4933 .session_environment_materializer
4934 .as_ref()
4935 .ok_or(())
4936 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
4937 if cleanup.is_err() {
4938 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
4939 self.materializations
4940 .lock()
4941 .unwrap_or_else(|poisoned| poisoned.into_inner())
4942 .insert(id.clone(), cleanup_ownership);
4943 if self.persist_state().is_err() {
4944 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4945 }
4946 return Err(failure(
4947 NodeFailureCode::BackendOperationFailed,
4948 "managed session environment cleanup requires recovery",
4949 ));
4950 }
4951 let _transaction = self
4952 .state_transaction
4953 .lock()
4954 .unwrap_or_else(|poisoned| poisoned.into_inner());
4955 self.remove_record_memory(record_id);
4956 self.materializations
4957 .lock()
4958 .unwrap_or_else(|poisoned| poisoned.into_inner())
4959 .remove(&id);
4960 if let Err(error) = self.persist_state_locked() {
4961 self.session_records
4962 .lock()
4963 .unwrap_or_else(|poisoned| poisoned.into_inner())
4964 .records
4965 .insert(record_id.clone(), terminal_record);
4966 self.materializations
4967 .lock()
4968 .unwrap_or_else(|poisoned| poisoned.into_inner())
4969 .insert(id, cleanup_ownership);
4970 return Err(persistence_failure(error));
4971 }
4972 Ok(true)
4973 }
4974
4975 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
4976 let changed = {
4977 let mut materializations = self
4978 .materializations
4979 .lock()
4980 .unwrap_or_else(|poisoned| poisoned.into_inner());
4981 let Some(ownership) = materializations.get_mut(id) else {
4982 return;
4983 };
4984 ownership.mark_recovery_required(unix_time_ms()).is_ok()
4985 };
4986 if changed && self.persist_state().is_err() {
4987 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4988 }
4989 }
4990
4991 fn reconcile_materializations(&self) {
4992 let records = self
4993 .materializations
4994 .lock()
4995 .unwrap_or_else(|poisoned| poisoned.into_inner())
4996 .values()
4997 .cloned()
4998 .collect::<Vec<_>>();
4999 for mut ownership in records {
5000 let result = match (ownership.state(), ownership.owner()) {
5001 (MaterializationState::Preparing, _)
5002 | (MaterializationState::CleanupRequired, _) => {
5003 self.cleanup_materialization(ownership.id())
5004 }
5005 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5006 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5007 self.materializations
5008 .lock()
5009 .unwrap_or_else(|poisoned| poisoned.into_inner())
5010 .insert(ownership.id().clone(), ownership.clone());
5011 self.persist_state().map_err(persistence_failure)
5012 } else {
5013 Err(failure(
5014 NodeFailureCode::BackendOperationFailed,
5015 "session environment reconciliation failed",
5016 ))
5017 }
5018 }
5019 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5020 .session_environment_materializer
5021 .as_ref()
5022 .ok_or_else(|| {
5023 failure(
5024 NodeFailureCode::BackendOperationFailed,
5025 "session environment materializer is unavailable",
5026 )
5027 })
5028 .and_then(|materializer| {
5029 materializer.revalidate(&ownership).map_err(|_| failure(
5030 NodeFailureCode::BackendOperationFailed,
5031 "session materialization revalidation failed",
5032 ))?;
5033 let record = self.record(record_id)?;
5034 if record.environment_profile.as_ref() != ownership.environment_profile()
5035 || record.bundle.as_ref() != ownership.bundle()
5036 || record.context.as_ref() != ownership.context()
5037 {
5038 return Err(failure(
5039 NodeFailureCode::BackendOperationFailed,
5040 "session materialization ownership changed after restart",
5041 ));
5042 }
5043 if let Some(receipt) = ownership.bundle() {
5044 let catalog = self.bundle_catalog.read()
5045 .unwrap_or_else(|poisoned| poisoned.into_inner());
5046 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5047 NodeFailureCode::UnknownBundle,
5048 "session bundle is unavailable after restart",
5049 ))?;
5050 if bundle.receipt() != *receipt {
5051 return Err(failure(
5052 NodeFailureCode::BundleBindingMismatch,
5053 "session bundle changed after restart",
5054 ));
5055 }
5056 let layout = self.bundle_layout(
5057 &record.provider,
5058 record.mode,
5059 record.environment_profile.as_ref(),
5060 bundle,
5061 )?;
5062 materializer
5063 .revalidate_bundle(&ownership, bundle, layout)
5064 .map_err(|_| failure(
5065 NodeFailureCode::BundleMaterializationFailed,
5066 "session bundle revalidation failed",
5067 ))?;
5068 }
5069 if let Some(receipt) = ownership.context() {
5070 let pack = materializer
5071 .revalidate_context(&ownership, receipt)
5072 .map_err(|_| {
5073 failure(
5074 NodeFailureCode::ContextPackMaterializationFailed,
5075 "session context revalidation failed",
5076 )
5077 })?;
5078 self.context_catalog
5079 .write()
5080 .unwrap_or_else(|poisoned| poisoned.into_inner())
5081 .insert(pack)
5082 .map_err(|_| {
5083 failure(
5084 NodeFailureCode::ContextPackMaterializationFailed,
5085 "session context catalog restore failed",
5086 )
5087 })?;
5088 }
5089 Ok(())
5090 }),
5091 (MaterializationState::RecoveryRequired, _) => Ok(()),
5092 };
5093 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5094 let _ = ownership.mark_recovery_required(unix_time_ms());
5095 self.materializations
5096 .lock()
5097 .unwrap_or_else(|poisoned| poisoned.into_inner())
5098 .insert(ownership.id().clone(), ownership);
5099 if self.persist_state().is_err() {
5100 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5101 }
5102 }
5103 }
5104 let materialized_profiles = self
5105 .environment_materialization_profiles
5106 .read()
5107 .unwrap_or_else(|poisoned| poisoned.into_inner())
5108 .keys()
5109 .cloned()
5110 .collect::<Vec<_>>();
5111 let ownership = self
5112 .materializations
5113 .lock()
5114 .unwrap_or_else(|poisoned| poisoned.into_inner())
5115 .values()
5116 .cloned()
5117 .collect::<Vec<_>>();
5118 let changed = {
5119 let mut session_records = self
5120 .session_records
5121 .lock()
5122 .unwrap_or_else(|poisoned| poisoned.into_inner());
5123 let mut changed = false;
5124 for record in session_records.records.values_mut() {
5125 let environment_profile = record.environment_profile.as_ref();
5126 if environment_profile.is_none()
5127 && record.bundle.is_none()
5128 && record.context.is_none()
5129 {
5130 continue;
5131 }
5132 if record.bundle.is_none()
5133 && environment_profile.is_some_and(|profile| {
5134 !materialized_profiles.contains(&profile.profile_id)
5135 })
5136 {
5137 continue;
5138 }
5139 let has_exact_ownership = ownership.iter().any(|candidate| {
5140 candidate.state() == MaterializationState::Ready
5141 && candidate.environment_profile() == environment_profile
5142 && candidate.bundle() == record.bundle.as_ref()
5143 && candidate.context() == record.context.as_ref()
5144 && candidate.owner()
5145 == &MaterializationOwner::Record {
5146 record_id: record.record_id.clone(),
5147 }
5148 });
5149 if !has_exact_ownership {
5150 record.provider_session = None;
5151 record.active_session = None;
5152 record.state = ManagedSessionState::Unavailable;
5153 record.last_error = Some(if record.bundle.is_some() {
5154 "bundle-unavailable".to_owned()
5155 } else if record.context.is_some() {
5156 "context-unavailable".to_owned()
5157 } else {
5158 "environment-profile-unavailable".to_owned()
5159 });
5160 record.updated_at_unix_ms = unix_time_ms();
5161 changed = true;
5162 }
5163 }
5164 changed
5165 };
5166 if changed && self.persist_state().is_err() {
5167 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5168 }
5169 }
5170
5171 fn replay_spawn_spec(
5172 &self,
5173 spec: &SpawnSpec,
5174 now: Instant,
5175 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5176 let mut cache = self
5177 .spawn_idempotency
5178 .lock()
5179 .unwrap_or_else(|poisoned| poisoned.into_inner());
5180 cache.entries.retain(|_, entry| entry.expires_at > now);
5181 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5182 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5183 return Err(failure(
5184 NodeFailureCode::SpawnIdempotencyConflict,
5185 "spawn idempotency key was reused across spawn kinds",
5186 ));
5187 };
5188 if existing != spec {
5189 return Err(failure(
5190 NodeFailureCode::SpawnIdempotencyConflict,
5191 "spawn idempotency key was reused with a different specification",
5192 ));
5193 }
5194 return Ok(Some(result.clone()));
5195 }
5196 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5197 return Err(failure(
5198 NodeFailureCode::SpawnIdempotencyCapacity,
5199 "spawn idempotency cache reached its bounded capacity",
5200 ));
5201 }
5202 Ok(None)
5203 }
5204
5205 fn remember_spawn_spec(
5206 &self,
5207 spec: SpawnSpec,
5208 result: Result<ResolvedSpawnReceipt, NodeFailure>,
5209 accepted_at: Instant,
5210 ) {
5211 self
5212 .spawn_idempotency
5213 .lock()
5214 .unwrap_or_else(|poisoned| poisoned.into_inner())
5215 .entries
5216 .insert(
5217 spec.idempotency_key.clone(),
5218 SpawnIdempotencyEntry {
5219 value: SpawnIdempotencyValue::Standard { spec, result },
5220 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5221 },
5222 );
5223 }
5224
5225 fn claim_harness_mcp_spawn(
5226 &self,
5227 _reservation_id: &HarnessMcpReservationId,
5228 _activation_digest: &HarnessMcpActivationDigest,
5229 spec: &SpawnSpec,
5230 now: Instant,
5231 ) -> Result<(), NodeFailure> {
5232 let mut cache = self.spawn_idempotency.lock()
5233 .unwrap_or_else(|poisoned| poisoned.into_inner());
5234 cache.entries.retain(|_, entry| entry.expires_at > now);
5235 if cache.entries.contains_key(&spec.idempotency_key) {
5236 return Err(failure(
5237 NodeFailureCode::SpawnIdempotencyConflict,
5238 "spawn idempotency key was reused across spawn kinds",
5239 ));
5240 }
5241 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5242 return Err(failure(
5243 NodeFailureCode::SpawnIdempotencyCapacity,
5244 "spawn idempotency cache reached its bounded capacity",
5245 ));
5246 }
5247 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5248 value: SpawnIdempotencyValue::HarnessMcp,
5249 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5250 });
5251 Ok(())
5252 }
5253
5254 fn replay_managed_spawn(
5255 &self,
5256 request: &ManagedWorktreeSpawnRequest,
5257 now: Instant,
5258 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5259 let mut cache = self.spawn_idempotency.lock()
5260 .unwrap_or_else(|poisoned| poisoned.into_inner());
5261 cache.entries.retain(|_, entry| entry.expires_at > now);
5262 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5263 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5264 return Err(failure(
5265 NodeFailureCode::SpawnIdempotencyConflict,
5266 "spawn idempotency key was reused across spawn kinds",
5267 ));
5268 };
5269 if existing != request {
5270 return Err(failure(
5271 NodeFailureCode::SpawnIdempotencyConflict,
5272 "spawn idempotency key was reused with a different managed specification",
5273 ));
5274 }
5275 return Ok(Some(result.clone()));
5276 }
5277 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5278 return Err(failure(
5279 NodeFailureCode::SpawnIdempotencyCapacity,
5280 "spawn idempotency cache reached its bounded capacity",
5281 ));
5282 }
5283 Ok(None)
5284 }
5285
5286 fn remember_managed_spawn(
5287 &self,
5288 request: ManagedWorktreeSpawnRequest,
5289 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5290 accepted_at: Instant,
5291 ) {
5292 self.spawn_idempotency.lock()
5293 .unwrap_or_else(|poisoned| poisoned.into_inner())
5294 .entries
5295 .insert(
5296 request.spawn_spec.idempotency_key.clone(),
5297 SpawnIdempotencyEntry {
5298 value: SpawnIdempotencyValue::Managed { request, result },
5299 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5300 },
5301 );
5302 }
5303
5304 fn remember_managed_spawn_attempt(
5305 &self,
5306 durable_v2_key: Option<&SpawnIdempotencyKey>,
5307 request: ManagedWorktreeSpawnRequest,
5308 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5309 accepted_at: Instant,
5310 ) {
5311 if durable_v2_key.is_none() {
5312 self.remember_managed_spawn(request, result, accepted_at);
5313 }
5314 }
5315
5316 fn acquire_durable_managed_spawn_v2(
5317 &self,
5318 request: &ManagedWorktreeSpawnRequestV2,
5319 request_digest: String,
5320 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5321 if self.state_path.is_none() {
5322 return Err(failure(
5323 NodeFailureCode::BackendOperationFailed,
5324 "managed worktree V2 requires durable node state",
5325 ));
5326 }
5327 let _transaction = self.state_transaction
5328 .lock()
5329 .unwrap_or_else(|poisoned| poisoned.into_inner());
5330 let key = request.spawn_spec.idempotency_key.clone();
5331 let mut previous = None;
5332 {
5333 let mut replays = self.managed_spawn_replays
5334 .lock()
5335 .unwrap_or_else(|poisoned| poisoned.into_inner());
5336 if let Some(record) = replays.get_mut(&key) {
5337 if record.request_digest != request_digest {
5338 return Err(failure(
5339 NodeFailureCode::SpawnIdempotencyConflict,
5340 "managed worktree V2 idempotency key was reused with a different request",
5341 ));
5342 }
5343 match &record.state {
5344 ManagedWorktreeSpawnReplayStateV10::Pending {
5345 owner_incarnation_id,
5346 lease_id: None,
5347 } if owner_incarnation_id != &self.incarnation_id => {
5348 previous = Some(record.clone());
5349 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
5350 owner_incarnation_id: self.incarnation_id,
5351 lease_id: None,
5352 };
5353 record.updated_at_unix_ms = unix_time_ms();
5354 }
5355 ManagedWorktreeSpawnReplayStateV10::Pending {
5356 lease_id: None,
5357 ..
5358 } => {
5359 return Err(failure(
5360 NodeFailureCode::BackendBusy,
5361 "managed worktree V2 reservation is already active",
5362 ));
5363 }
5364 ManagedWorktreeSpawnReplayStateV10::Pending {
5365 lease_id: Some(_),
5366 ..
5367 } => {
5368 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
5369 }
5370 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
5371 return Ok(DurableManagedSpawnReplayDecision::Committed(
5372 receipt.clone(),
5373 ));
5374 }
5375 }
5376 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
5377 return Err(failure(
5378 NodeFailureCode::SpawnIdempotencyCapacity,
5379 "durable managed worktree replay ledger reached its bounded capacity",
5380 ));
5381 } else {
5382 let now = unix_time_ms();
5383 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
5384 idempotency_key: key.clone(),
5385 request_digest,
5386 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
5387 profile_id: request.worktree_profile_id.clone(),
5388 expected_profile_revision: request.expected_profile_revision.clone(),
5389 state: ManagedWorktreeSpawnReplayStateV10::Pending {
5390 owner_incarnation_id: self.incarnation_id,
5391 lease_id: None,
5392 },
5393 created_at_unix_ms: now,
5394 updated_at_unix_ms: now,
5395 });
5396 }
5397 }
5398 if let Err(error) = self.persist_state_locked() {
5399 let mut replays = self.managed_spawn_replays
5400 .lock()
5401 .unwrap_or_else(|poisoned| poisoned.into_inner());
5402 match previous {
5403 Some(previous) => {
5404 replays.insert(key, previous);
5405 }
5406 None => {
5407 replays.remove(&key);
5408 }
5409 }
5410 return Err(persistence_failure(error));
5411 }
5412 Ok(DurableManagedSpawnReplayDecision::Reserved)
5413 }
5414
5415 fn link_durable_managed_spawn_v2_lease_locked(
5416 &self,
5417 key: &SpawnIdempotencyKey,
5418 lease_id: &ManagedWorktreeLeaseId,
5419 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
5420 let mut replays = self.managed_spawn_replays
5421 .lock()
5422 .unwrap_or_else(|poisoned| poisoned.into_inner());
5423 let record = replays.get_mut(key).ok_or_else(|| {
5424 failure(
5425 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5426 "managed worktree V2 reservation disappeared before allocation",
5427 )
5428 })?;
5429 let previous = record.clone();
5430 match &mut record.state {
5431 ManagedWorktreeSpawnReplayStateV10::Pending {
5432 lease_id: current,
5433 ..
5434 }
5435 if current.is_none() =>
5436 {
5437 *current = Some(lease_id.clone());
5438 record.updated_at_unix_ms = unix_time_ms();
5439 Ok(previous)
5440 }
5441 _ => Err(failure(
5442 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5443 "managed worktree V2 reservation is not allocation-ready",
5444 )),
5445 }
5446 }
5447
5448 fn restore_durable_managed_spawn_v2_locked(
5449 &self,
5450 key: &SpawnIdempotencyKey,
5451 previous: ManagedWorktreeSpawnReplayRecordV10,
5452 ) {
5453 self.managed_spawn_replays
5454 .lock()
5455 .unwrap_or_else(|poisoned| poisoned.into_inner())
5456 .insert(key.clone(), previous);
5457 }
5458
5459 fn commit_durable_managed_spawn_v2(
5460 &self,
5461 request: &ManagedWorktreeSpawnRequestV2,
5462 receipt: ManagedWorktreeSpawnReceipt,
5463 ) -> Result<(), NodeFailure> {
5464 let _transaction = self.state_transaction
5465 .lock()
5466 .unwrap_or_else(|poisoned| poisoned.into_inner());
5467 let key = &request.spawn_spec.idempotency_key;
5468 let previous = {
5469 let mut replays = self.managed_spawn_replays
5470 .lock()
5471 .unwrap_or_else(|poisoned| poisoned.into_inner());
5472 let record = replays.get_mut(key).ok_or_else(|| {
5473 failure(
5474 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5475 "managed worktree V2 reservation disappeared before commit",
5476 )
5477 })?;
5478 let previous = record.clone();
5479 match &record.state {
5480 ManagedWorktreeSpawnReplayStateV10::Pending {
5481 lease_id: Some(lease_id),
5482 ..
5483 } if lease_id == &receipt.lease.lease_id => {}
5484 _ => {
5485 return Err(failure(
5486 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5487 "managed worktree V2 reservation is not linked to the spawned lease",
5488 ));
5489 }
5490 }
5491 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
5492 record.updated_at_unix_ms = unix_time_ms();
5493 previous
5494 };
5495 if let Err(error) = self.persist_state_locked() {
5496 self.restore_durable_managed_spawn_v2_locked(key, previous);
5497 return Err(persistence_failure(error));
5498 }
5499 Ok(())
5500 }
5501
5502 fn remove_unallocated_durable_managed_spawn_v2(
5503 &self,
5504 request: &ManagedWorktreeSpawnRequestV2,
5505 ) -> Result<(), NodeFailure> {
5506 let _transaction = self.state_transaction
5507 .lock()
5508 .unwrap_or_else(|poisoned| poisoned.into_inner());
5509 let key = &request.spawn_spec.idempotency_key;
5510 let removed = {
5511 let mut replays = self.managed_spawn_replays
5512 .lock()
5513 .unwrap_or_else(|poisoned| poisoned.into_inner());
5514 match replays.get(key).map(|record| &record.state) {
5515 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
5516 lease_id: None,
5517 ..
5518 }) => {
5519 replays.remove(key)
5520 }
5521 _ => None,
5522 }
5523 };
5524 let Some(removed) = removed else {
5525 return Ok(());
5526 };
5527 if let Err(error) = self.persist_state_locked() {
5528 self.managed_spawn_replays
5529 .lock()
5530 .unwrap_or_else(|poisoned| poisoned.into_inner())
5531 .insert(key.clone(), removed);
5532 return Err(persistence_failure(error));
5533 }
5534 Ok(())
5535 }
5536
5537 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
5538 if spec.target.node_id != self.node_id {
5539 return Err(failure(
5540 NodeFailureCode::SpawnTargetMismatch,
5541 "spawn target does not match this node",
5542 ));
5543 }
5544 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
5545 failure(
5546 NodeFailureCode::UnknownSpawnProfile,
5547 "spawn profile is unavailable on this node",
5548 )
5549 })?;
5550 let resolved = spec.resolve(defaults).map_err(|error| match error {
5551 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
5552 NodeFailureCode::SpawnProfileRevisionMismatch,
5553 "spawn profile revision does not match the loaded profile",
5554 ),
5555 _ => failure(
5556 NodeFailureCode::InvalidRequest,
5557 "spawn specification could not be resolved",
5558 ),
5559 })?;
5560 if resolved.target.worktree_id.is_some() {
5561 self.require_worktree_service(&resolved.target.workspace_id)?;
5562 }
5563 let environment_profile = self.resolve_environment_profile(&resolved)?;
5564 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
5565 self.resolve_context(&resolved)?;
5566 Ok(resolved)
5567 }
5568
5569 async fn resolve_spawn_workspace(
5570 &self,
5571 resolved: &ResolvedSpawnSpec,
5572 deadline: Instant,
5573 ) -> Result<WorkspaceId, NodeFailure> {
5574 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
5575 return Ok(resolved.target.workspace_id.clone());
5576 };
5577 if self.managed_worktrees.lock()
5578 .unwrap_or_else(|poisoned| poisoned.into_inner())
5579 .lease_for_workspace(worktree_id)
5580 .is_some()
5581 {
5582 return Err(failure(
5583 NodeFailureCode::ManagedWorktreeOwnershipConflict,
5584 "managed worktree targets may only be spawned through their lease request",
5585 ));
5586 }
5587 self.require_worktree_service(&resolved.target.workspace_id)?;
5588 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
5589 let selected_root = self.workspace_root(worktree_id)?;
5590 let remaining = spawn_deadline_remaining(deadline)?;
5591 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
5592 .await
5593 .map_err(|_| {
5594 failure(
5595 NodeFailureCode::SpawnDeadlineExceeded,
5596 "Git worktree selection exceeded the spawn deadline",
5597 )
5598 })?
5599 .map_err(git_worktree_failure)?;
5600 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
5601 Ok(worktree_id.clone())
5602 }
5603
5604 async fn spawn_from_spec(
5605 &self,
5606 spec: SpawnSpec,
5607 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
5608 self.spawn_from_spec_with_proxy(spec, None).await
5609 }
5610
5611 async fn spawn_from_spec_with_proxy(
5612 &self,
5613 spec: SpawnSpec,
5614 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
5615 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
5616 let accepted_at = Instant::now();
5617 if harness_mcp.is_none() {
5618 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
5619 return replayed;
5620 }
5621 }
5622 let resolved = self.resolve_spawn_spec(&spec)?;
5623 let environment_profile = self.resolve_environment_profile(&resolved)?;
5624 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
5625 let context = self.resolve_context(&resolved)?;
5626 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
5627 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
5628 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
5629 let spawn_workspace_id = match spawn_workspace_id {
5630 Ok(workspace_id) => workspace_id,
5631 Err(error) => {
5632 let result = Err(error);
5633 if harness_mcp.is_none() {
5634 self.remember_spawn_spec(spec, result.clone(), accepted_at);
5635 }
5636 return result;
5637 }
5638 };
5639 if self.managed_worktrees.lock()
5640 .unwrap_or_else(|poisoned| poisoned.into_inner())
5641 .lease_for_workspace(&spawn_workspace_id)
5642 .is_some()
5643 {
5644 let result = Err(failure(
5645 NodeFailureCode::ManagedWorktreeOwnershipConflict,
5646 "managed worktree targets may only be spawned through their lease request",
5647 ));
5648 if harness_mcp.is_none() {
5649 self.remember_spawn_spec(spec, result.clone(), accepted_at);
5650 }
5651 return result;
5652 }
5653 let result = self
5654 .spawn_session_with_deadline(
5655 spawn_workspace_id,
5656 resolved.provider.clone(),
5657 resolved.mode,
5658 resolved.terminal_size,
5659 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
5660 environment_profile.clone(),
5661 bundle.clone(),
5662 context.clone(),
5663 None,
5664 None,
5665 SpawnRecordPolicy::Always,
5666 Some(deadline),
5667 &required_capabilities,
5668 harness_mcp,
5669 resolved.approval_level,
5670 resolved.network_allowlist.clone(),
5671 resolved.browser_profile_id.clone(),
5672 )
5673 .await
5674 .map(|(session, _runtime_policy)| {
5675 resolved.receipt_with_materialization(
5676 self.incarnation_id,
5677 session,
5678 environment_profile,
5679 bundle,
5680 context,
5681 )
5682 });
5683 if harness_mcp.is_none() {
5684 self.remember_spawn_spec(spec, result.clone(), accepted_at);
5685 }
5686 result
5687 }
5688
5689 async fn spawn_from_spec_with_harness_mcp(
5690 &self,
5691 reservation_id: HarnessMcpReservationId,
5692 activation_digest: HarnessMcpActivationDigest,
5693 spec: SpawnSpec,
5694 deadline_unix_ms: u64,
5695 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
5696 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
5697 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
5698 })?;
5699 if let Some(receipt) = registry
5700 .replay_spawn(&reservation_id, &activation_digest, &spec)
5701 .map_err(harness_mcp_failure)?
5702 {
5703 return Ok(receipt);
5704 }
5705 let resolved = self.resolve_spawn_spec(&spec)?;
5706 let prepared = registry
5707 .prepare_spawn(
5708 &reservation_id,
5709 &activation_digest,
5710 &spec,
5711 deadline_unix_ms,
5712 resolved.provider.clone(),
5713 )
5714 .map_err(harness_mcp_failure)?;
5715 self.claim_harness_mcp_spawn(
5716 &reservation_id,
5717 &activation_digest,
5718 &spec,
5719 Instant::now(),
5720 )?;
5721 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
5722 let mut receipt = match spawn {
5723 Ok(receipt) => receipt,
5724 Err(error) => {
5725 let _ = registry.abort(&reservation_id, &activation_digest);
5726 return Err(error);
5727 }
5728 };
5729 let record_id = self
5730 .session_bindings
5731 .lock()
5732 .unwrap_or_else(|poisoned| poisoned.into_inner())
5733 .get(&receipt.session.session.instance_id)
5734 .and_then(|binding| binding.record_id.clone());
5735 let record_id = match record_id {
5736 Some(record_id) => record_id,
5737 None => {
5738 let rollback = self.remove_session(&receipt.session).await;
5739 let _ = registry.abort(&reservation_id, &activation_digest);
5740 if let Err(rollback_error) = rollback {
5741 return Err(rollback_error);
5742 }
5743 return Err(failure(
5744 NodeFailureCode::BindingMismatch,
5745 "harness MCP spawn did not retain its exact managed record",
5746 ));
5747 }
5748 };
5749 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
5750 reservation_id: reservation_id.clone(),
5751 activation_digest: activation_digest.clone(),
5752 });
5753 if let Err(error) = registry.mark_spawned(
5754 &prepared,
5755 receipt.session.clone(),
5756 record_id,
5757 receipt.clone(),
5758 ) {
5759 let rollback = self.remove_session(&receipt.session).await;
5760 let _ = registry.abort(&reservation_id, &activation_digest);
5761 if let Err(rollback_error) = rollback {
5762 return Err(rollback_error);
5763 }
5764 return Err(harness_mcp_failure(error));
5765 }
5766 Ok(receipt)
5767 }
5768
5769 async fn spawn_managed_worktree(
5770 &self,
5771 request: ManagedWorktreeSpawnRequest,
5772 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
5773 self.spawn_managed_worktree_inner(request, None).await
5774 }
5775
5776 async fn spawn_managed_worktree_inner(
5777 &self,
5778 request: ManagedWorktreeSpawnRequest,
5779 durable_v2_key: Option<&SpawnIdempotencyKey>,
5780 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
5781 let accepted_at = Instant::now();
5782 if durable_v2_key.is_none() {
5783 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
5784 return replayed;
5785 }
5786 }
5787 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
5788 if request.spawn_spec.target.worktree_id.is_some() {
5789 let result = Err(failure(
5790 NodeFailureCode::InvalidRequest,
5791 "managed worktree spawn must not select a caller-provided worktree",
5792 ));
5793 self.remember_managed_spawn_attempt(
5794 durable_v2_key,
5795 request,
5796 result.clone(),
5797 accepted_at,
5798 );
5799 return result;
5800 }
5801 let environment_profile = self.resolve_environment_profile(&resolved)?;
5802 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
5803 let context = self.resolve_context(&resolved)?;
5804 let source_workspace_id = resolved.target.workspace_id.clone();
5805 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
5806 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
5807 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
5808 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
5809 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
5810 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
5811 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
5812 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
5813 };
5814 let runtime_policy = timeout(
5815 spawn_deadline_remaining(deadline)?,
5816 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
5817 ).await.map_err(|_| failure(
5818 NodeFailureCode::SpawnDeadlineExceeded,
5819 "provider admission exceeded the managed spawn deadline",
5820 ))??;
5821 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
5822 return Err(failure(
5823 NodeFailureCode::UnsupportedSpawnCapability,
5824 "provider runtime does not admit a required spawn capability",
5825 ));
5826 }
5827 self.ensure_binding_capacity()?;
5828 let source_root = self.workspace_root(&source_workspace_id)?;
5829 profile.validate_for_workspace(&source_root)
5830 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
5831 let base_timeout_ms = u64::try_from(
5832 spawn_deadline_remaining(deadline)?.as_millis(),
5833 ).unwrap_or(u64::MAX).max(1);
5834 let base_commit = resolve_base_commit_with_timeout(
5835 &source_root,
5836 profile.base(),
5837 base_timeout_ms,
5838 ).await.map_err(|error| {
5839 if Instant::now() >= deadline || error.message.contains("timed out") {
5840 failure(
5841 NodeFailureCode::SpawnDeadlineExceeded,
5842 "managed worktree base resolution exceeded the spawn deadline",
5843 )
5844 } else {
5845 managed_git_worktree_failure(error)
5846 }
5847 })?;
5848
5849 let lease = {
5850 let _transaction = self.state_transaction.lock()
5851 .unwrap_or_else(|poisoned| poisoned.into_inner());
5852 let previous = self.managed_worktrees.lock()
5853 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
5854 let lease = self.managed_worktrees.lock()
5855 .unwrap_or_else(|poisoned| poisoned.into_inner())
5856 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
5857 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
5858 let previous_replay = match durable_v2_key
5859 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
5860 .transpose()
5861 {
5862 Ok(previous) => previous,
5863 Err(error) => {
5864 *self.managed_worktrees.lock()
5865 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
5866 return Err(error);
5867 }
5868 };
5869 if let Err(error) = self.persist_state_locked() {
5870 *self.managed_worktrees.lock()
5871 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
5872 if let (Some(key), Some(previous_replay)) =
5873 (durable_v2_key, previous_replay)
5874 {
5875 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
5876 }
5877 return Err(persistence_failure(error));
5878 }
5879 lease
5880 };
5881 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
5882 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
5883 self.managed_worktrees.lock()
5884 .unwrap_or_else(|poisoned| poisoned.into_inner())
5885 .remove_unmutated(&lease.lease_id);
5886 if self.persist_state().is_err() {
5887 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5888 }
5889 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
5890 self.remember_managed_spawn_attempt(
5891 durable_v2_key,
5892 request,
5893 result.clone(),
5894 accepted_at,
5895 );
5896 return result;
5897 }
5898 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
5899 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
5900 Err(error) => {
5901 self.reconcile_failed_allocation(&lease, &source_root).await;
5902 let result = Err(error);
5903 self.remember_managed_spawn_attempt(
5904 durable_v2_key,
5905 request,
5906 result.clone(),
5907 accepted_at,
5908 );
5909 return result;
5910 }
5911 };
5912 let created = match create_git_worktree_with_timeout(
5913 &source_root,
5914 &lease.target_root,
5915 &lease.branch,
5916 Some(&base_commit),
5917 mutation_timeout_ms,
5918 ).await {
5919 Ok(created) => created,
5920 Err(error) => {
5921 self.reconcile_failed_allocation(&lease, &source_root).await;
5922 let result = Err(if Instant::now() >= deadline
5923 || error.message.contains("timed out")
5924 || error.message.contains("deadline")
5925 {
5926 failure(
5927 NodeFailureCode::SpawnDeadlineExceeded,
5928 "managed worktree creation exceeded the spawn deadline",
5929 )
5930 } else {
5931 managed_git_worktree_failure(error)
5932 });
5933 self.remember_managed_spawn_attempt(
5934 durable_v2_key,
5935 request,
5936 result.clone(),
5937 accepted_at,
5938 );
5939 return result;
5940 }
5941 };
5942 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
5943 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
5944 let result = Err(failure(
5945 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5946 "created worktree identity did not match its durable lease",
5947 ));
5948 self.remember_managed_spawn_attempt(
5949 durable_v2_key,
5950 request,
5951 result.clone(),
5952 accepted_at,
5953 );
5954 return result;
5955 }
5956 {
5957 let mut registry = self.managed_worktrees.lock()
5958 .unwrap_or_else(|poisoned| poisoned.into_inner());
5959 let current = registry.get_mut(&lease.lease_id)
5960 .expect("allocated managed worktree lease remains present");
5961 current.expected_head = Some(created.head.clone());
5962 current.state = ManagedWorktreeLeaseState::Ready;
5963 current.updated_at_unix_ms = unix_time_ms();
5964 }
5965 if let Err(error) = self.persist_state() {
5966 self.mark_managed_recovery_required(
5967 &lease.lease_id,
5968 ManagedWorktreeCleanupFailure::Backend,
5969 );
5970 let result = Err(persistence_failure(error));
5971 self.remember_managed_spawn_attempt(
5972 durable_v2_key,
5973 request,
5974 result.clone(),
5975 accepted_at,
5976 );
5977 return result;
5978 }
5979 let ready = self.managed_worktrees.lock()
5980 .unwrap_or_else(|poisoned| poisoned.into_inner())
5981 .get(&lease.lease_id)
5982 .expect("ready managed worktree lease remains present")
5983 .snapshot();
5984 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
5985 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
5986 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
5987 let result = Err(error);
5988 self.remember_managed_spawn_attempt(
5989 durable_v2_key,
5990 request,
5991 result.clone(),
5992 accepted_at,
5993 );
5994 return result;
5995 }
5996 let spawn = self.spawn_session_with_deadline(
5997 lease.workspace_id.clone(),
5998 resolved.provider.clone(),
5999 resolved.mode,
6000 resolved.terminal_size,
6001 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6002 environment_profile.clone(),
6003 bundle.clone(),
6004 context.clone(),
6005 Some(lease.lease_id.clone()),
6006 Some(runtime_policy),
6007 SpawnRecordPolicy::Always,
6008 Some(deadline),
6009 &required_capabilities,
6010 None,
6011 resolved.approval_level,
6012 resolved.network_allowlist.clone(),
6013 resolved.browser_profile_id.clone(),
6014 ).await;
6015 let (session, effective_runtime_policy) = match spawn {
6016 Ok(spawned) => spawned,
6017 Err(error) => {
6018 if self
6019 .cleanup_managed_worktree(&lease.lease_id, true)
6020 .await
6021 .is_err()
6022 {
6023 self.mark_managed_recovery_required(
6024 &lease.lease_id,
6025 ManagedWorktreeCleanupFailure::Backend,
6026 );
6027 }
6028 let result = Err(error);
6029 self.remember_managed_spawn_attempt(
6030 durable_v2_key,
6031 request,
6032 result.clone(),
6033 accepted_at,
6034 );
6035 return result;
6036 }
6037 };
6038 let (record_id, raw_inventory_record_id) = {
6039 let mut bindings = self.session_bindings.lock()
6040 .unwrap_or_else(|poisoned| poisoned.into_inner());
6041 let binding = bindings.get_mut(&session.session.instance_id)
6042 .expect("successful spawn retains its session binding");
6043 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6044 managed_spawn_record_ownership(binding, effective_runtime_policy)
6045 };
6046 let bound = self.managed_worktrees.lock()
6047 .unwrap_or_else(|poisoned| poisoned.into_inner())
6048 .bind_session(
6049 &lease.lease_id,
6050 ManagedWorktreeSessionHolder {
6051 incarnation_id: self.incarnation_id,
6052 instance_id: session.session.instance_id,
6053 generation: session.session.generation,
6054 },
6055 record_id,
6056 unix_time_ms(),
6057 )
6058 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6059 let snapshot = match bound {
6060 Ok(snapshot) => snapshot,
6061 Err(error) => {
6062 if raw_inventory_record_id
6063 .as_ref()
6064 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6065 {
6066 self.mark_managed_recovery_required(
6067 &lease.lease_id,
6068 ManagedWorktreeCleanupFailure::Backend,
6069 );
6070 }
6071 let _ = self.remove_session(&session).await;
6072 self.mark_managed_recovery_required(
6073 &lease.lease_id,
6074 ManagedWorktreeCleanupFailure::Backend,
6075 );
6076 let result = Err(error);
6077 self.remember_managed_spawn_attempt(
6078 durable_v2_key,
6079 request,
6080 result.clone(),
6081 accepted_at,
6082 );
6083 return result;
6084 }
6085 };
6086 if let Err(error) = self.persist_state() {
6087 if raw_inventory_record_id
6088 .as_ref()
6089 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6090 {
6091 self.mark_managed_recovery_required(
6092 &lease.lease_id,
6093 ManagedWorktreeCleanupFailure::Backend,
6094 );
6095 }
6096 let _ = self.remove_session(&session).await;
6097 if self
6098 .cleanup_managed_worktree(&lease.lease_id, true)
6099 .await
6100 .is_err()
6101 {
6102 self.mark_managed_recovery_required(
6103 &lease.lease_id,
6104 ManagedWorktreeCleanupFailure::Backend,
6105 );
6106 }
6107 let result = Err(persistence_failure(error));
6108 self.remember_managed_spawn_attempt(
6109 durable_v2_key,
6110 request,
6111 result.clone(),
6112 accepted_at,
6113 );
6114 return result;
6115 }
6116 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6117 let receipt = managed_spawn_receipt(
6118 &resolved,
6119 self.incarnation_id,
6120 session,
6121 snapshot,
6122 environment_profile,
6123 bundle,
6124 context,
6125 );
6126 let result = Ok(receipt);
6127 self.remember_managed_spawn_attempt(
6128 durable_v2_key,
6129 request,
6130 result.clone(),
6131 accepted_at,
6132 );
6133 result
6134 }
6135
6136 async fn spawn_managed_worktree_v2(
6137 &self,
6138 request: ManagedWorktreeSpawnRequestV2,
6139 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6140 if request.spawn_spec.target.node_id != self.node_id {
6141 return Err(failure(
6142 NodeFailureCode::SpawnTargetMismatch,
6143 "spawn target does not match this node",
6144 ));
6145 }
6146 if request.spawn_spec.target.worktree_id.is_some() {
6147 return Err(failure(
6148 NodeFailureCode::InvalidRequest,
6149 "managed worktree spawn must not select a caller-provided worktree",
6150 ));
6151 }
6152 let request_digest = managed_spawn_request_digest_v2(&request)?;
6153 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6154 match replay {
6155 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6156 DurableManagedSpawnReplayDecision::PendingLinked => {
6157 return Err(failure(
6158 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6159 "managed worktree V2 reservation has an incomplete durable allocation",
6160 ));
6161 }
6162 DurableManagedSpawnReplayDecision::Reserved => {}
6163 }
6164 let validated = (|| {
6165 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6166 let profile = self.managed_profile(
6167 &resolved.target.workspace_id,
6168 &request.worktree_profile_id,
6169 )?;
6170 if profile.revision() != &request.expected_profile_revision {
6171 return Err(failure(
6172 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6173 "managed worktree profile revision does not match the loaded profile",
6174 ));
6175 }
6176 Ok(())
6177 })();
6178 match validated {
6179 Ok(()) => {}
6180 Err(error) => {
6181 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6182 return Err(error);
6183 }
6184 }
6185 let legacy = ManagedWorktreeSpawnRequest {
6186 spawn_spec: request.spawn_spec.clone(),
6187 worktree_profile_id: request.worktree_profile_id.clone(),
6188 };
6189 match self
6190 .spawn_managed_worktree_inner(
6191 legacy,
6192 Some(&request.spawn_spec.idempotency_key),
6193 )
6194 .await
6195 {
6196 Ok(receipt) => {
6197 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6198 Ok(receipt)
6199 }
6200 Err(error) => {
6201 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6202 Err(error)
6203 }
6204 }
6205 }
6206
6207 fn managed_profile(
6208 &self,
6209 workspace_id: &WorkspaceId,
6210 profile_id: &WorktreeProfileId,
6211 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6212 if self.worktree_service_modes.get(workspace_id).copied()
6213 != Some(WorktreeServiceMode::Managed)
6214 {
6215 return Err(failure(
6216 NodeFailureCode::UnsupportedSpawnCapability,
6217 "workspace does not enable managed worktrees",
6218 ));
6219 }
6220 self.managed_worktree_profiles.get(workspace_id)
6221 .and_then(|profiles| profiles.get(profile_id))
6222 .cloned()
6223 .ok_or_else(|| failure(
6224 NodeFailureCode::InvalidRequest,
6225 "managed worktree profile is unavailable for this workspace",
6226 ))
6227 }
6228
6229 async fn reconcile_failed_allocation(
6230 &self,
6231 lease: &ManagedWorktreeLeaseRecord,
6232 source_root: &str,
6233 ) {
6234 match list_git_worktrees(source_root).await {
6235 Ok(worktrees) => {
6236 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6237 None => {
6238 match std::fs::symlink_metadata(&lease.target_root) {
6239 Err(error) if error.kind() == io::ErrorKind::NotFound => {
6240 self.managed_worktrees.lock()
6241 .unwrap_or_else(|poisoned| poisoned.into_inner())
6242 .remove_unmutated(&lease.lease_id);
6243 let _ = self.persist_state();
6244 }
6245 _ => self.mark_managed_recovery_required(
6246 &lease.lease_id,
6247 ManagedWorktreeCleanupFailure::OwnershipConflict,
6248 ),
6249 }
6250 }
6251 Some(item) if exact_owned_worktree(
6252 lease,
6253 &item.path,
6254 item.branch.as_deref(),
6255 &item.head,
6256 ) => {
6257 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6258 }
6259 Some(_) => self.mark_managed_recovery_required(
6260 &lease.lease_id,
6261 ManagedWorktreeCleanupFailure::OwnershipConflict,
6262 ),
6263 }
6264 }
6265 Err(_) => self.mark_managed_recovery_required(
6266 &lease.lease_id,
6267 ManagedWorktreeCleanupFailure::Backend,
6268 ),
6269 }
6270 }
6271
6272 async fn reconcile_managed_worktrees(&self) {
6273 let leases = self.managed_worktrees.lock()
6274 .unwrap_or_else(|poisoned| poisoned.into_inner())
6275 .active_records()
6276 .cloned()
6277 .collect::<Vec<_>>();
6278 for lease in leases {
6279 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6280 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6281 _ => {
6282 self.mark_managed_recovery_required(
6283 &lease.lease_id,
6284 ManagedWorktreeCleanupFailure::OwnershipConflict,
6285 );
6286 continue;
6287 }
6288 };
6289 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6290 self.mark_managed_recovery_required(
6291 &lease.lease_id,
6292 ManagedWorktreeCleanupFailure::OwnershipConflict,
6293 );
6294 continue;
6295 }
6296 let source_root = match self.workspace_root(&lease.source_workspace_id) {
6297 Ok(root) => root,
6298 Err(_) => {
6299 self.mark_managed_recovery_required(
6300 &lease.lease_id,
6301 ManagedWorktreeCleanupFailure::OwnershipConflict,
6302 );
6303 continue;
6304 }
6305 };
6306 let worktrees = match list_git_worktrees(&source_root).await {
6307 Ok(worktrees) => worktrees,
6308 Err(_) => {
6309 self.mark_managed_recovery_required(
6310 &lease.lease_id,
6311 ManagedWorktreeCleanupFailure::Backend,
6312 );
6313 continue;
6314 }
6315 };
6316 let listed = worktrees.iter()
6317 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6318 let Some(listed) = listed else {
6319 match std::fs::symlink_metadata(&lease.target_root) {
6320 Err(error) if error.kind() == io::ErrorKind::NotFound
6321 && lease.state == ManagedWorktreeLeaseState::Allocating
6322 && !lease.has_holders() => {
6323 let removed = self.managed_worktrees.lock()
6324 .unwrap_or_else(|poisoned| poisoned.into_inner())
6325 .tombstone(&lease.lease_id, unix_time_ms());
6326 if self.persist_state().is_ok() && removed.is_some() {
6327 self.publish(NodeEvent::ManagedWorktreeRemoved {
6328 lease_id: lease.lease_id.clone(),
6329 });
6330 }
6331 }
6332 _ => self.mark_managed_recovery_required(
6333 &lease.lease_id,
6334 ManagedWorktreeCleanupFailure::OwnershipConflict,
6335 ),
6336 }
6337 continue;
6338 };
6339 if listed.is_main || listed.is_bare || !exact_owned_worktree(
6340 &lease,
6341 &listed.path,
6342 listed.branch.as_deref(),
6343 &listed.head,
6344 ) {
6345 self.mark_managed_recovery_required(
6346 &lease.lease_id,
6347 ManagedWorktreeCleanupFailure::OwnershipConflict,
6348 );
6349 continue;
6350 }
6351 if !self.workspaces.read()
6352 .unwrap_or_else(|poisoned| poisoned.into_inner())
6353 .contains_key(&lease.workspace_id)
6354 {
6355 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
6356 self.mark_managed_recovery_required(
6357 &lease.lease_id,
6358 ManagedWorktreeCleanupFailure::Backend,
6359 );
6360 continue;
6361 }
6362 }
6363 let snapshot = {
6364 let mut registry = self.managed_worktrees.lock()
6365 .unwrap_or_else(|poisoned| poisoned.into_inner());
6366 let current = registry.get_mut(&lease.lease_id)
6367 .expect("reconciled managed lease remains present");
6368 current.expected_head = Some(listed.head.clone());
6369 current.cleanup_failure = None;
6370 current.state = if current.has_holders() {
6371 ManagedWorktreeLeaseState::InUse
6372 } else if current.retention == ManagedWorktreeRetention::Retain {
6373 ManagedWorktreeLeaseState::Retained
6374 } else {
6375 ManagedWorktreeLeaseState::Ready
6376 };
6377 current.updated_at_unix_ms = unix_time_ms();
6378 current.snapshot()
6379 };
6380 if self.persist_state().is_err() {
6381 self.mark_managed_recovery_required(
6382 &lease.lease_id,
6383 ManagedWorktreeCleanupFailure::Backend,
6384 );
6385 continue;
6386 }
6387 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6388 if snapshot.state == ManagedWorktreeLeaseState::Ready {
6389 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
6390 }
6391 }
6392 }
6393
6394 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
6414 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
6415 .session_records
6416 .lock()
6417 .unwrap_or_else(|poisoned| poisoned.into_inner())
6418 .records
6419 .values()
6420 .filter(|record| {
6421 record.state == ManagedSessionState::Live
6422 && record.exported_context.is_none()
6423 && record.provider_session.is_some()
6424 })
6425 .filter_map(|record| {
6426 record
6427 .active_session
6428 .clone()
6429 .map(|session| (record.record_id.clone(), session))
6430 })
6431 .collect();
6432 for (record_id, session) in candidates {
6433 let shared = Arc::clone(self);
6434 tokio::spawn(async move {
6435 shared
6436 .reconcile_context_pack_export_for_record(&record_id, session)
6437 .await;
6438 });
6439 }
6440 }
6441
6442 async fn reconcile_context_pack_export_for_record(
6461 &self,
6462 record_id: &SessionRecordId,
6463 session: SessionAddress,
6464 ) {
6465 let Ok(record) = self.record(record_id) else {
6466 return;
6467 };
6468 if !matches!(
6469 record.state,
6470 ManagedSessionState::Live | ManagedSessionState::Dormant
6471 ) || record.exported_context.is_some()
6472 || record.provider_session.is_none()
6473 {
6474 return;
6475 }
6476 let Ok(receipt) = self
6483 .export_context_pack_for_session_record(&record.record_id, &session, true)
6484 .await
6485 else {
6486 return;
6487 };
6488 self.set_exported_context(&record.record_id, receipt);
6489 }
6490
6491 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
6496 let transaction = self
6497 .state_transaction
6498 .lock()
6499 .unwrap_or_else(|poisoned| poisoned.into_inner());
6500 let (previous, updated) = {
6501 let mut records = self
6502 .session_records
6503 .lock()
6504 .unwrap_or_else(|poisoned| poisoned.into_inner());
6505 let Some(record) = records.records.get_mut(record_id) else {
6506 return;
6507 };
6508 if record.exported_context.is_some() {
6509 return;
6510 }
6511 let previous = record.clone();
6512 record.exported_context = Some(receipt);
6513 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
6514 (previous, record.clone())
6515 };
6516 if self.persist_state_locked().is_err() {
6517 self.session_records
6518 .lock()
6519 .unwrap_or_else(|poisoned| poisoned.into_inner())
6520 .records
6521 .insert(record_id.clone(), previous);
6522 return;
6523 }
6524 drop(transaction);
6525 self.publish_record(updated);
6526 }
6527
6528 fn managed_record_export_target_for_instance(
6538 &self,
6539 instance_id: AgentInstanceId,
6540 generation: SessionGeneration,
6541 ) -> Option<(SessionRecordId, SessionAddress)> {
6542 let bindings = self
6543 .session_bindings
6544 .lock()
6545 .unwrap_or_else(|poisoned| poisoned.into_inner());
6546 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
6547 let record_id = binding.record_id.clone()?;
6548 Some((
6549 record_id,
6550 SessionAddress {
6551 workspace_id: binding.workspace_id.clone(),
6552 session: SessionKey { instance_id, generation },
6553 },
6554 ))
6555 }
6556
6557 fn mark_managed_recovery_required(
6558 &self,
6559 lease_id: &ManagedWorktreeLeaseId,
6560 failure_kind: ManagedWorktreeCleanupFailure,
6561 ) {
6562 let snapshot = {
6563 let mut registry = self.managed_worktrees.lock()
6564 .unwrap_or_else(|poisoned| poisoned.into_inner());
6565 let Some(lease) = registry.get_mut(lease_id) else { return };
6566 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
6567 lease.cleanup_failure = Some(failure_kind);
6568 lease.updated_at_unix_ms = unix_time_ms();
6569 lease.snapshot()
6570 };
6571 let _ = self.persist_state();
6572 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
6573 }
6574
6575 fn set_persistence_error(&self, error: Option<String>) {
6576 *self
6577 .persistence_error
6578 .write()
6579 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
6580 .map(|message| session_registry::sanitized_persistence_summary(&message));
6581 }
6582
6583 fn persist_state(&self) -> io::Result<()> {
6584 let _transaction = self
6585 .state_transaction
6586 .lock()
6587 .unwrap_or_else(|poisoned| poisoned.into_inner());
6588 self.persist_state_locked()
6589 }
6590
6591 fn persist_state_locked(&self) -> io::Result<()> {
6592 let workspaces = self
6593 .workspaces
6594 .read()
6595 .unwrap_or_else(|poisoned| poisoned.into_inner())
6596 .clone();
6597 let records = self
6598 .session_records
6599 .lock()
6600 .unwrap_or_else(|poisoned| poisoned.into_inner())
6601 .records
6602 .values()
6603 .cloned()
6604 .collect::<Vec<_>>();
6605 let mut managed = self.managed_worktrees
6606 .lock()
6607 .unwrap_or_else(|poisoned| poisoned.into_inner());
6608 managed.reattach_record_holders(
6609 &managed_worktree_record_holders(&records),
6610 unix_time_ms(),
6611 );
6612 let materializations = self
6613 .materializations
6614 .lock()
6615 .unwrap_or_else(|poisoned| poisoned.into_inner())
6616 .values()
6617 .cloned()
6618 .collect::<Vec<_>>();
6619 let managed_spawn_replays = self.managed_spawn_replays
6620 .lock()
6621 .unwrap_or_else(|poisoned| poisoned.into_inner())
6622 .values()
6623 .cloned()
6624 .collect::<Vec<_>>();
6625 let result = session_registry::save_v10(
6626 self.state_path.as_deref(),
6627 &self.node_id,
6628 &workspaces,
6629 &records,
6630 &managed.records(),
6631 &managed.tombstones(),
6632 &materializations,
6633 &managed_spawn_replays,
6634 );
6635 match &result {
6636 Ok(warning) => self.set_persistence_error(warning.clone()),
6637 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
6638 }
6639 result.map(|_| ())
6640 }
6641
6642 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
6643 let _mutation_guard = self.mutation_gate.lock().await;
6644 self.begin_shutdown_locked().await
6645 }
6646
6647 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
6648 if self.shutdown.swap(true, Ordering::AcqRel) {
6649 return Ok(());
6650 }
6651 if let Some(registry) = self.harness_mcp_registry.as_ref() {
6652 let instances = registry.shutdown();
6653 if let Some(control) = self.native_launch_profile_control.as_ref() {
6654 for instance_id in instances {
6655 control.clear_native_mcp_server_launch_overlay(instance_id);
6656 }
6657 }
6658 }
6659 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
6660 let mut first_error = None;
6661 for session in &self.handle.snapshot().sessions {
6662 let remaining = deadline.saturating_duration_since(Instant::now());
6663 if remaining.is_zero() {
6664 first_error.get_or_insert_with(|| {
6665 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
6666 });
6667 break;
6668 }
6669 if let Err(error) = self
6670 .dispatch_bounded(
6671 ControlCommand::Stop {
6672 instance_id: session.instance_id,
6673 force: true,
6674 },
6675 remaining,
6676 )
6677 .await
6678 {
6679 first_error.get_or_insert(error);
6680 }
6681 }
6682 self.shutdown_notify.notify_waiters();
6683 match first_error {
6684 Some(error) => Err(error),
6685 None => Ok(()),
6686 }
6687 }
6688
6689 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
6690 let bindings = self
6691 .session_bindings
6692 .lock()
6693 .unwrap_or_else(|poisoned| poisoned.into_inner());
6694 if bindings.len() >= CONTROL_SESSIONS_MAX {
6695 Err(failure(
6696 NodeFailureCode::BackendBusy,
6697 "node session-binding capacity is full; remove an existing session first",
6698 ))
6699 } else {
6700 Ok(())
6701 }
6702 }
6703
6704 fn ensure_workspace_environment_profile_compatible(
6711 &self,
6712 workspace_id: &WorkspaceId,
6713 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
6714 ) -> Result<(), NodeFailure> {
6715 let bindings = self
6716 .session_bindings
6717 .lock()
6718 .unwrap_or_else(|poisoned| poisoned.into_inner());
6719 for binding in bindings.values() {
6720 if &binding.workspace_id != workspace_id {
6721 continue;
6722 }
6723 if binding.environment_profile.as_ref() != environment_profile {
6724 return Err(failure(
6725 NodeFailureCode::WorkspaceBusy,
6726 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
6727 ));
6728 }
6729 }
6730 Ok(())
6731 }
6732
6733 #[cfg(test)]
6734 fn bind_session(
6735 &self,
6736 instance_id: AgentInstanceId,
6737 workspace_id: WorkspaceId,
6738 generation: SessionGeneration,
6739 ) {
6740 self.bind_session_with_policy(
6741 &SessionAddress {
6742 workspace_id,
6743 session: SessionKey { instance_id, generation },
6744 },
6745 ProviderRuntimePolicy::raw_pty(),
6746 None,
6747 );
6748 }
6749
6750 #[cfg(test)]
6751 fn bind_session_with_policy(
6752 &self,
6753 address: &SessionAddress,
6754 runtime_policy: ProviderRuntimePolicy,
6755 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6756 ) {
6757 self.bind_session_with_materialization(
6758 address,
6759 runtime_policy,
6760 environment_profile,
6761 None,
6762 None,
6763 None,
6764 );
6765 }
6766
6767 fn bind_session_with_materialization(
6768 &self,
6769 address: &SessionAddress,
6770 runtime_policy: ProviderRuntimePolicy,
6771 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6772 bundle: Option<ResolvedBundleReceipt>,
6773 context: Option<ResolvedContextPackReceipt>,
6774 materialization_id: Option<MaterializationId>,
6775 ) {
6776 let mut bindings = self
6777 .session_bindings
6778 .lock()
6779 .unwrap_or_else(|poisoned| poisoned.into_inner());
6780 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
6781 debug_assert!(!bindings.contains_key(&address.session.instance_id));
6782 bindings.insert(address.session.instance_id, SessionBinding {
6783 workspace_id: address.workspace_id.clone(),
6784 generation: address.session.generation,
6785 runtime_policy,
6786 pending_resume: None,
6787 record_id: None,
6788 managed_worktree_lease_id: None,
6789 environment_profile,
6790 bundle,
6791 context,
6792 materialization_id,
6793 });
6794 }
6795
6796 #[cfg(test)]
6797 fn bind_managed_session(
6798 &self,
6799 address: &SessionAddress,
6800 record_id: SessionRecordId,
6801 runtime_policy: ProviderRuntimePolicy,
6802 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6803 ) {
6804 self.bind_managed_session_with_materialization(
6805 address,
6806 record_id,
6807 runtime_policy,
6808 environment_profile,
6809 None,
6810 None,
6811 None,
6812 );
6813 }
6814
6815 fn bind_managed_session_with_materialization(
6816 &self,
6817 address: &SessionAddress,
6818 record_id: SessionRecordId,
6819 runtime_policy: ProviderRuntimePolicy,
6820 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6821 bundle: Option<ResolvedBundleReceipt>,
6822 context: Option<ResolvedContextPackReceipt>,
6823 materialization_id: Option<MaterializationId>,
6824 ) {
6825 let mut bindings = self
6826 .session_bindings
6827 .lock()
6828 .unwrap_or_else(|poisoned| poisoned.into_inner());
6829 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
6830 debug_assert!(!bindings.contains_key(&address.session.instance_id));
6831 bindings.insert(
6832 address.session.instance_id,
6833 SessionBinding {
6834 workspace_id: address.workspace_id.clone(),
6835 generation: address.session.generation,
6836 runtime_policy,
6837 pending_resume: None,
6838 record_id: Some(record_id),
6839 managed_worktree_lease_id: None,
6840 environment_profile,
6841 bundle,
6842 context,
6843 materialization_id,
6844 },
6845 );
6846 }
6847
6848 #[cfg(test)]
6849 fn bind_spawn_session(
6850 &self,
6851 address: &SessionAddress,
6852 provider: AgentId,
6853 mode: SessionMode,
6854 runtime_policy: ProviderRuntimePolicy,
6855 record_policy: SpawnRecordPolicy,
6856 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6857 ) -> Result<Option<SessionRecordId>, NodeFailure> {
6858 self.bind_spawn_session_with_materialization(
6859 address,
6860 provider,
6861 mode,
6862 runtime_policy,
6863 record_policy,
6864 environment_profile,
6865 None,
6866 None,
6867 None,
6868 )
6869 }
6870
6871 fn bind_spawn_session_with_materialization(
6872 &self,
6873 address: &SessionAddress,
6874 provider: AgentId,
6875 mode: SessionMode,
6876 runtime_policy: ProviderRuntimePolicy,
6877 record_policy: SpawnRecordPolicy,
6878 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6879 bundle: Option<ResolvedBundleReceipt>,
6880 context: Option<ResolvedContextPackReceipt>,
6881 materialization_id: Option<MaterializationId>,
6882 ) -> Result<Option<SessionRecordId>, NodeFailure> {
6883 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
6884 && !runtime_policy.provider_session_identity
6885 {
6886 self.bind_session_with_materialization(
6887 address,
6888 runtime_policy,
6889 environment_profile,
6890 bundle,
6891 context,
6892 materialization_id,
6893 );
6894 return Ok(None);
6895 }
6896 let record_owns_lifecycle = runtime_policy.provider_session_identity;
6897 let record = self.new_record(
6898 address,
6899 provider,
6900 mode,
6901 active_record_state(runtime_policy.provider_session_identity, false),
6902 None,
6903 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
6904 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
6905 record_owns_lifecycle.then(|| context.clone()).flatten(),
6906 )?;
6907 let record_id = record.record_id.clone();
6908 let transaction = self
6909 .state_transaction
6910 .lock()
6911 .unwrap_or_else(|poisoned| poisoned.into_inner());
6912 self.insert_record(record.clone())?;
6913 let previous_materialization = if record_owns_lifecycle {
6914 if let Some(materialization_id) = materialization_id.as_ref() {
6915 let mut materializations = self
6916 .materializations
6917 .lock()
6918 .unwrap_or_else(|poisoned| poisoned.into_inner());
6919 let Some(ownership) = materializations.get_mut(materialization_id) else {
6920 drop(materializations);
6921 self.remove_record_memory(&record_id);
6922 return Err(failure(
6923 NodeFailureCode::BackendOperationFailed,
6924 "session environment ownership disappeared before binding",
6925 ));
6926 };
6927 let previous = ownership.clone();
6928 if ownership
6929 .transfer_to_record(record_id.clone(), unix_time_ms())
6930 .is_err()
6931 {
6932 drop(materializations);
6933 self.remove_record_memory(&record_id);
6934 return Err(failure(
6935 NodeFailureCode::BackendOperationFailed,
6936 "session environment ownership transfer failed",
6937 ));
6938 }
6939 Some(previous)
6940 } else {
6941 None
6942 }
6943 } else {
6944 None
6945 };
6946 self.bind_managed_session_with_materialization(
6947 address,
6948 record_id.clone(),
6949 runtime_policy,
6950 environment_profile,
6951 bundle,
6952 context,
6953 materialization_id.clone(),
6954 );
6955 if let Err(error) = self.persist_state_locked() {
6956 self.remove_binding(address);
6957 self.remove_record_memory(&record_id);
6958 if let (Some(materialization_id), Some(previous)) =
6959 (materialization_id.as_ref(), previous_materialization)
6960 {
6961 self.materializations
6962 .lock()
6963 .unwrap_or_else(|poisoned| poisoned.into_inner())
6964 .insert(materialization_id.clone(), previous);
6965 }
6966 return Err(persistence_failure(error));
6967 }
6968 drop(transaction);
6969 self.publish_record(record);
6970 Ok(Some(record_id))
6971 }
6972
6973 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
6974 for _ in 0..8 {
6975 let bytes = random_nonce().map_err(|error| {
6976 failure(
6977 NodeFailureCode::BackendOperationFailed,
6978 &format!("session record identity generation failed: {error}"),
6979 )
6980 })?;
6981 let mut value = String::with_capacity(27);
6982 value.push_str("sr-");
6983 for byte in &bytes[..12] {
6984 use std::fmt::Write as _;
6985 let _ = write!(&mut value, "{byte:02x}");
6986 }
6987 let record_id = SessionRecordId::new(value).map_err(|error| {
6988 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
6989 })?;
6990 let records = self
6991 .session_records
6992 .lock()
6993 .unwrap_or_else(|poisoned| poisoned.into_inner());
6994 if !records.records.contains_key(&record_id) {
6995 return Ok(record_id);
6996 }
6997 }
6998 Err(failure(
6999 NodeFailureCode::BackendBusy,
7000 "could not allocate a unique session record ID",
7001 ))
7002 }
7003
7004 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7005 for _ in 0..8 {
7006 let bytes = random_nonce().map_err(|error| {
7007 failure(
7008 NodeFailureCode::BackendOperationFailed,
7009 &format!("task identity generation failed: {error}"),
7010 )
7011 })?;
7012 let mut nonce = [0_u8; 12];
7013 nonce.copy_from_slice(&bytes[..12]);
7014 let task_id = TaskId::from_nonce(nonce);
7015 let records = self
7016 .session_records
7017 .lock()
7018 .unwrap_or_else(|poisoned| poisoned.into_inner());
7019 if !records.records.values().any(|record| {
7020 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7021 == Some(&task_id)
7022 }) {
7023 return Ok(task_id);
7024 }
7025 }
7026 Err(failure(
7027 NodeFailureCode::BackendBusy,
7028 "could not allocate a unique task ID",
7029 ))
7030 }
7031
7032 fn default_record_name(&self, provider: &AgentId) -> String {
7033 let records = self
7034 .session_records
7035 .lock()
7036 .unwrap_or_else(|poisoned| poisoned.into_inner());
7037 let ordinal = records
7038 .records
7039 .values()
7040 .filter(|record| &record.provider == provider)
7041 .count()
7042 .saturating_add(1);
7043 format!("{} #{ordinal}", provider.as_str())
7044 }
7045
7046 fn new_record(
7047 &self,
7048 address: &SessionAddress,
7049 provider: AgentId,
7050 mode: SessionMode,
7051 state: ManagedSessionState,
7052 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7053 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7054 bundle: Option<ResolvedBundleReceipt>,
7055 context: Option<ResolvedContextPackReceipt>,
7056 ) -> Result<ManagedSessionRecord, NodeFailure> {
7057 let canonical_root = self.workspace_root(&address.workspace_id)?;
7058 let now = unix_time_ms();
7059 Ok(ManagedSessionRecord {
7060 record_id: self.allocate_record_id()?,
7061 display_name: self.default_record_name(&provider),
7062 provider,
7063 mode,
7064 state,
7065 workspace_id: address.workspace_id.clone(),
7066 canonical_root: opaque_windows_path(canonical_root),
7067 provider_session,
7068 active_session: Some(address.clone()),
7069 environment_profile,
7070 bundle,
7071 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7072 context,
7073 exported_context: None,
7074 task_binding: None,
7075 created_at_unix_ms: now,
7076 updated_at_unix_ms: now,
7077 last_error: None,
7078 })
7079 }
7080
7081 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7082 let mut records = self
7083 .session_records
7084 .lock()
7085 .unwrap_or_else(|poisoned| poisoned.into_inner());
7086 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7087 return Err(failure(
7088 NodeFailureCode::BackendBusy,
7089 "managed session record capacity is full; forget an inactive record first",
7090 ));
7091 }
7092 if let Some(identity) = record.provider_session.as_ref() {
7093 if records.records.values().any(|candidate| {
7094 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7095 session_registry::same_provider_session(
7096 &candidate.provider,
7097 candidate_identity,
7098 &record.provider,
7099 identity,
7100 )
7101 })
7102 }) {
7103 return Err(failure(
7104 NodeFailureCode::SessionRecordConflict,
7105 "provider session is already represented by another managed record",
7106 ));
7107 }
7108 }
7109 match records.records.entry(record.record_id.clone()) {
7110 std::collections::btree_map::Entry::Vacant(entry) => {
7111 entry.insert(record);
7112 }
7113 std::collections::btree_map::Entry::Occupied(_) => {
7114 return Err(failure(
7115 NodeFailureCode::SessionRecordConflict,
7116 "session record ID collision",
7117 ));
7118 }
7119 }
7120 Ok(())
7121 }
7122
7123 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7124 self.session_records
7125 .lock()
7126 .unwrap_or_else(|poisoned| poisoned.into_inner())
7127 .records
7128 .get(record_id)
7129 .cloned()
7130 .ok_or_else(|| {
7131 failure(
7132 NodeFailureCode::UnknownSessionRecord,
7133 "managed session record does not exist",
7134 )
7135 })
7136 }
7137
7138 fn revalidate_session_record_preview(
7139 &self,
7140 expected: &ManagedSessionRecord,
7141 identity: &ProviderSessionIdentity,
7142 ) -> Result<(), NodeFailure> {
7143 let current = self.record(&expected.record_id)?;
7144 let current_root = self.workspace_root(¤t.workspace_id)?;
7145 if current.provider != expected.provider
7146 || current.workspace_id != expected.workspace_id
7147 || current.canonical_root != expected.canonical_root
7148 || current.provider_session.as_ref() != Some(identity)
7149 || !platform::roots_equal(
7150 ¤t_root,
7151 windows_path_text(¤t.canonical_root),
7152 )
7153 {
7154 return Err(failure(
7155 NodeFailureCode::SessionRecordConflict,
7156 "managed session changed while its preview was loading",
7157 ));
7158 }
7159 Ok(())
7160 }
7161
7162 fn revalidate_session_record_context_export(
7163 &self,
7164 expected: &ManagedSessionRecord,
7165 identity: Option<&ProviderSessionIdentity>,
7166 session: &SessionAddress,
7167 allow_clean_detachment: bool,
7168 ) -> Result<(), NodeFailure> {
7169 let current = self.record(&expected.record_id)?;
7170 let current_root = self.workspace_root(¤t.workspace_id)?;
7171 let snapshot = self.internal_session_snapshot(session)?;
7172 let binding = self
7173 .session_bindings
7174 .lock()
7175 .unwrap_or_else(|poisoned| poisoned.into_inner())
7176 .get(&session.session.instance_id)
7177 .cloned()
7178 .ok_or_else(|| {
7179 failure(
7180 NodeFailureCode::SessionRecordConflict,
7181 "managed session runtime binding disappeared during context export",
7182 )
7183 })?;
7184 if !session_record_context_export_binding_is_exact(
7185 expected,
7186 ¤t,
7187 identity,
7188 session,
7189 &binding,
7190 &snapshot.agent_id,
7191 ¤t_root,
7192 allow_clean_detachment,
7193 ) || !session_record_context_export_source_is_usable(
7194 ¤t,
7195 &snapshot.status,
7196 allow_clean_detachment,
7197 ) {
7198 return Err(failure(
7199 NodeFailureCode::SessionRecordConflict,
7200 "managed session binding changed during context export",
7201 ));
7202 }
7203 Ok(())
7204 }
7205
7206 fn revalidate_loaded_history(
7207 &self,
7208 session: &SessionAddress,
7209 candidate_id: &str,
7210 loaded: &HistorySessionRecord,
7211 ) -> Result<(), NodeFailure> {
7212 let snapshot = self.internal_session_snapshot(session)?;
7213 if snapshot.history.pending.is_some()
7214 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7215 || snapshot.history.loaded.as_ref() != Some(loaded)
7216 {
7217 return Err(failure(
7218 NodeFailureCode::SessionRecordConflict,
7219 "managed session history changed during context export",
7220 ));
7221 }
7222 Ok(())
7223 }
7224
7225 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7226 self.session_records
7227 .lock()
7228 .unwrap_or_else(|poisoned| poisoned.into_inner())
7229 .records
7230 .remove(record_id)
7231 }
7232
7233 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7234 if self.destroy_record_materialization(record_id)? {
7235 self.publish(NodeEvent::SessionRecordRemoved {
7236 record_id: record_id.clone(),
7237 });
7238 return Ok(());
7239 }
7240 let transaction = self
7241 .state_transaction
7242 .lock()
7243 .unwrap_or_else(|poisoned| poisoned.into_inner());
7244 let Some(removed) = self.remove_record_memory(record_id) else {
7245 return Ok(());
7246 };
7247 if let Err(error) = self.persist_state_locked() {
7248 let _ = self.insert_record(removed);
7249 return Err(persistence_failure(error));
7250 }
7251 drop(transaction);
7252 self.publish(NodeEvent::SessionRecordRemoved {
7253 record_id: record_id.clone(),
7254 });
7255 Ok(())
7256 }
7257
7258 fn publish_record(&self, record: ManagedSessionRecord) {
7259 self.publish(NodeEvent::SessionRecordUpserted { record });
7260 }
7261
7262 fn mark_record_error(
7263 &self,
7264 record_id: &SessionRecordId,
7265 message: &str,
7266 ) -> Result<(), NodeFailure> {
7267 let transaction = self
7268 .state_transaction
7269 .lock()
7270 .unwrap_or_else(|poisoned| poisoned.into_inner());
7271 let previous = {
7272 let mut records = self
7273 .session_records
7274 .lock()
7275 .unwrap_or_else(|poisoned| poisoned.into_inner());
7276 let Some(record) = records.records.get_mut(record_id) else {
7277 return Ok(());
7278 };
7279 let previous = record.clone();
7280 record.state = ManagedSessionState::Unavailable;
7281 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7282 record.updated_at_unix_ms = unix_time_ms();
7283 previous
7284 };
7285 if let Err(error) = self.persist_state_locked() {
7286 self.session_records
7287 .lock()
7288 .unwrap_or_else(|poisoned| poisoned.into_inner())
7289 .records
7290 .insert(record_id.clone(), previous);
7291 return Err(persistence_failure(error));
7292 }
7293 let record = self.record(record_id)?;
7294 drop(transaction);
7295 self.publish_record(record);
7296 Ok(())
7297 }
7298
7299 fn rename_session_record(
7300 &self,
7301 record_id: &SessionRecordId,
7302 display_name: String,
7303 ) -> Result<ManagedSessionRecord, NodeFailure> {
7304 validate_display_name(&display_name)
7305 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7306 let _transaction = self
7307 .state_transaction
7308 .lock()
7309 .unwrap_or_else(|poisoned| poisoned.into_inner());
7310 let previous = {
7311 let mut records = self
7312 .session_records
7313 .lock()
7314 .unwrap_or_else(|poisoned| poisoned.into_inner());
7315 let record = records.records.get_mut(record_id).ok_or_else(|| {
7316 failure(
7317 NodeFailureCode::UnknownSessionRecord,
7318 "managed session record does not exist",
7319 )
7320 })?;
7321 let previous = record.clone();
7322 record.display_name = display_name;
7323 record.updated_at_unix_ms = unix_time_ms();
7324 previous
7325 };
7326 if let Err(error) = self.persist_state_locked() {
7327 self.session_records
7328 .lock()
7329 .unwrap_or_else(|poisoned| poisoned.into_inner())
7330 .records
7331 .insert(record_id.clone(), previous);
7332 return Err(persistence_failure(error));
7333 }
7334 let updated = self.record(record_id)?;
7335 self.publish_record(updated.clone());
7336 Ok(updated)
7337 }
7338
7339 fn set_session_task(
7340 &self,
7341 record_id: &SessionRecordId,
7342 expected_revision: u64,
7343 target: SessionTaskTargetV1,
7344 ) -> Result<ManagedSessionRecord, NodeFailure> {
7345 let transaction = self
7346 .state_transaction
7347 .lock()
7348 .unwrap_or_else(|poisoned| poisoned.into_inner());
7349 let current = self.record(record_id)?;
7350 let current_task_id = current
7351 .task_binding
7352 .as_ref()
7353 .and_then(|binding| binding.task_id.as_ref());
7354 let current_revision = current
7355 .task_binding
7356 .as_ref()
7357 .map_or(0, |binding| binding.revision);
7358 if expected_revision != current_revision {
7359 return Err(failure(
7360 NodeFailureCode::SessionRecordConflict,
7361 "managed session task binding revision changed",
7362 ));
7363 }
7364 let exact = match &target {
7365 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
7366 SessionTaskTargetV1::Clear => current_task_id.is_none(),
7367 SessionTaskTargetV1::New => false,
7368 };
7369 if exact {
7370 return Ok(current);
7371 }
7372 let revision = current_revision.checked_add(1).ok_or_else(|| {
7373 failure(
7374 NodeFailureCode::SessionRecordConflict,
7375 "managed session task binding revision is exhausted",
7376 )
7377 })?;
7378 let task_id = match target {
7379 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
7380 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
7381 SessionTaskTargetV1::Clear => None,
7382 };
7383 let changed_at_unix_ms = unix_time_ms()
7384 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
7385 failure(
7386 NodeFailureCode::SessionRecordConflict,
7387 "managed session record timestamp is exhausted",
7388 )
7389 })?);
7390 let updated = {
7391 let mut records = self
7392 .session_records
7393 .lock()
7394 .unwrap_or_else(|poisoned| poisoned.into_inner());
7395 let record = records.records.get_mut(record_id).ok_or_else(|| {
7396 failure(
7397 NodeFailureCode::UnknownSessionRecord,
7398 "managed session record does not exist",
7399 )
7400 })?;
7401 record.task_binding = Some(SessionTaskBindingV1 {
7402 revision,
7403 task_id,
7404 changed_at_unix_ms,
7405 });
7406 record.updated_at_unix_ms = changed_at_unix_ms;
7407 record.clone()
7408 };
7409 if let Err(error) = self.persist_state_locked() {
7410 self.session_records
7411 .lock()
7412 .unwrap_or_else(|poisoned| poisoned.into_inner())
7413 .records
7414 .insert(record_id.clone(), current);
7415 return Err(persistence_failure(error));
7416 }
7417 drop(transaction);
7418 self.publish_record(updated.clone());
7419 Ok(updated)
7420 }
7421
7422 fn index_provider_session(
7423 &self,
7424 workspace_id: WorkspaceId,
7425 provider: AgentId,
7426 identity: ProviderSessionIdentity,
7427 display_name: String,
7428 ) -> Result<ManagedSessionRecord, NodeFailure> {
7429 self.index_provider_session_with_policy(
7430 workspace_id,
7431 provider,
7432 identity,
7433 display_name,
7434 false,
7435 )
7436 }
7437
7438 fn index_provider_session_with_policy(
7439 &self,
7440 workspace_id: WorkspaceId,
7441 provider: AgentId,
7442 identity: ProviderSessionIdentity,
7443 display_name: String,
7444 allow_validated_transcript_path: bool,
7445 ) -> Result<ManagedSessionRecord, NodeFailure> {
7446 validate_display_name(&display_name)
7447 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7448 identity
7449 .validate()
7450 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7451 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
7452 return Err(failure(
7453 NodeFailureCode::InvalidRequest,
7454 "provider session reference index v1 does not accept transcript paths",
7455 ));
7456 }
7457 let canonical_root = self.workspace_root(&workspace_id)?;
7458 if !self.enabled_providers.contains(&provider) {
7459 return Err(failure(
7460 NodeFailureCode::InvalidRequest,
7461 "provider is not enabled on this node",
7462 ));
7463 }
7464
7465 let transaction = self
7466 .state_transaction
7467 .lock()
7468 .unwrap_or_else(|poisoned| poisoned.into_inner());
7469 {
7470 let records = self
7471 .session_records
7472 .lock()
7473 .unwrap_or_else(|poisoned| poisoned.into_inner());
7474 if let Some(existing) = records.records.values().find(|candidate| {
7475 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7476 candidate_identity.key == identity.key && candidate_identity.id == identity.id
7477 })
7478 }) {
7479 if existing.provider == provider && existing.workspace_id == workspace_id {
7480 return Ok(existing.clone());
7481 }
7482 return Err(failure(
7483 NodeFailureCode::SessionRecordConflict,
7484 "provider session reference conflicts with another provider or workspace",
7485 ));
7486 }
7487 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7488 return Err(failure(
7489 NodeFailureCode::BackendBusy,
7490 "managed session record capacity is full; forget an inactive record first",
7491 ));
7492 }
7493 }
7494
7495 let now = unix_time_ms();
7496 let record = ManagedSessionRecord {
7497 record_id: self.allocate_record_id()?,
7498 display_name,
7499 provider,
7500 mode: SessionMode::Pty,
7501 state: ManagedSessionState::Dormant,
7502 workspace_id,
7503 canonical_root: opaque_windows_path(canonical_root),
7504 provider_session: Some(identity),
7505 active_session: None,
7506 environment_profile: None,
7507 bundle: None,
7508 context_id: None,
7509 context: None,
7510 exported_context: None,
7511 task_binding: None,
7512 created_at_unix_ms: now,
7513 updated_at_unix_ms: now,
7514 last_error: None,
7515 };
7516 self.insert_record(record.clone())?;
7517 if let Err(error) = self.persist_state_locked() {
7518 self.remove_record_memory(&record.record_id);
7519 return Err(persistence_failure(error));
7520 }
7521 drop(transaction);
7522 self.publish_record(record.clone());
7523 Ok(record)
7524 }
7525
7526 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
7527 self.session_bindings
7528 .lock()
7529 .unwrap_or_else(|poisoned| poisoned.into_inner())
7530 .iter()
7531 .find_map(|(instance_id, binding)| {
7532 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
7533 workspace_id: binding.workspace_id.clone(),
7534 session: SessionKey {
7535 instance_id: *instance_id,
7536 generation: binding.generation,
7537 },
7538 })
7539 })
7540 }
7541
7542 async fn forget_session_record(
7543 &self,
7544 record_id: &SessionRecordId,
7545 ) -> Result<(), NodeFailure> {
7546 let record = self.record(record_id)?;
7547 if record.active_session.is_some()
7548 || matches!(
7549 record.state,
7550 ManagedSessionState::Live | ManagedSessionState::IdentityPending
7551 )
7552 {
7553 return Err(failure(
7554 NodeFailureCode::SessionRecordBusy,
7555 "stop the managed session before forgetting it",
7556 ));
7557 }
7558 if let Some(address) = self.bound_address_for_record(record_id) {
7559 self.remove_session(&address).await?;
7560 }
7561 self.remove_managed_session_record(record_id, &record.workspace_id).await
7562 }
7563
7564 async fn remove_managed_session_record(
7579 &self,
7580 record_id: &SessionRecordId,
7581 workspace_id: &WorkspaceId,
7582 ) -> Result<(), NodeFailure> {
7583 let managed_lease_id = self.managed_worktrees.lock()
7584 .unwrap_or_else(|poisoned| poisoned.into_inner())
7585 .lease_for_workspace(workspace_id);
7586 if !self.destroy_record_materialization(record_id)? {
7587 let transaction = self
7588 .state_transaction
7589 .lock()
7590 .unwrap_or_else(|poisoned| poisoned.into_inner());
7591 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
7592 failure(
7593 NodeFailureCode::UnknownSessionRecord,
7594 "managed session record does not exist",
7595 )
7596 })?;
7597 if let Err(error) = self.persist_state_locked() {
7598 let _ = self.insert_record(removed);
7599 return Err(persistence_failure(error));
7600 }
7601 drop(transaction);
7602 }
7603 self.publish(NodeEvent::SessionRecordRemoved {
7604 record_id: record_id.clone(),
7605 });
7606 if let Some(lease_id) = managed_lease_id {
7607 if let Some(snapshot) = self.managed_worktrees.lock()
7608 .unwrap_or_else(|poisoned| poisoned.into_inner())
7609 .get(&lease_id)
7610 .map(ManagedWorktreeLeaseRecord::snapshot)
7611 {
7612 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7613 }
7614 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
7615 }
7616 Ok(())
7617 }
7618
7619 async fn retire_unavailable_session_records(&self) {
7632 let policy = self.session_record_retention;
7633 let age_enabled = policy.age_ms > 0;
7634 let keep_enabled = policy.keep_per_workspace > 0;
7635 if !age_enabled && !keep_enabled {
7636 return;
7637 }
7638 let now = unix_time_ms();
7639 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
7640 self.session_records
7641 .lock()
7642 .unwrap_or_else(|poisoned| poisoned.into_inner())
7643 .records
7644 .values()
7645 .filter(|record| record.state == ManagedSessionState::Unavailable)
7646 .map(|record| {
7647 (
7648 record.record_id.clone(),
7649 record.workspace_id.clone(),
7650 record.provider.clone(),
7651 record.updated_at_unix_ms,
7652 )
7653 })
7654 .collect()
7655 };
7656 if candidates.is_empty() {
7657 return;
7658 }
7659 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
7660 BTreeMap::new();
7661 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
7662 by_workspace
7663 .entry(workspace_id)
7664 .or_default()
7665 .push((record_id, provider, updated_at_unix_ms));
7666 }
7667 let reason = if age_enabled && keep_enabled {
7668 "age+keep-n"
7669 } else if age_enabled {
7670 "age"
7671 } else {
7672 "keep-n"
7673 };
7674 let mut retired: u64 = 0;
7675 for (workspace_id, mut records) in by_workspace {
7676 records.sort_by(|left, right| right.2.cmp(&left.2));
7679 for (index, (record_id, provider, updated_at_unix_ms)) in
7680 records.into_iter().enumerate()
7681 {
7682 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
7683 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
7684 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
7685 let should_retire = if age_enabled && keep_enabled {
7686 age_hit && keep_hit
7687 } else {
7688 age_hit || keep_hit
7689 };
7690 if !should_retire {
7691 continue;
7692 }
7693 match self.remove_managed_session_record(&record_id, &workspace_id).await {
7694 Ok(()) => {
7695 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
7696 retired += 1;
7697 tracing::info!(
7698 node_id = %self.node_id,
7699 record_id = %record_id,
7700 workspace_id = %workspace_id,
7701 provider = %provider,
7702 age_ms = record_age_ms,
7703 reason,
7704 "managed session record retired"
7705 );
7706 }
7707 Err(error) => tracing::warn!(
7708 node_id = %self.node_id,
7709 record_id = %record_id,
7710 workspace_id = %workspace_id,
7711 code = ?error.code,
7712 cause = %error.message,
7713 "session record retention sweep failed to retire a record"
7714 ),
7715 }
7716 }
7717 }
7718 if retired > 0 {
7719 let remaining_unavailable = self
7720 .session_records
7721 .lock()
7722 .unwrap_or_else(|poisoned| poisoned.into_inner())
7723 .records
7724 .values()
7725 .filter(|record| record.state == ManagedSessionState::Unavailable)
7726 .count();
7727 tracing::info!(
7728 node_id = %self.node_id,
7729 retired,
7730 remaining_unavailable,
7731 "session record retention sweep completed"
7732 );
7733 }
7734 }
7735
7736 async fn resume_session_record(
7737 &self,
7738 record_id: &SessionRecordId,
7739 terminal_size: gate4agent_types::TerminalSize,
7740 initial_prompt: Option<String>,
7741 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
7742 let record = self.record(record_id)?;
7743 if record.active_session.is_some()
7744 || matches!(
7745 record.state,
7746 ManagedSessionState::Live | ManagedSessionState::IdentityPending
7747 )
7748 {
7749 return Err(failure(
7750 NodeFailureCode::SessionRecordBusy,
7751 "managed session already has a live runtime binding",
7752 ));
7753 }
7754 let identity = record.provider_session.clone().ok_or_else(|| {
7755 failure(
7756 NodeFailureCode::SessionRecordNotResumable,
7757 "managed session has no verified provider session identity",
7758 )
7759 })?;
7760 let working_directory = self.workspace_root(&record.workspace_id)?;
7761 if !platform::roots_equal(
7762 &working_directory,
7763 windows_path_text(&record.canonical_root),
7764 ) {
7765 return Err(failure(
7766 NodeFailureCode::SessionRecordConflict,
7767 "managed session workspace root changed; refusing to resume in another directory",
7768 ));
7769 }
7770 {
7771 let records = self
7772 .session_records
7773 .lock()
7774 .unwrap_or_else(|poisoned| poisoned.into_inner());
7775 if records.records.values().any(|candidate| {
7776 candidate.record_id != *record_id
7777 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7778 session_registry::same_provider_session(
7779 &candidate.provider,
7780 candidate_identity,
7781 &record.provider,
7782 &identity,
7783 )
7784 })
7785 && (candidate.active_session.is_some()
7786 || matches!(
7787 candidate.state,
7788 ManagedSessionState::Live | ManagedSessionState::IdentityPending
7789 ))
7790 }) {
7791 return Err(failure(
7792 NodeFailureCode::SessionRecordConflict,
7793 "provider session is already active through another managed record",
7794 ));
7795 }
7796 }
7797 let runtime_requirement = if initial_prompt.is_some() {
7798 ProviderRuntimeRequirement::ResumeWithPrompt
7799 } else {
7800 ProviderRuntimeRequirement::Resume
7801 };
7802 let runtime_policy = self.admit_provider_runtime(
7803 &record.provider,
7804 runtime_requirement,
7805 )
7806 .await?;
7807 let request = ResumeLaunchRequest {
7808 working_directory,
7809 terminal_size,
7810 initial_prompt,
7811 };
7812 request
7813 .validate()
7814 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7815 self.ensure_binding_capacity()?;
7816 let resolved_materialization = match self.resolve_record_materialization(
7817 record_id,
7818 &record.provider,
7819 record.mode,
7820 record.environment_profile.as_ref(),
7821 record.bundle.as_ref(),
7822 record.context.as_ref(),
7823 ) {
7824 Ok(materialization) => materialization,
7825 Err(error) => {
7826 let label = match error.code {
7827 NodeFailureCode::UnknownBundle => "bundle-unavailable",
7828 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
7829 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
7830 _ => "environment-profile-unavailable",
7831 };
7832 self.mark_record_error(record_id, label)?;
7833 return Err(error);
7834 }
7835 };
7836 let (materialization_id, environment_overlay) = match resolved_materialization {
7837 Some((id, overlay)) => (Some(id), overlay),
7838 None => (None, None),
7839 };
7840 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
7841 let mut environment_selection = match self.select_environment_profile(
7842 instance_id,
7843 &record.provider,
7844 record.mode,
7845 record.environment_profile.as_ref(),
7846 ) {
7847 Ok(selection) => selection,
7848 Err(error) => {
7849 self.mark_record_error(record_id, "environment-profile-unavailable")?;
7850 return Err(error);
7851 }
7852 };
7853 let mut instance_overlay = environment_overlay
7854 .map(|overlay| self.install_prepared_launch_overlay(instance_id, overlay))
7855 .transpose()?
7856 .flatten();
7857 if let Some(stale_address) = self.bound_address_for_record(record_id) {
7858 self.remove_session(&stale_address).await?;
7859 }
7860 let address = SessionAddress {
7861 workspace_id: record.workspace_id.clone(),
7862 session: SessionKey {
7863 instance_id,
7864 generation: SessionGeneration(1),
7865 },
7866 };
7867 let transaction = self
7868 .state_transaction
7869 .lock()
7870 .unwrap_or_else(|poisoned| poisoned.into_inner());
7871 let current = self.record(record_id)?;
7872 if current.active_session.is_some()
7873 || matches!(
7874 current.state,
7875 ManagedSessionState::Live | ManagedSessionState::IdentityPending
7876 )
7877 {
7878 return Err(failure(
7879 NodeFailureCode::SessionRecordBusy,
7880 "managed session became active while resume was being prepared",
7881 ));
7882 }
7883 self.bind_managed_session_with_materialization(
7884 &address,
7885 record_id.clone(),
7886 runtime_policy,
7887 record.environment_profile.clone(),
7888 record.bundle.clone(),
7889 record.context.clone(),
7890 materialization_id,
7891 );
7892 if let Some(selection) = environment_selection.take() {
7893 selection.retain();
7894 }
7895 if let Some(overlay) = instance_overlay.take() {
7896 overlay.retain();
7897 }
7898 drop(transaction);
7899
7900 let transport = match record.mode {
7901 SessionMode::Pty => TransportKind::Pty,
7902 SessionMode::Inline => TransportKind::Pipe,
7903 SessionMode::Acp => TransportKind::Acp,
7904 };
7905 let agent_id = record.provider.clone();
7906 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7907 if let Err(error) = self
7908 .dispatch_bounded(
7909 ControlCommand::Register {
7910 instance_id,
7911 agent_id,
7912 transport,
7913 },
7914 dispatch_timeout,
7915 )
7916 .await
7917 {
7918 if let Some(binding) = self.remove_binding(&address) {
7919 self.cleanup_session_owned_materialization(&address, &binding)?;
7920 }
7921 return Err(error);
7922 }
7923 let command = self.prepare_command(ControlCommand::Resume {
7924 instance_id,
7925 target: ResumeTarget::ProviderSession { identity },
7926 runtime_policy,
7927 request,
7928 });
7929 let started_after = self.current_sequence();
7930 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
7931 self.rollback_spawn(&address, dispatch_timeout).await?;
7932 return Err(error);
7933 }
7934 if let Err(error) = self.dispatch_envelope(command) {
7935 self.clear_armed_resume(&address);
7936 self.rollback_spawn(&address, dispatch_timeout).await?;
7937 return Err(error);
7938 }
7939 match self
7940 .wait_for_record_resume(&address, started_after)
7941 .await
7942 {
7943 Ok(settled_address) => {
7944 let managed_lease_id = {
7945 self.managed_worktrees.lock()
7946 .unwrap_or_else(|poisoned| poisoned.into_inner())
7947 .lease_for_workspace(&record.workspace_id)
7948 };
7949 if let Some(lease_id) = managed_lease_id {
7950 {
7951 let mut bindings = self.session_bindings.lock()
7952 .unwrap_or_else(|poisoned| poisoned.into_inner());
7953 let binding = bindings.get_mut(&settled_address.session.instance_id)
7954 .ok_or_else(|| failure(
7955 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7956 "resumed managed session lost its exact binding",
7957 ))?;
7958 if binding.generation != settled_address.session.generation {
7959 return Err(failure(
7960 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7961 "resumed managed session generation diverged",
7962 ));
7963 }
7964 binding.managed_worktree_lease_id = Some(lease_id.clone());
7965 }
7966 let snapshot = self.managed_worktrees.lock()
7967 .unwrap_or_else(|poisoned| poisoned.into_inner())
7968 .bind_session(
7969 &lease_id,
7970 ManagedWorktreeSessionHolder {
7971 incarnation_id: self.incarnation_id,
7972 instance_id: settled_address.session.instance_id,
7973 generation: settled_address.session.generation,
7974 },
7975 Some(record_id.clone()),
7976 unix_time_ms(),
7977 ).map_err(|message| failure(
7978 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7979 &message,
7980 ))?;
7981 if let Err(error) = self.persist_state() {
7982 let _ = self.remove_session(&settled_address).await;
7983 return Err(persistence_failure(error));
7984 }
7985 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7986 }
7987 Ok((self.record(record_id)?, settled_address))
7988 }
7989 Err(error) => {
7990 if matches!(
7991 error.code,
7992 NodeFailureCode::SessionRecordNotResumable
7993 | NodeFailureCode::BackendOperationFailed
7994 ) {
7995 if let Some(bound) = self.bound_address_for_record(record_id) {
7996 let _ = self.remove_session(&bound).await;
7997 }
7998 }
7999 Err(error)
8000 }
8001 }
8002 }
8003
8004 async fn wait_for_record_resume(
8005 &self,
8006 initial_address: &SessionAddress,
8007 after_sequence: u64,
8008 ) -> Result<SessionAddress, NodeFailure> {
8009 let deadline = Instant::now()
8010 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8011 let mut scan_after = after_sequence;
8012 loop {
8013 let events = self
8014 .history
8015 .lock()
8016 .unwrap_or_else(|poisoned| poisoned.into_inner())
8017 .events
8018 .iter()
8019 .filter(|event| event.sequence > scan_after)
8020 .cloned()
8021 .collect::<Vec<_>>();
8022 for envelope in events {
8023 scan_after = scan_after.max(envelope.sequence);
8024 let NodeEvent::Control { address, event } = envelope.event else {
8025 continue;
8026 };
8027 if address.session.instance_id != initial_address.session.instance_id {
8028 continue;
8029 }
8030 match event.event {
8031 ControlEventKind::Resumed { .. } => return Ok(address),
8032 ControlEventKind::ResumeDenied { reason } => {
8033 return Err(failure(
8034 NodeFailureCode::SessionRecordNotResumable,
8035 &reason,
8036 ));
8037 }
8038 ControlEventKind::ResumeFailed { message }
8039 | ControlEventKind::CommandRejected { message } => {
8040 return Err(failure(
8041 NodeFailureCode::BackendOperationFailed,
8042 &message,
8043 ));
8044 }
8045 _ => {}
8046 }
8047 }
8048 if Instant::now() >= deadline {
8049 return Err(failure(
8050 NodeFailureCode::BackendBusy,
8051 "managed resume did not settle before the bounded deadline",
8052 ));
8053 }
8054 sleep(Duration::from_millis(5)).await;
8055 }
8056 }
8057
8058 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8059 self.workspaces
8060 .read()
8061 .unwrap_or_else(|poisoned| poisoned.into_inner())
8062 .get(workspace_id)
8063 .cloned()
8064 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8065 }
8066
8067 fn workspace_roots(&self) -> Vec<PathBuf> {
8068 self.workspaces
8069 .read()
8070 .unwrap_or_else(|poisoned| poisoned.into_inner())
8071 .values()
8072 .map(PathBuf::from)
8073 .collect()
8074 }
8075
8076 async fn inspect_workspace(
8077 &self,
8078 workspace_id: WorkspaceId,
8079 ) -> Result<WorkspaceInspection, NodeFailure> {
8080 let _permit = self
8081 .inspection_slots
8082 .clone()
8083 .try_acquire_owned()
8084 .map_err(|_| {
8085 tracing::warn!(
8086 workspace_id = %workspace_id,
8087 "workspace inspection rejected: inspection capacity is busy",
8088 );
8089 failure(
8090 NodeFailureCode::BackendBusy,
8091 "workspace inspection capacity is busy",
8092 )
8093 })?;
8094 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8095 tracing::warn!(
8096 workspace_id = %workspace_id,
8097 code = ?error.code,
8098 "workspace inspection rejected: workspace is not registered",
8099 );
8100 error
8101 })?;
8102 let tree_root = canonical_root.clone();
8103 let started_at = Instant::now();
8104 let time_budget_ms = workspace_inspection_time_budget_ms();
8105 let entry_cap = workspace_inspection_entry_cap();
8106 let deadline = started_at + Duration::from_millis(time_budget_ms);
8107 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8108 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8109 })
8110 .await
8111 .map_err(|error| {
8112 tracing::warn!(
8113 workspace_id = %workspace_id,
8114 error = %error,
8115 "workspace inspection rejected: walk task failed",
8116 );
8117 failure(
8118 NodeFailureCode::BackendOperationFailed,
8119 &format!("workspace inspection task failed: {error}"),
8120 )
8121 })?;
8122 let (mut git, git_time_budget_exceeded) =
8123 inspect_git_workspace(&canonical_root, deadline).await;
8124 if !git.worktrees.is_empty() {
8125 let registered = self
8126 .workspaces
8127 .read()
8128 .unwrap_or_else(|poisoned| poisoned.into_inner())
8129 .iter()
8130 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8131 .collect::<Vec<_>>();
8132 for worktree in &mut git.worktrees {
8133 worktree.workspace_id = registered
8134 .iter()
8135 .find(|(_, root)| {
8136 worktree_paths_equal(root, windows_path_text(&worktree.path))
8137 })
8138 .map(|(workspace_id, _)| workspace_id.clone());
8139 }
8140 }
8141 if git.is_repository {
8142 git.managed_worktree = self
8143 .managed_worktrees
8144 .lock()
8145 .unwrap_or_else(|poisoned| poisoned.into_inner())
8146 .git_scope(&workspace_id, git.branch.as_deref());
8147 }
8148 let truncation = workspace_inspection_truncation(
8149 &walk_budget,
8150 git_time_budget_exceeded,
8151 started_at.elapsed(),
8152 );
8153 if let Some(truncation) = &truncation {
8154 tracing::info!(
8155 workspace_id = %workspace_id,
8156 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8157 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8158 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8159 entries_visited = truncation.entries_visited,
8160 elapsed_ms = truncation.elapsed_ms,
8161 "workspace inspection truncated by its inner budget",
8162 );
8163 }
8164 Ok(WorkspaceInspection {
8165 workspace_id,
8166 entries,
8167 tree_truncated,
8168 git,
8169 truncation,
8170 })
8171 }
8172
8173 async fn browse_host_directories(
8174 &self,
8175 directory: Option<OpaqueHostPath>,
8176 after: Option<OpaqueHostPath>,
8177 ) -> Result<HostDirectoryListing, NodeFailure> {
8178 let permit = self
8179 .inspection_slots
8180 .clone()
8181 .try_acquire_owned()
8182 .map_err(|_| {
8183 failure(
8184 NodeFailureCode::BackendBusy,
8185 "host directory browse capacity is busy",
8186 )
8187 })?;
8188 let task = tokio::task::spawn_blocking(move || {
8189 let _permit = permit;
8190 browse_host_directories(directory, after)
8191 });
8192 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8193 .await
8194 .map_err(|_| {
8195 failure(
8196 NodeFailureCode::HostDirectoryReadTimedOut,
8197 "host directory browse exceeded its bounded deadline",
8198 )
8199 })?
8200 .map_err(|_| {
8201 failure(
8202 NodeFailureCode::HostDirectoryReadFailed,
8203 "host directory browse task failed",
8204 )
8205 })?
8206 .map_err(host_directory_failure)
8207 }
8208
8209 async fn read_workspace_file(
8210 &self,
8211 workspace_id: WorkspaceId,
8212 path: RepositoryPath,
8213 ) -> Result<WorkspaceFileRead, NodeFailure> {
8214 let canonical_root = self.workspace_root(&workspace_id)?;
8215 let permit = self
8216 .inspection_slots
8217 .clone()
8218 .try_acquire_owned()
8219 .map_err(|_| {
8220 failure(
8221 NodeFailureCode::BackendBusy,
8222 "workspace file read capacity is busy",
8223 )
8224 })?;
8225 let path_to_read = path.clone();
8226 let task = tokio::task::spawn_blocking(move || {
8227 let _permit = permit;
8228 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8229 });
8230 let content = timeout(
8231 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8232 task,
8233 )
8234 .await
8235 .map_err(|_| {
8236 failure(
8237 NodeFailureCode::RepositoryFileReadTimedOut,
8238 "repository file read exceeded its bounded deadline",
8239 )
8240 })?
8241 .map_err(|_| {
8242 failure(
8243 NodeFailureCode::RepositoryFileReadFailed,
8244 "repository file read task failed",
8245 )
8246 })?
8247 .map_err(workspace_file_failure)?;
8248 let content = match content {
8249 WorkspaceFileBytes::Utf8(text) => {
8250 let revision = Some(workspace_file_revision(text.as_bytes()));
8251 return Ok(WorkspaceFileRead {
8252 workspace_id,
8253 path,
8254 content: WorkspaceFileContent::Utf8 {
8255 byte_len: u32::try_from(text.len())
8256 .expect("bounded workspace text length must fit u32"),
8257 text,
8258 },
8259 revision,
8260 });
8261 }
8262 WorkspaceFileBytes::NonUtf8 { byte_length } => {
8263 WorkspaceFileContent::NonUtf8 {
8264 byte_len: u32::try_from(byte_length)
8265 .expect("bounded workspace file length must fit u32"),
8266 }
8267 }
8268 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8269 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8270 },
8271 };
8272 Ok(WorkspaceFileRead {
8273 workspace_id,
8274 path,
8275 content,
8276 revision: None,
8277 })
8278 }
8279
8280 async fn write_workspace_file(
8281 &self,
8282 workspace_id: WorkspaceId,
8283 path: RepositoryPath,
8284 expected_revision: WorkspaceFileRevision,
8285 text: String,
8286 ) -> Result<WorkspaceFileRead, NodeFailure> {
8287 let canonical_root = self.workspace_root(&workspace_id)?;
8288 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8289 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8290 })?;
8291 let path_to_write = path.clone();
8292 let expected = expected_revision.as_str().to_owned();
8293 let text_to_write = text.clone();
8294 let task = tokio::task::spawn_blocking(move || {
8295 let _permit = permit;
8296 write_workspace_file_to_disk(
8297 Path::new(&canonical_root),
8298 &path_to_write,
8299 &expected,
8300 &text_to_write,
8301 )
8302 });
8303 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8304 .await
8305 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8306 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8307 .map_err(workspace_file_write_failure)?;
8308 Ok(WorkspaceFileRead {
8309 workspace_id,
8310 path,
8311 content: WorkspaceFileContent::Utf8 {
8312 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8313 text,
8314 },
8315 revision: Some(WorkspaceFileRevision::new(revision)
8316 .expect("workspace writer returns a valid SHA-256 revision")),
8317 })
8318 }
8319
8320 async fn create_workspace_file(
8321 &self,
8322 workspace_id: WorkspaceId,
8323 path: RepositoryPath,
8324 ) -> Result<WorkspaceFileRead, NodeFailure> {
8325 let canonical_root = self.workspace_root(&workspace_id)?;
8326 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8327 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8328 })?;
8329 let path_to_create = path.clone();
8330 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8331 let task_commit_state = Arc::clone(&commit_state);
8332 let task = tokio::task::spawn_blocking(move || {
8333 let _permit = permit;
8334 create_workspace_file_on_disk(
8335 Path::new(&canonical_root),
8336 &path_to_create,
8337 &task_commit_state,
8338 )
8339 });
8340 let revision = settle_workspace_entry_create(
8341 task,
8342 commit_state,
8343 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8344 "repository file create exceeded its bounded deadline",
8345 "repository file create task failed",
8346 )
8347 .await?;
8348 Ok(WorkspaceFileRead {
8349 workspace_id,
8350 path,
8351 content: WorkspaceFileContent::Utf8 {
8352 text: String::new(),
8353 byte_len: 0,
8354 },
8355 revision: Some(WorkspaceFileRevision::new(revision)
8356 .expect("workspace creator returns a valid SHA-256 revision")),
8357 })
8358 }
8359
8360 async fn create_workspace_directory(
8361 &self,
8362 workspace_id: WorkspaceId,
8363 path: RepositoryPath,
8364 ) -> Result<WorkspaceEntry, NodeFailure> {
8365 let canonical_root = self.workspace_root(&workspace_id)?;
8366 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8367 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8368 })?;
8369 let path_to_create = path.clone();
8370 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8371 let task_commit_state = Arc::clone(&commit_state);
8372 let task = tokio::task::spawn_blocking(move || {
8373 let _permit = permit;
8374 create_workspace_directory_on_disk(
8375 Path::new(&canonical_root),
8376 &path_to_create,
8377 &task_commit_state,
8378 )
8379 });
8380 settle_workspace_entry_create(
8381 task,
8382 commit_state,
8383 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8384 "repository directory create exceeded its bounded deadline",
8385 "repository directory create task failed",
8386 )
8387 .await?;
8388 Ok(WorkspaceEntry {
8389 relative_path: path,
8390 kind: WorkspaceEntryKind::Directory,
8391 })
8392 }
8393
8394 async fn read_git_history(
8395 &self,
8396 workspace_id: WorkspaceId,
8397 path: Option<RepositoryPath>,
8398 before: Option<GitObjectId>,
8399 limit: u16,
8400 ) -> Result<GitHistoryPage, NodeFailure> {
8401 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8402 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
8403 })?;
8404 let root = self.workspace_root(&workspace_id)?;
8405 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
8406 }
8407
8408 async fn read_git_diff(
8409 &self,
8410 workspace_id: WorkspaceId,
8411 request: GitDiffRequest,
8412 ) -> Result<GitDiff, NodeFailure> {
8413 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8414 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
8415 })?;
8416 let root = self.workspace_root(&workspace_id)?;
8417 read_git_diff_bounded(&root, request).await
8418 }
8419
8420 fn native_session_route_root(
8421 &self,
8422 route: &NativeSessionCatalogRoute,
8423 ) -> Result<Option<String>, NodeFailure> {
8424 route
8425 .validate()
8426 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8427 match route.scope {
8428 gate4agent_types::NativeSessionCatalogScope::Workspace => route
8429 .workspace_id
8430 .as_ref()
8431 .map(|workspace_id| self.workspace_root(workspace_id))
8432 .transpose(),
8433 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
8434 }
8435 }
8436
8437 fn project_native_session_entries(
8438 &self,
8439 route: &NativeSessionCatalogRoute,
8440 entries: Vec<ScopedNativeSessionCatalogEntry>,
8441 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
8442 let current_root = route
8443 .workspace_id
8444 .as_ref()
8445 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
8446 let records = self
8447 .session_records
8448 .lock()
8449 .unwrap_or_else(|poisoned| poisoned.into_inner());
8450 let mut seen_records = std::collections::HashSet::new();
8451 entries
8452 .into_iter()
8453 .map(|entry| {
8454 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
8455 let matches = records
8456 .records
8457 .values()
8458 .filter(|record| {
8459 record.provider == route.provider
8460 && record.workspace_id == *workspace_id
8461 && current_root.as_deref().is_some_and(|root| {
8462 platform::roots_equal(
8463 root,
8464 windows_path_text(&record.canonical_root),
8465 )
8466 })
8467 && record.provider_session.as_ref().is_some_and(|identity| {
8468 session_registry::same_provider_session(
8469 &record.provider,
8470 identity,
8471 &route.provider,
8472 &entry.provider_session,
8473 )
8474 })
8475 })
8476 .map(|record| record.record_id.clone())
8477 .collect::<Vec<_>>();
8478 (matches.len() == 1
8479 && seen_records.insert(matches[0].clone()))
8480 .then(|| matches[0].clone())
8481 });
8482 let metadata = entry.metadata;
8483 crate::protocol::NativeSessionCatalogEntry {
8484 selection_id: metadata.selection_id,
8485 title: metadata.title,
8486 modified_at_unix_ms: metadata.modified_at_unix_ms,
8487 model: metadata.model,
8488 message_count: metadata.message_count,
8489 completed_turn_count: metadata.completed_turn_count,
8490 external_group: entry.external_group,
8491 record_id,
8492 }
8493 })
8494 .collect()
8495 }
8496
8497 async fn catalog_native_sessions(
8498 &self,
8499 route: NativeSessionCatalogRoute,
8500 limit: u16,
8501 ) -> Result<(
8502 Vec<crate::protocol::NativeSessionCatalogEntry>,
8503 crate::protocol::NativeSessionCatalogSummary,
8504 ), NodeFailure> {
8505 let canonical_root = self.native_session_route_root(&route)?;
8506 let registered_workspace_roots = self.workspace_roots();
8507 let provider = route.provider.clone();
8508 let scope = route.scope;
8509 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8510 failure(
8511 NodeFailureCode::InvalidRequest,
8512 "native session catalog is not configured",
8513 )
8514 })?;
8515 let _permit = Arc::clone(&self.inspection_slots)
8516 .try_acquire_owned()
8517 .map_err(|_| {
8518 failure(
8519 NodeFailureCode::BackendBusy,
8520 "native session catalog capacity is busy",
8521 )
8522 })?;
8523 let page = run_native_session_catalog_operation(
8524 catalog,
8525 "native session catalog",
8526 move |catalog| {
8527 catalog.catalog_initial_for_scope(
8528 &provider,
8529 scope,
8530 canonical_root.as_deref().map(Path::new),
8531 ®istered_workspace_roots,
8532 limit,
8533 )
8534 },
8535 )
8536 .await?;
8537 let summary = crate::protocol::NativeSessionCatalogSummary {
8538 catalog_revision: page.revision,
8539 recent_cutoff_unix_ms: page.cutoff_unix_ms,
8540 recent_total_count: page.recent_total_count,
8541 older_total_count: page.older_total_count,
8542 recent_next_after_selection_id: page.next_after_selection_id,
8543 recent_has_more: page.remaining_count > 0,
8544 };
8545 Ok((self.project_native_session_entries(&route, page.entries), summary))
8546 }
8547
8548 async fn page_native_sessions(
8549 &self,
8550 route: NativeSessionCatalogRoute,
8551 window: crate::protocol::NativeSessionCatalogWindow,
8552 catalog_revision: u64,
8553 recent_cutoff_unix_ms: u64,
8554 after_selection_id: Option<String>,
8555 limit: u16,
8556 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
8557 let canonical_root = self.native_session_route_root(&route)?;
8558 let registered_workspace_roots = self.workspace_roots();
8559 let provider = route.provider.clone();
8560 let scope = route.scope;
8561 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8562 failure(
8563 NodeFailureCode::InvalidRequest,
8564 "native session catalog is not configured",
8565 )
8566 })?;
8567 let _permit = Arc::clone(&self.inspection_slots)
8568 .try_acquire_owned()
8569 .map_err(|_| {
8570 failure(
8571 NodeFailureCode::BackendBusy,
8572 "native session catalog capacity is busy",
8573 )
8574 })?;
8575 let page = run_native_session_catalog_operation(
8576 catalog,
8577 "native session catalog page",
8578 move |catalog| {
8579 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
8580 &provider,
8581 scope,
8582 canonical_root.as_deref().map(Path::new),
8583 ®istered_workspace_roots,
8584 window,
8585 catalog_revision,
8586 recent_cutoff_unix_ms,
8587 after_selection_id.as_deref(),
8588 limit,
8589 ))
8590 },
8591 )
8592 .await?;
8593 let page = page.map_err(|error| match error {
8594 NativeSessionCatalogError::StaleCatalog => failure(
8595 NodeFailureCode::StaleNativeSessionCatalog,
8596 "native session catalog revision or cursor is stale",
8597 ),
8598 _ => failure(
8599 NodeFailureCode::BackendOperationFailed,
8600 "native session catalog page failed",
8601 ),
8602 })?;
8603 let entries = self.project_native_session_entries(&route, page.entries);
8604 Ok(crate::protocol::NativeSessionCatalogPage {
8605 window: page.window,
8606 revision: page.revision,
8607 entries,
8608 next_after_selection_id: page.next_after_selection_id,
8609 remaining_count: page.remaining_count,
8610 has_more: page.remaining_count > 0,
8611 })
8612 }
8613
8614 async fn preview_native_session(
8615 &self,
8616 selection: NativeSessionSelection,
8617 message_limit: u16,
8618 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
8619 selection
8620 .validate()
8621 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8622 let canonical_root = self.native_session_route_root(&selection.route)?;
8623 let registered_workspace_roots = self.workspace_roots();
8624 let provider = selection.route.provider.clone();
8625 let scope = selection.route.scope;
8626 let catalog_revision = selection.catalog_revision;
8627 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
8628 let selection_id = selection.selection_id.clone();
8629 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8630 failure(
8631 NodeFailureCode::InvalidRequest,
8632 "native session preview is not configured",
8633 )
8634 })?;
8635 let _permit = Arc::clone(&self.inspection_slots)
8636 .try_acquire_owned()
8637 .map_err(|_| {
8638 failure(
8639 NodeFailureCode::BackendBusy,
8640 "native session preview capacity is busy",
8641 )
8642 })?;
8643 let preview = run_native_session_catalog_operation(
8644 catalog,
8645 "native session preview",
8646 move |catalog| {
8647 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
8648 &provider,
8649 scope,
8650 canonical_root.as_deref().map(Path::new),
8651 ®istered_workspace_roots,
8652 catalog_revision,
8653 recent_cutoff_unix_ms,
8654 &selection_id,
8655 message_limit,
8656 ))
8657 },
8658 )
8659 .await?;
8660 preview
8661 .map(Into::into)
8662 .map_err(native_session_preview_failure)
8663 }
8664
8665 async fn index_native_session(
8666 &self,
8667 selection: NativeSessionSelection,
8668 display_name: String,
8669 ) -> Result<ManagedSessionRecord, NodeFailure> {
8670 selection
8671 .validate()
8672 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8673 if selection.route.scope
8674 != gate4agent_types::NativeSessionCatalogScope::Workspace
8675 {
8676 return Err(failure(
8677 NodeFailureCode::WorkspaceRegistrationRequired,
8678 "register the external project as a workspace before indexing or resuming it",
8679 ));
8680 }
8681 let workspace_id = selection
8682 .route
8683 .workspace_id
8684 .clone()
8685 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
8686 let canonical_root = self
8687 .native_session_route_root(&selection.route)?
8688 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
8689 let registered_workspace_roots = self.workspace_roots();
8690 let provider = selection.route.provider.clone();
8691 let catalog_revision = selection.catalog_revision;
8692 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
8693 let selection_id = selection.selection_id.clone();
8694 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8695 failure(
8696 NodeFailureCode::InvalidRequest,
8697 "native session index is not configured",
8698 )
8699 })?;
8700 let _permit = Arc::clone(&self.inspection_slots)
8701 .try_acquire_owned()
8702 .map_err(|_| {
8703 failure(
8704 NodeFailureCode::BackendBusy,
8705 "native session index capacity is busy",
8706 )
8707 })?;
8708 let resolved = run_native_session_catalog_operation(
8709 catalog,
8710 "native session index resolution",
8711 move |catalog| {
8712 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
8713 &provider,
8714 gate4agent_types::NativeSessionCatalogScope::Workspace,
8715 Some(Path::new(&canonical_root)),
8716 ®istered_workspace_roots,
8717 catalog_revision,
8718 recent_cutoff_unix_ms,
8719 &selection_id,
8720 ))
8721 },
8722 )
8723 .await?
8724 .map_err(native_session_preview_failure)?;
8725 self.index_provider_session_with_policy(
8726 workspace_id,
8727 selection.route.provider,
8728 resolved.identity,
8729 display_name,
8730 true,
8731 )
8732 }
8733
8734 async fn preview_native_session_by_session_id(
8735 &self,
8736 workspace_id: WorkspaceId,
8737 provider: AgentId,
8738 session_id: String,
8739 message_limit: u16,
8740 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
8741 let canonical_root = self.workspace_root(&workspace_id)?;
8742 let registered_workspace_roots = self.workspace_roots();
8743 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8744 failure(
8745 NodeFailureCode::InvalidRequest,
8746 "native session preview is not configured",
8747 )
8748 })?;
8749 let _permit = Arc::clone(&self.inspection_slots)
8750 .try_acquire_owned()
8751 .map_err(|_| {
8752 failure(
8753 NodeFailureCode::BackendBusy,
8754 "native session preview capacity is busy",
8755 )
8756 })?;
8757 run_native_session_catalog_operation(
8758 catalog,
8759 "native session preview",
8760 move |catalog| {
8761 catalog.preview_session_id_for_workspace(
8762 &provider,
8763 Path::new(&canonical_root),
8764 ®istered_workspace_roots,
8765 &session_id,
8766 message_limit,
8767 )
8768 },
8769 )
8770 .await
8771 }
8772
8773 async fn register_workspace(
8774 &self,
8775 workspace_id: WorkspaceId,
8776 root: String,
8777 ) -> Result<WorkspaceSnapshot, NodeFailure> {
8778 validate_workspace_request_root(&root)?;
8779 let canonicalize_workspace_id = workspace_id.clone();
8780 let workspace = tokio::task::spawn_blocking(move || {
8781 WorkspaceConfig::new(canonicalize_workspace_id, root)
8782 })
8783 .await
8784 .map_err(|error| {
8785 failure(
8786 NodeFailureCode::BackendOperationFailed,
8787 &format!("workspace canonicalization task failed: {error}"),
8788 )
8789 })?
8790 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
8791 let transaction = self
8792 .state_transaction
8793 .lock()
8794 .unwrap_or_else(|poisoned| poisoned.into_inner());
8795 let mut workspaces = self
8796 .workspaces
8797 .write()
8798 .unwrap_or_else(|poisoned| poisoned.into_inner());
8799 if workspaces.contains_key(&workspace_id) {
8800 return Err(failure(
8801 NodeFailureCode::DuplicateWorkspaceId,
8802 "workspace ID is already registered",
8803 ));
8804 }
8805 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
8806 platform::roots_equal(existing_root, workspace.canonical_root())
8807 }) {
8808 return Err(failure(
8809 NodeFailureCode::DuplicateWorkspaceRoot,
8810 &format!("workspace root is already registered as '{existing_id}'"),
8811 ));
8812 }
8813 {
8814 let records = self
8815 .session_records
8816 .lock()
8817 .unwrap_or_else(|poisoned| poisoned.into_inner());
8818 if let Some(record) = records.records.values().find(|record| {
8819 record.workspace_id == workspace_id
8820 && !record
8821 .canonical_root
8822 .as_utf8()
8823 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
8824 }) {
8825 return Err(failure(
8826 NodeFailureCode::SessionRecordConflict,
8827 &format!(
8828 "workspace ID is retained by session record '{}' at another root",
8829 record.record_id,
8830 ),
8831 ));
8832 }
8833 }
8834 let snapshot = WorkspaceSnapshot {
8835 workspace_id: workspace_id.clone(),
8836 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
8837 sessions: Vec::new(),
8838 worktree_service_mode: Some(WorktreeServiceMode::Manual),
8839 managed_worktree_profiles: Some(WorktreeProfileInventory {
8840 profiles: Vec::new(),
8841 }),
8842 };
8843 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
8844 drop(workspaces);
8845 let previous_records = {
8846 let mut records = self
8847 .session_records
8848 .lock()
8849 .unwrap_or_else(|poisoned| poisoned.into_inner());
8850 let previous = records.records.clone();
8851 for record in records.records.values_mut().filter(|record| {
8852 record.workspace_id == workspace_id
8853 && record
8854 .canonical_root
8855 .as_utf8()
8856 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
8857 }) {
8858 if record.active_session.is_none() {
8859 record.state = detached_record_state(record);
8860 }
8861 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
8862 record.last_error = None;
8863 }
8864 record.updated_at_unix_ms = unix_time_ms();
8865 }
8866 previous
8867 };
8868 if let Err(error) = self.persist_state_locked() {
8869 self.workspaces
8870 .write()
8871 .unwrap_or_else(|poisoned| poisoned.into_inner())
8872 .remove(&workspace_id);
8873 self.session_records
8874 .lock()
8875 .unwrap_or_else(|poisoned| poisoned.into_inner())
8876 .records = previous_records;
8877 return Err(persistence_failure(error));
8878 }
8879 let restored = self
8880 .session_records
8881 .lock()
8882 .unwrap_or_else(|poisoned| poisoned.into_inner())
8883 .records
8884 .values()
8885 .filter(|record| record.workspace_id == workspace_id)
8886 .cloned()
8887 .collect::<Vec<_>>();
8888 drop(transaction);
8889 self.publish(NodeEvent::WorkspaceAdded {
8890 workspace: snapshot.clone(),
8891 });
8892 for record in restored {
8893 self.publish_record(record);
8894 }
8895 Ok(snapshot)
8896 }
8897
8898 async fn create_standalone_workspace(
8899 &self,
8900 workspace_id: WorkspaceId,
8901 root: String,
8902 initial_branch: Option<String>,
8903 ) -> Result<WorkspaceSnapshot, NodeFailure> {
8904 validate_workspace_request_root(&root)?;
8905 validate_git_revision("initial branch", initial_branch.as_deref())?;
8906 let preflight_workspace_id = workspace_id.clone();
8907 let preflight_root = root.clone();
8908 let canonical_candidate = tokio::task::spawn_blocking(move || {
8909 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
8910 })
8911 .await
8912 .map_err(|_| failure(
8913 NodeFailureCode::BackendOperationFailed,
8914 "standalone workspace root preflight task failed",
8915 ))?
8916 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
8917 {
8918 let workspaces = self
8919 .workspaces
8920 .read()
8921 .unwrap_or_else(|poisoned| poisoned.into_inner());
8922 if workspaces.contains_key(&workspace_id) {
8923 return Err(failure(
8924 NodeFailureCode::DuplicateWorkspaceId,
8925 "workspace ID is already registered",
8926 ));
8927 }
8928 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
8929 platform::roots_equal(existing_root, &canonical_candidate)
8930 }) {
8931 return Err(failure(
8932 NodeFailureCode::DuplicateWorkspaceRoot,
8933 &format!("workspace root is already registered as '{existing_id}'"),
8934 ));
8935 }
8936 }
8937 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
8938 .await
8939 .map_err(standalone_workspace_failure)?;
8940 prepared
8941 .verify_for_registration()
8942 .await
8943 .map_err(standalone_workspace_failure)?;
8944 match self
8945 .register_workspace(workspace_id, prepared.root().to_owned())
8946 .await
8947 {
8948 Ok(workspace) => Ok(workspace),
8949 Err(registration_error) => {
8950 if prepared.compensate_registration_failure().await {
8951 Err(registration_error)
8952 } else {
8953 Err(failure(
8954 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
8955 "standalone workspace initialization succeeded but registration failed; recovery is required",
8956 ))
8957 }
8958 }
8959 }
8960 }
8961
8962 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
8963 let bindings = self
8964 .session_bindings
8965 .lock()
8966 .unwrap_or_else(|poisoned| poisoned.into_inner());
8967 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
8968 return Err(failure(
8969 NodeFailureCode::WorkspaceBusy,
8970 "workspace retains a bound session; remove every session before unregistering it",
8971 ));
8972 }
8973 drop(bindings);
8974 let transaction = self
8975 .state_transaction
8976 .lock()
8977 .unwrap_or_else(|poisoned| poisoned.into_inner());
8978 let mut workspaces = self
8979 .workspaces
8980 .write()
8981 .unwrap_or_else(|poisoned| poisoned.into_inner());
8982 if !workspaces.contains_key(workspace_id) {
8983 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
8984 }
8985 if workspaces.len() == 1 {
8986 return Err(failure(
8987 NodeFailureCode::LastWorkspace,
8988 "node must retain at least one workspace",
8989 ));
8990 }
8991 let removed_root = workspaces
8992 .remove(workspace_id)
8993 .expect("workspace presence was validated");
8994 drop(workspaces);
8995 let previous_records = {
8996 let mut records = self
8997 .session_records
8998 .lock()
8999 .unwrap_or_else(|poisoned| poisoned.into_inner());
9000 let previous = records.records.clone();
9001 for record in records
9002 .records
9003 .values_mut()
9004 .filter(|record| &record.workspace_id == workspace_id)
9005 {
9006 record.active_session = None;
9007 record.state = ManagedSessionState::Unavailable;
9008 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9009 record.updated_at_unix_ms = unix_time_ms();
9010 }
9011 previous
9012 };
9013 if let Err(error) = self.persist_state_locked() {
9014 self.workspaces
9015 .write()
9016 .unwrap_or_else(|poisoned| poisoned.into_inner())
9017 .insert(workspace_id.clone(), removed_root);
9018 self.session_records
9019 .lock()
9020 .unwrap_or_else(|poisoned| poisoned.into_inner())
9021 .records = previous_records;
9022 return Err(persistence_failure(error));
9023 }
9024 let unavailable = self
9025 .session_records
9026 .lock()
9027 .unwrap_or_else(|poisoned| poisoned.into_inner())
9028 .records
9029 .values()
9030 .filter(|record| &record.workspace_id == workspace_id)
9031 .cloned()
9032 .collect::<Vec<_>>();
9033 drop(transaction);
9034 self.publish(NodeEvent::WorkspaceRemoved {
9035 workspace_id: workspace_id.clone(),
9036 });
9037 for record in unavailable {
9038 self.publish_record(record);
9039 }
9040 Ok(())
9041 }
9042
9043 async fn create_worktree(
9044 &self,
9045 source_workspace_id: WorkspaceId,
9046 workspace_id: WorkspaceId,
9047 target_root: String,
9048 branch: String,
9049 base: Option<String>,
9050 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9051 self.require_worktree_service(&source_workspace_id)?;
9052 validate_workspace_request_root(&target_root)?;
9053 validate_git_revision("worktree base", base.as_deref())?;
9054 let source_root = self.workspace_root(&source_workspace_id)?;
9055 {
9056 let workspaces = self
9057 .workspaces
9058 .read()
9059 .unwrap_or_else(|poisoned| poisoned.into_inner());
9060 if workspaces.contains_key(&workspace_id) {
9061 return Err(failure(
9062 NodeFailureCode::DuplicateWorkspaceId,
9063 "workspace ID is already registered",
9064 ));
9065 }
9066 if let Some((existing_id, _)) = workspaces
9067 .iter()
9068 .find(|(_, root)| worktree_paths_equal(root, &target_root))
9069 {
9070 return Err(failure(
9071 NodeFailureCode::DuplicateWorkspaceRoot,
9072 &format!("workspace root is already registered as '{existing_id}'"),
9073 ));
9074 }
9075 }
9076 let mut worktree = create_git_worktree(
9077 &source_root,
9078 &target_root,
9079 &branch,
9080 base.as_deref(),
9081 )
9082 .await
9083 .map_err(git_worktree_failure)?;
9084 let workspace = match self
9085 .register_workspace(workspace_id, worktree.path.clone())
9086 .await
9087 {
9088 Ok(workspace) => workspace,
9089 Err(registration_error) => {
9090 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9091 return Err(failure(
9092 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9093 "worktree registration failed and compensation requires recovery",
9094 ));
9095 }
9096 return Err(failure(
9097 registration_error.code,
9098 ®istration_error.message,
9099 ));
9100 }
9101 };
9102 worktree.workspace_id = Some(workspace.workspace_id.clone());
9103 Ok((protocol_worktree(worktree), workspace))
9104 }
9105
9106 async fn remove_worktree(
9107 &self,
9108 source_workspace_id: WorkspaceId,
9109 target_root: String,
9110 ) -> Result<Option<WorkspaceId>, NodeFailure> {
9111 self.require_worktree_service(&source_workspace_id)?;
9112 validate_workspace_request_root(&target_root)?;
9113 let source_root = self.workspace_root(&source_workspace_id)?;
9114 let target_root = normalize_worktree_removal_target(&target_root)
9115 .map_err(git_worktree_failure)?;
9116 let target = list_git_worktrees(&source_root)
9117 .await
9118 .map_err(git_worktree_failure)?
9119 .into_iter()
9120 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9121 .ok_or_else(|| {
9122 failure(
9123 NodeFailureCode::WorktreeProtected,
9124 "refusing to remove a path that is not in Git's worktree listing",
9125 )
9126 })?;
9127 let registered_workspace_id = {
9128 let workspaces = self
9129 .workspaces
9130 .read()
9131 .unwrap_or_else(|poisoned| poisoned.into_inner());
9132 let matched = workspaces
9133 .iter()
9134 .find(|(_, root)| worktree_paths_equal(root, &target.path))
9135 .map(|(workspace_id, _)| workspace_id.clone());
9136 if matched.is_some() && workspaces.len() == 1 {
9137 return Err(failure(
9138 NodeFailureCode::LastWorkspace,
9139 "node must retain at least one workspace",
9140 ));
9141 }
9142 matched
9143 };
9144 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9145 let bindings = self
9146 .session_bindings
9147 .lock()
9148 .unwrap_or_else(|poisoned| poisoned.into_inner());
9149 if bindings
9150 .values()
9151 .any(|binding| &binding.workspace_id == workspace_id)
9152 {
9153 return Err(failure(
9154 NodeFailureCode::WorkspaceBusy,
9155 "worktree workspace retains a bound session; remove every session first",
9156 ));
9157 }
9158 }
9159 remove_git_worktree(&source_root, &target.path)
9160 .await
9161 .map_err(git_worktree_failure)?;
9162 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9163 self.unregister_workspace(workspace_id)
9164 .map_err(|error| failure(error.code, &error.message))?;
9165 }
9166 Ok(registered_workspace_id)
9167 }
9168
9169 async fn cleanup_managed_worktree(
9170 &self,
9171 lease_id: &ManagedWorktreeLeaseId,
9172 explicit: bool,
9173 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9174 let lease = self.managed_worktrees.lock()
9175 .unwrap_or_else(|poisoned| poisoned.into_inner())
9176 .get(lease_id)
9177 .cloned()
9178 .ok_or_else(|| failure(
9179 NodeFailureCode::UnknownManagedWorktreeLease,
9180 "managed worktree lease does not exist",
9181 ))?;
9182 if lease.state == ManagedWorktreeLeaseState::Removed {
9183 return Ok(lease.snapshot());
9184 }
9185 if lease.has_holders() {
9186 return Err(failure(
9187 NodeFailureCode::ManagedWorktreeBusy,
9188 "managed worktree lease still has a session or durable-record holder",
9189 ));
9190 }
9191 if self
9192 .materializations
9193 .lock()
9194 .unwrap_or_else(|poisoned| poisoned.into_inner())
9195 .values()
9196 .any(|record| record.managed_lease_id() == Some(lease_id))
9197 {
9198 return Err(failure(
9199 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9200 "managed worktree cleanup is blocked by session environment ownership",
9201 ));
9202 }
9203 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9204 let snapshot = {
9205 let mut registry = self.managed_worktrees.lock()
9206 .unwrap_or_else(|poisoned| poisoned.into_inner());
9207 let current = registry.get_mut(lease_id)
9208 .expect("managed worktree lease remains present");
9209 current.state = ManagedWorktreeLeaseState::Retained;
9210 current.cleanup_failure = None;
9211 current.updated_at_unix_ms = unix_time_ms();
9212 current.snapshot()
9213 };
9214 if self.persist_state().is_err() {
9215 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9216 }
9217 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9218 return Ok(snapshot);
9219 }
9220 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9221 if profile.revision() != &lease.profile_revision {
9222 self.mark_managed_recovery_required(
9223 lease_id,
9224 ManagedWorktreeCleanupFailure::OwnershipConflict,
9225 );
9226 return Err(failure(
9227 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9228 "managed worktree profile revision changed",
9229 ));
9230 }
9231 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9232 self.mark_managed_recovery_required(
9233 lease_id,
9234 ManagedWorktreeCleanupFailure::OwnershipConflict,
9235 );
9236 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9237 })?;
9238 let source_root = self.workspace_root(&lease.source_workspace_id)?;
9239 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9240 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9241 managed_git_worktree_failure(error)
9242 })?;
9243 let target = listed.iter()
9244 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9245 if let Some(target) = target {
9246 if target.is_main || target.is_bare || !exact_owned_worktree(
9247 &lease,
9248 &target.path,
9249 target.branch.as_deref(),
9250 &target.head,
9251 ) {
9252 self.mark_managed_recovery_required(
9253 lease_id,
9254 ManagedWorktreeCleanupFailure::OwnershipConflict,
9255 );
9256 return Err(failure(
9257 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9258 "Git worktree identity no longer matches the managed lease",
9259 ));
9260 }
9261 if target.prunable {
9262 self.mark_managed_cleanup_blocked(
9263 lease_id,
9264 ManagedWorktreeCleanupFailure::Prunable,
9265 );
9266 return Err(failure(
9267 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9268 "managed worktree is prunable and requires recovery",
9269 ));
9270 }
9271 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9272 let failure_kind = match error.kind {
9273 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9274 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9275 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9276 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9277 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9278 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9279 };
9280 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9281 self.mark_managed_recovery_required(lease_id, failure_kind);
9282 } else {
9283 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9284 }
9285 return Err(managed_git_worktree_failure(error));
9286 }
9287 } else {
9288 match std::fs::symlink_metadata(&lease.target_root) {
9289 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9290 Ok(_) => {
9291 self.mark_managed_recovery_required(
9292 lease_id,
9293 ManagedWorktreeCleanupFailure::OwnershipConflict,
9294 );
9295 return Err(failure(
9296 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9297 "managed target exists but is not owned by Git",
9298 ));
9299 }
9300 Err(_) => {
9301 self.mark_managed_cleanup_blocked(
9302 lease_id,
9303 ManagedWorktreeCleanupFailure::Backend,
9304 );
9305 return Err(failure(
9306 NodeFailureCode::BackendOperationFailed,
9307 "managed target identity could not be inspected",
9308 ));
9309 }
9310 }
9311 }
9312 let registered = self.workspaces.read()
9313 .unwrap_or_else(|poisoned| poisoned.into_inner())
9314 .contains_key(&lease.workspace_id);
9315 if registered {
9316 self.unregister_workspace(&lease.workspace_id)?;
9317 }
9318 let removed = {
9319 self.managed_worktrees.lock()
9320 .unwrap_or_else(|poisoned| poisoned.into_inner())
9321 .tombstone(lease_id, unix_time_ms())
9322 .expect("managed worktree lease remains present during cleanup")
9323 };
9324 self.persist_state().map_err(persistence_failure)?;
9325 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9326 Ok(removed.snapshot())
9327 }
9328
9329 fn mark_managed_cleanup_blocked(
9330 &self,
9331 lease_id: &ManagedWorktreeLeaseId,
9332 failure_kind: ManagedWorktreeCleanupFailure,
9333 ) {
9334 let snapshot = {
9335 let mut registry = self.managed_worktrees.lock()
9336 .unwrap_or_else(|poisoned| poisoned.into_inner());
9337 let Some(lease) = registry.get_mut(lease_id) else { return };
9338 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9339 lease.cleanup_failure = Some(failure_kind);
9340 lease.updated_at_unix_ms = unix_time_ms();
9341 lease.snapshot()
9342 };
9343 let _ = self.persist_state();
9344 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9345 }
9346
9347 fn require_worktree_service(
9348 &self,
9349 source_workspace_id: &WorkspaceId,
9350 ) -> Result<(), NodeFailure> {
9351 if self.managed_worktrees.lock()
9352 .unwrap_or_else(|poisoned| poisoned.into_inner())
9353 .lease_for_workspace(source_workspace_id)
9354 .is_some()
9355 {
9356 return Err(failure(
9357 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9358 "managed worktree targets are internal-only workspaces",
9359 ));
9360 }
9361 match self
9362 .worktree_service_modes
9363 .get(source_workspace_id)
9364 .copied()
9365 .unwrap_or(WorktreeServiceMode::Manual)
9366 {
9367 WorktreeServiceMode::Manual => Ok(()),
9368 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
9369 NodeFailureCode::UnsupportedSpawnCapability,
9370 "Git worktree service mode is unavailable in this node build",
9371 )),
9372 }
9373 }
9374
9375 fn managed_reservation_conflict(
9376 &self,
9377 workspace_id: Option<&WorkspaceId>,
9378 root: Option<&str>,
9379 ) -> bool {
9380 self.managed_worktrees.lock()
9381 .unwrap_or_else(|poisoned| poisoned.into_inner())
9382 .active_records()
9383 .any(|lease| {
9384 workspace_id.is_some_and(|workspace_id| {
9385 workspace_id == &lease.source_workspace_id
9386 || workspace_id == &lease.workspace_id
9387 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
9388 })
9389 }
9390
9391 fn reject_managed_reservation(
9392 &self,
9393 workspace_id: Option<&WorkspaceId>,
9394 root: Option<&str>,
9395 ) -> Result<(), NodeFailure> {
9396 if self.managed_reservation_conflict(workspace_id, root) {
9397 Err(failure(
9398 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9399 "managed worktree ownership may only be mutated through its lease cleanup",
9400 ))
9401 } else {
9402 Ok(())
9403 }
9404 }
9405
9406 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
9407 self.validate_address(address)?;
9408 self.workspace_root(&address.workspace_id)
9409 }
9410
9411 fn require_session_runtime_policy(
9445 &self,
9446 address: &SessionAddress,
9447 requirement: ProviderRuntimeRequirement,
9448 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
9449 let runtime_policy = {
9450 let bindings = self
9451 .session_bindings
9452 .lock()
9453 .unwrap_or_else(|poisoned| poisoned.into_inner());
9454 let binding = bindings
9455 .get(&address.session.instance_id)
9456 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
9457 if binding.workspace_id != address.workspace_id {
9458 return Err(failure(
9459 NodeFailureCode::SessionWorkspaceMismatch,
9460 "session belongs to another workspace",
9461 ));
9462 }
9463 if binding.generation != address.session.generation {
9464 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
9465 }
9466 binding.runtime_policy
9467 };
9468 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
9469 let snapshot = self.internal_session_snapshot(address)?;
9470 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
9471 && snapshot.provider.lead_activity != ProviderActivity::Idle
9472 {
9473 return Err(failure(
9474 NodeFailureCode::TurnInFlight,
9475 "session already has a turn in flight; wait for it to complete before sending another prompt",
9476 ));
9477 }
9478 }
9479 require_policy(runtime_policy, false, false, requirement)
9480 .map(|()| runtime_policy)
9481 .map_err(|_| failure(
9482 NodeFailureCode::UnsupportedCapability,
9483 "session runtime policy does not admit this semantic operation",
9484 ))
9485 }
9486
9487 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
9488 let mut bindings = self
9489 .session_bindings
9490 .lock()
9491 .unwrap_or_else(|poisoned| poisoned.into_inner());
9492 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
9493 binding.workspace_id == address.workspace_id
9494 && binding.generation == address.session.generation
9495 }) {
9496 let removed = bindings.remove(&address.session.instance_id);
9497 #[cfg(feature = "dig2-station-probe")]
9500 {
9501 crate::dig2_station_lease::release_lease_for_holder(
9502 &self.browser_station_leases,
9503 address.session.instance_id,
9504 );
9505 }
9506 self.clear_terminal_frame_watermark(address);
9507 if let Some(control) = self.native_launch_profile_control.as_ref() {
9508 control.clear_native_mcp_server_launch_overlay(
9509 address.session.instance_id,
9510 );
9511 }
9512 if let Some(registry) = self.harness_mcp_registry.as_ref() {
9513 registry.revoke_session(address);
9514 }
9515 if removed
9516 .as_ref()
9517 .is_some_and(|binding| {
9518 binding.environment_profile.is_some()
9519 || binding.bundle.is_some()
9520 || binding.context.is_some()
9521 })
9522 {
9523 if let Some(control) = self.native_launch_profile_control.as_ref() {
9524 control.clear_native_launch_profile_selection(
9525 address.session.instance_id,
9526 );
9527 control.clear_native_instance_launch_overlay(
9528 address.session.instance_id,
9529 );
9530 }
9531 }
9532 return removed;
9533 }
9534 None
9535 }
9536
9537 fn cleanup_session_owned_materialization(
9538 &self,
9539 address: &SessionAddress,
9540 binding: &SessionBinding,
9541 ) -> Result<(), NodeFailure> {
9542 let Some(id) = binding.materialization_id.as_ref() else {
9543 return Ok(());
9544 };
9545 let owner = self
9546 .materializations
9547 .lock()
9548 .unwrap_or_else(|poisoned| poisoned.into_inner())
9549 .get(id)
9550 .map(|record| record.owner().clone());
9551 match owner {
9552 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
9553 Some(MaterializationOwner::Session {
9554 incarnation_id,
9555 instance_id,
9556 generation,
9557 }) if incarnation_id == self.incarnation_id
9558 && instance_id == address.session.instance_id
9559 && generation == address.session.generation =>
9560 {
9561 self.cleanup_materialization(id)
9562 }
9563 Some(MaterializationOwner::Session { .. }) => Err(failure(
9564 NodeFailureCode::BackendOperationFailed,
9565 "session environment ownership requires recovery",
9566 )),
9567 }
9568 }
9569
9570 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
9571 let mut watermarks = self
9572 .terminal_frame_watermarks
9573 .lock()
9574 .unwrap_or_else(|poisoned| poisoned.into_inner());
9575 if watermarks
9576 .get(&address.session.instance_id)
9577 .is_some_and(|(current, _)| current == address)
9578 {
9579 watermarks.remove(&address.session.instance_id);
9580 }
9581 }
9582
9583 #[cfg(test)]
9584 fn address_for(
9585 &self,
9586 instance_id: AgentInstanceId,
9587 generation: SessionGeneration,
9588 ) -> Option<SessionAddress> {
9589 let binding = self.session_bindings
9590 .lock()
9591 .unwrap_or_else(|poisoned| poisoned.into_inner())
9592 .get(&instance_id)
9593 .cloned()?;
9594 (binding.generation == generation).then_some(SessionAddress {
9595 workspace_id: binding.workspace_id,
9596 session: SessionKey { instance_id, generation },
9597 })
9598 }
9599
9600 fn arm_resume(
9601 &self,
9602 address: &SessionAddress,
9603 command_id: CommandId,
9604 runtime_policy: ProviderRuntimePolicy,
9605 ) -> Result<(), NodeFailure> {
9606 let mut bindings = self
9607 .session_bindings
9608 .lock()
9609 .unwrap_or_else(|poisoned| poisoned.into_inner());
9610 let binding = bindings
9611 .get_mut(&address.session.instance_id)
9612 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
9613 if binding.workspace_id != address.workspace_id {
9614 return Err(failure(
9615 NodeFailureCode::SessionWorkspaceMismatch,
9616 "session belongs to another workspace",
9617 ));
9618 }
9619 if binding.generation != address.session.generation {
9620 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
9621 }
9622 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
9623 Ok(())
9624 }
9625
9626 fn clear_armed_resume(&self, address: &SessionAddress) {
9627 let mut bindings = self
9628 .session_bindings
9629 .lock()
9630 .unwrap_or_else(|poisoned| poisoned.into_inner());
9631 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
9632 if binding.workspace_id == address.workspace_id
9633 && binding.generation == address.session.generation
9634 {
9635 binding.pending_resume = None;
9636 }
9637 }
9638 }
9639
9640 fn publish_control(&self, event: ControlEvent) {
9641 let revokes_harness_mcp = matches!(
9642 &event.event,
9643 ControlEventKind::Exited { .. }
9644 | ControlEventKind::Failed { .. }
9645 | ControlEventKind::Removed
9646 );
9647 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
9648 let mut bindings = self
9649 .session_bindings
9650 .lock()
9651 .unwrap_or_else(|poisoned| poisoned.into_inner());
9652 let Some(binding) = bindings.get_mut(&event.instance_id) else {
9653 return;
9654 };
9655 let previous_address = SessionAddress {
9656 workspace_id: binding.workspace_id.clone(),
9657 session: SessionKey {
9658 instance_id: event.instance_id,
9659 generation: binding.generation,
9660 },
9661 };
9662 let mut resume_command_rejected = false;
9663 if binding.generation == event.generation {
9664 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
9665 let rejected_resume = matches!(
9666 &event.event,
9667 ControlEventKind::CommandRejected { .. }
9668 ) && event.command_id == Some(pending_command_id);
9669 let resume_failed = matches!(
9670 &event.event,
9671 ControlEventKind::ResumeDenied { .. }
9672 | ControlEventKind::ResumeFailed { .. }
9673 );
9674 if pending_generation == binding.generation
9675 && (rejected_resume || resume_failed)
9676 {
9677 resume_command_rejected = rejected_resume;
9678 binding.pending_resume = None;
9679 }
9680 }
9681 } else {
9682 let expected = binding.generation.0.checked_add(1);
9683 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
9684 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
9685 generation == binding.generation
9686 })
9687 || expected != Some(event.generation.0)
9688 || !authorized
9689 {
9690 return;
9691 }
9692 let (_, _, runtime_policy) = binding
9693 .pending_resume
9694 .expect("authorized resume retains its pending runtime policy");
9695 binding.generation = event.generation;
9696 binding.runtime_policy = runtime_policy;
9697 binding.pending_resume = None;
9698 }
9699 (
9700 SessionAddress {
9701 workspace_id: binding.workspace_id.clone(),
9702 session: SessionKey {
9703 instance_id: event.instance_id,
9704 generation: event.generation,
9705 },
9706 },
9707 (previous_address.session.generation != event.generation)
9708 .then_some(previous_address),
9709 binding.record_id.clone(),
9710 binding.runtime_policy.provider_session_identity,
9711 resume_command_rejected,
9712 )
9713 };
9714 if let Some(previous_address) = previous_address.as_ref() {
9715 self.clear_terminal_frame_watermark(previous_address);
9716 }
9717 if let Some(record_id) = record_id {
9718 self.reconcile_managed_record(
9719 &record_id,
9720 &address,
9721 &event,
9722 identity_admitted,
9723 resume_command_rejected,
9724 );
9725 }
9726 match &event.event {
9734 ControlEventKind::Running { process_id } => {
9735 tracing::info!(
9736 node_id = %self.node_id,
9737 workspace_id = %address.workspace_id,
9738 session = ?address.session,
9739 pid = ?process_id,
9740 "session process is running",
9741 );
9742 }
9743 ControlEventKind::Exited { exit_code, forced } => {
9744 tracing::info!(
9745 node_id = %self.node_id,
9746 workspace_id = %address.workspace_id,
9747 session = ?address.session,
9748 exit_code = ?exit_code,
9749 forced = forced,
9750 "session process exited",
9751 );
9752 }
9753 ControlEventKind::Failed { message } => {
9754 tracing::warn!(
9755 node_id = %self.node_id,
9756 workspace_id = %address.workspace_id,
9757 session = ?address.session,
9758 cause = %message,
9759 "session failed",
9760 );
9761 }
9762 ControlEventKind::CommandRejected { message } => {
9763 tracing::warn!(
9764 node_id = %self.node_id,
9765 workspace_id = %address.workspace_id,
9766 session = ?address.session,
9767 cause = %message,
9768 "session command rejected",
9769 );
9770 }
9771 ControlEventKind::Removed => {
9772 tracing::info!(
9773 node_id = %self.node_id,
9774 workspace_id = %address.workspace_id,
9775 session = ?address.session,
9776 "session removed",
9777 );
9778 }
9779 ControlEventKind::ResumeAuthorized { session: provider_session } => {
9780 tracing::info!(
9781 node_id = %self.node_id,
9782 workspace_id = %address.workspace_id,
9783 session = ?address.session,
9784 provider_session = ?provider_session,
9785 "session resume authorized",
9786 );
9787 }
9788 ControlEventKind::Resumed { session: provider_session, process_id } => {
9789 tracing::info!(
9790 node_id = %self.node_id,
9791 workspace_id = %address.workspace_id,
9792 session = ?address.session,
9793 provider_session = ?provider_session,
9794 pid = ?process_id,
9795 "session resumed",
9796 );
9797 }
9798 ControlEventKind::ResumeDenied { reason } => {
9799 tracing::warn!(
9800 node_id = %self.node_id,
9801 workspace_id = %address.workspace_id,
9802 session = ?address.session,
9803 cause = %reason,
9804 "session resume denied",
9805 );
9806 }
9807 ControlEventKind::ResumeFailed { message } => {
9808 tracing::warn!(
9809 node_id = %self.node_id,
9810 workspace_id = %address.workspace_id,
9811 session = ?address.session,
9812 cause = %message,
9813 "session resume failed",
9814 );
9815 }
9816 _ => {}
9817 }
9818 let stream_chunk = agent_stream_chunk(&event);
9819 self.publish(NodeEvent::Control {
9820 address: address.clone(),
9821 event,
9822 });
9823 if let Some(chunk) = stream_chunk {
9824 self.publish_agent_stream_chunk(address.clone(), chunk);
9825 }
9826 if revokes_harness_mcp {
9827 if let Some(registry) = self.harness_mcp_registry.as_ref() {
9828 registry.revoke_session(&address);
9829 }
9830 if let Some(control) = self.native_launch_profile_control.as_ref() {
9831 control.clear_native_mcp_server_launch_overlay(
9832 address.session.instance_id,
9833 );
9834 }
9835 }
9836 }
9837
9838 fn reconcile_managed_record(
9839 &self,
9840 record_id: &SessionRecordId,
9841 address: &SessionAddress,
9842 event: &ControlEvent,
9843 identity_admitted: bool,
9844 resume_command_rejected: bool,
9845 ) {
9846 let _transaction = self
9847 .state_transaction
9848 .lock()
9849 .unwrap_or_else(|poisoned| poisoned.into_inner());
9850 let observed_identity = match &event.event {
9851 ControlEventKind::ProviderEvent {
9852 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
9853 ..
9854 } if identity_admitted => Some(identity.clone()),
9855 _ => None,
9856 };
9857 let identity_was_observed = observed_identity.is_some();
9858 let replacement_id = observed_identity
9859 .as_ref()
9860 .and_then(|_| self.allocate_record_id().ok());
9861 let mut upserts = Vec::new();
9862 let mut removals = Vec::new();
9863 let mut rebind = None;
9864 let original_records;
9865 let original_materializations = self
9866 .materializations
9867 .lock()
9868 .unwrap_or_else(|poisoned| poisoned.into_inner())
9869 .clone();
9870 {
9871 let mut records = self
9872 .session_records
9873 .lock()
9874 .unwrap_or_else(|poisoned| poisoned.into_inner());
9875 original_records = records.records.clone();
9876 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
9877 return;
9878 };
9879 if let Some(identity) = observed_identity {
9880 let existing_id = records
9881 .records
9882 .values()
9883 .find(|record| {
9884 record.record_id != *record_id
9885 && record.provider_session.as_ref().is_some_and(|record_identity| {
9886 session_registry::same_provider_session(
9887 &record.provider,
9888 record_identity,
9889 ¤t_snapshot.provider,
9890 &identity,
9891 )
9892 })
9893 })
9894 .map(|record| record.record_id.clone());
9895 if let Some(existing_id) = existing_id {
9896 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
9897 record.mode == current_snapshot.mode
9898 && record.workspace_id == current_snapshot.workspace_id
9899 && record.environment_profile == current_snapshot.environment_profile
9900 && record.bundle == current_snapshot.bundle
9901 && record.context_id == current_snapshot.context_id
9902 && record.context == current_snapshot.context
9903 && record
9904 .canonical_root
9905 .as_utf8()
9906 .zip(current_snapshot.canonical_root.as_utf8())
9907 .is_some_and(|(left, right)| platform::roots_equal(left, right))
9908 });
9909 if !scope_matches {
9910 if let Some(current) = records.records.get_mut(record_id) {
9911 current.state = ManagedSessionState::Unavailable;
9912 current.last_error =
9913 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
9914 current.updated_at_unix_ms = unix_time_ms();
9915 upserts.push(current.clone());
9916 }
9917 } else {
9918 let existing_is_active = records
9919 .records
9920 .get(&existing_id)
9921 .is_some_and(|record| record.active_session.is_some());
9922 if existing_is_active {
9923 if let Some(current) = records.records.get_mut(record_id) {
9924 current.state = ManagedSessionState::Unavailable;
9925 current.last_error =
9926 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
9927 current.updated_at_unix_ms = unix_time_ms();
9928 upserts.push(current.clone());
9929 }
9930 } else {
9931 if current_snapshot.provider_session.is_some() {
9932 if let Some(current) = records.records.get_mut(record_id) {
9933 current.active_session = None;
9934 current.state = ManagedSessionState::Dormant;
9935 current.updated_at_unix_ms = unix_time_ms();
9936 upserts.push(current.clone());
9937 }
9938 } else {
9939 records.records.remove(record_id);
9940 removals.push(record_id.clone());
9941 }
9942 if let Some(existing) = records.records.get_mut(&existing_id) {
9943 existing.provider_session = Some(identity.clone());
9944 existing.active_session = Some(address.clone());
9945 existing.state = ManagedSessionState::Live;
9946 existing.updated_at_unix_ms = unix_time_ms();
9947 existing.last_error = None;
9948 upserts.push(existing.clone());
9949 }
9950 rebind = Some(existing_id);
9951 }
9952 }
9953 } else if current_snapshot
9954 .provider_session
9955 .as_ref()
9956 .is_some_and(|current| {
9957 !session_registry::same_provider_session(
9958 ¤t_snapshot.provider,
9959 current,
9960 ¤t_snapshot.provider,
9961 &identity,
9962 )
9963 })
9964 {
9965 if let Some(current) = records.records.get_mut(record_id) {
9966 current.active_session = None;
9967 if current.bundle.is_some() || current.context.is_some() {
9968 let had_bundle = current.bundle.is_some();
9969 current.provider_session = None;
9970 current.bundle = None;
9971 current.context_id = None;
9972 current.context = None;
9973 current.state = ManagedSessionState::Unavailable;
9974 current.last_error = Some(if had_bundle {
9975 "bundle-unavailable".to_owned()
9976 } else {
9977 "context-unavailable".to_owned()
9978 });
9979 } else {
9980 current.state = ManagedSessionState::Dormant;
9981 }
9982 current.updated_at_unix_ms = unix_time_ms();
9983 upserts.push(current.clone());
9984 }
9985 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
9986 if let Some(current) = records.records.get_mut(record_id) {
9987 current.state = ManagedSessionState::Unavailable;
9988 current.last_error =
9989 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
9990 current.updated_at_unix_ms = unix_time_ms();
9991 upserts.push(current.clone());
9992 }
9993 } else if let Some(new_record_id) = replacement_id {
9994 let now = unix_time_ms();
9995 let replacement = ManagedSessionRecord {
9996 record_id: new_record_id.clone(),
9997 display_name: format!(
9998 "{} #{}",
9999 current_snapshot.provider.as_str(),
10000 records
10001 .records
10002 .values()
10003 .filter(|record| {
10004 record.provider == current_snapshot.provider
10005 })
10006 .count()
10007 .saturating_add(1),
10008 ),
10009 provider: current_snapshot.provider.clone(),
10010 mode: current_snapshot.mode,
10011 state: ManagedSessionState::Live,
10012 workspace_id: current_snapshot.workspace_id,
10013 canonical_root: current_snapshot.canonical_root,
10014 provider_session: Some(identity),
10015 active_session: Some(address.clone()),
10016 environment_profile: current_snapshot.environment_profile,
10017 bundle: current_snapshot.bundle,
10018 context_id: current_snapshot.context_id,
10019 context: current_snapshot.context,
10020 exported_context: None,
10026 task_binding: current_snapshot.task_binding,
10027 created_at_unix_ms: now,
10028 updated_at_unix_ms: now,
10029 last_error: None,
10030 };
10031 records
10032 .records
10033 .insert(new_record_id.clone(), replacement.clone());
10034 upserts.push(replacement);
10035 rebind = Some(new_record_id);
10036 } else if let Some(current) = records.records.get_mut(record_id) {
10037 current.state = ManagedSessionState::Unavailable;
10038 current.last_error =
10039 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10040 upserts.push(current.clone());
10041 }
10042 } else if let Some(current) = records.records.get_mut(record_id) {
10043 current.provider_session = Some(identity);
10044 current.active_session = Some(address.clone());
10045 current.state = ManagedSessionState::Live;
10046 current.updated_at_unix_ms = unix_time_ms();
10047 current.last_error = None;
10048 upserts.push(current.clone());
10049 }
10050 } else if let Some(current) = records.records.get_mut(record_id) {
10051 if resume_command_rejected {
10052 current.active_session = None;
10053 current.state = detached_record_state(current);
10054 current.updated_at_unix_ms = unix_time_ms();
10055 current.last_error =
10056 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10057 upserts.push(current.clone());
10058 } else {
10059 match &event.event {
10060 ControlEventKind::Running { .. }
10061 | ControlEventKind::ResumeAuthorized { .. }
10062 | ControlEventKind::Resumed { .. } => {
10063 current.active_session = Some(address.clone());
10064 current.state = active_record_state(
10065 identity_admitted,
10066 current.provider_session.is_some(),
10067 );
10068 current.updated_at_unix_ms = unix_time_ms();
10069 current.last_error = None;
10070 upserts.push(current.clone());
10071 }
10072 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10073 current.active_session = None;
10074 current.state = detached_record_state(current);
10075 current.updated_at_unix_ms = unix_time_ms();
10076 upserts.push(current.clone());
10077 }
10078 ControlEventKind::Failed { message }
10079 | ControlEventKind::ResumeFailed { message } => {
10080 current.active_session = None;
10081 current.state = detached_record_state(current);
10082 current.updated_at_unix_ms = unix_time_ms();
10083 current.last_error = Some(
10084 session_registry::sanitized_record_error_summary(message),
10085 );
10086 upserts.push(current.clone());
10087 }
10088 ControlEventKind::ResumeDenied { reason } => {
10089 current.active_session = None;
10090 current.state = detached_record_state(current);
10091 current.updated_at_unix_ms = unix_time_ms();
10092 current.last_error = Some(
10093 session_registry::sanitized_record_error_summary(reason),
10094 );
10095 upserts.push(current.clone());
10096 }
10097 _ => {}
10098 }
10099 }
10100 }
10101 }
10102 let materialization_owner_record = identity_was_observed.then(|| {
10103 rebind.clone().unwrap_or_else(|| record_id.clone())
10104 }).and_then(|candidate| {
10105 self.session_records
10106 .lock()
10107 .unwrap_or_else(|poisoned| poisoned.into_inner())
10108 .records
10109 .get(&candidate)
10110 .filter(|record| {
10111 record.provider_session.is_some()
10112 && record.active_session.as_ref() == Some(address)
10113 })
10114 .map(|_| candidate)
10115 });
10116 if let Some(owner_record_id) = materialization_owner_record {
10117 let materialization_id = self
10118 .session_bindings
10119 .lock()
10120 .unwrap_or_else(|poisoned| poisoned.into_inner())
10121 .get(&address.session.instance_id)
10122 .and_then(|binding| binding.materialization_id.clone());
10123 if let Some(materialization_id) = materialization_id {
10124 let mut transfer_failed = false;
10125 {
10126 let mut materializations = self
10127 .materializations
10128 .lock()
10129 .unwrap_or_else(|poisoned| poisoned.into_inner());
10130 if let Some(ownership) = materializations.get_mut(&materialization_id) {
10131 match ownership.owner() {
10132 MaterializationOwner::Session {
10133 incarnation_id,
10134 instance_id,
10135 generation,
10136 } if *incarnation_id == self.incarnation_id
10137 && *instance_id == address.session.instance_id
10138 && *generation == address.session.generation =>
10139 {
10140 transfer_failed = ownership
10141 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10142 .is_err();
10143 }
10144 MaterializationOwner::Record { record_id }
10145 if record_id == &owner_record_id => {}
10146 MaterializationOwner::Record { record_id } => {
10147 let expected = record_id.clone();
10148 transfer_failed = ownership
10149 .transfer_record_owner(
10150 &expected,
10151 owner_record_id.clone(),
10152 unix_time_ms(),
10153 )
10154 .is_err();
10155 }
10156 _ => transfer_failed = true,
10157 }
10158 if transfer_failed {
10159 let _ = ownership.mark_recovery_required(unix_time_ms());
10160 }
10161 } else {
10162 transfer_failed = true;
10163 }
10164 }
10165 if transfer_failed {
10166 if let Some(record) = self
10167 .session_records
10168 .lock()
10169 .unwrap_or_else(|poisoned| poisoned.into_inner())
10170 .records
10171 .get_mut(&owner_record_id)
10172 {
10173 record.state = ManagedSessionState::Unavailable;
10174 record.last_error = Some("environment-profile-unavailable".to_owned());
10175 record.updated_at_unix_ms = unix_time_ms();
10176 upserts.push(record.clone());
10177 }
10178 }
10179 }
10180 }
10181 if let Some(new_record_id) = rebind.clone() {
10182 if let Some(binding) = self
10183 .session_bindings
10184 .lock()
10185 .unwrap_or_else(|poisoned| poisoned.into_inner())
10186 .get_mut(&address.session.instance_id)
10187 {
10188 binding.record_id = Some(new_record_id);
10189 }
10190 }
10191 if upserts.is_empty() && removals.is_empty() {
10192 return;
10193 }
10194 if self.persist_state_locked().is_err() {
10195 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10196 let mut records = self
10197 .session_records
10198 .lock()
10199 .unwrap_or_else(|poisoned| poisoned.into_inner());
10200 records.records = original_records;
10201 *self
10202 .materializations
10203 .lock()
10204 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10205 if let Some(current) = records.records.get_mut(record_id) {
10206 current.state = ManagedSessionState::Unavailable;
10207 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10208 current.updated_at_unix_ms = unix_time_ms();
10209 upserts = vec![current.clone()];
10210 } else {
10211 upserts.clear();
10212 }
10213 removals.clear();
10214 drop(records);
10215 if let Some(binding) = self
10216 .session_bindings
10217 .lock()
10218 .unwrap_or_else(|poisoned| poisoned.into_inner())
10219 .get_mut(&address.session.instance_id)
10220 {
10221 binding.record_id = Some(record_id.clone());
10222 }
10223 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10224 }
10225 for record in upserts {
10226 self.publish_record(record);
10227 }
10228 for record_id in removals {
10229 self.publish(NodeEvent::SessionRecordRemoved { record_id });
10230 }
10231 }
10232
10233 fn snapshot(&self) -> NodeSnapshot {
10234 let control = self.handle.snapshot();
10235 let bindings = self
10236 .session_bindings
10237 .lock()
10238 .unwrap_or_else(|poisoned| poisoned.into_inner())
10239 .clone();
10240 let workspace_roots = self
10241 .workspaces
10242 .read()
10243 .unwrap_or_else(|poisoned| poisoned.into_inner())
10244 .clone();
10245 let environment_profiles = self
10246 .environment_profiles
10247 .read()
10248 .unwrap_or_else(|poisoned| poisoned.into_inner());
10249 let mut workspaces = workspace_roots
10250 .iter()
10251 .map(|(workspace_id, canonical_root)| {
10252 (
10253 workspace_id.clone(),
10254 WorkspaceSnapshot {
10255 workspace_id: workspace_id.clone(),
10256 canonical_root: opaque_windows_path(canonical_root.clone()),
10257 sessions: Vec::new(),
10258 worktree_service_mode: Some(
10259 self.worktree_service_modes
10260 .get(workspace_id)
10261 .copied()
10262 .unwrap_or(WorktreeServiceMode::Manual),
10263 ),
10264 managed_worktree_profiles: Some(WorktreeProfileInventory {
10265 profiles: self
10266 .managed_worktree_profiles
10267 .get(workspace_id)
10268 .into_iter()
10269 .flat_map(BTreeMap::values)
10270 .map(|profile| ManagedWorktreeProfileSummary {
10271 id: profile.profile_id().clone(),
10272 revision: profile.revision().clone(),
10273 retention: profile.retention(),
10274 })
10275 .collect(),
10276 }),
10277 },
10278 )
10279 })
10280 .collect::<BTreeMap<_, _>>();
10281 let mut agent_progress = Vec::new();
10282 for session in &control.sessions {
10283 let Some(binding) = bindings.get(&session.instance_id) else {
10284 continue;
10285 };
10286 if binding.generation != session.generation {
10287 continue;
10288 }
10289 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10290 let address = SessionAddress {
10291 workspace_id: binding.workspace_id.clone(),
10292 session: SessionKey {
10293 instance_id: session.instance_id,
10294 generation: session.generation,
10295 },
10296 };
10297 if let Some(progress) =
10298 agent_progress_from_provider_snapshot(address, &session.provider)
10299 {
10300 agent_progress.push(progress);
10301 }
10302 workspace.sessions.push(session.clone());
10303 }
10304 }
10305 NodeSnapshot {
10306 node_id: self.node_id.clone(),
10307 enabled_providers: self.enabled_providers.clone(),
10308 provider_runtime_statuses: {
10309 let updates = self
10310 .provider_runtime_status_updates
10311 .read()
10312 .unwrap_or_else(|poisoned| poisoned.into_inner());
10313 ProviderRuntimeStatuses::new(
10314 self.provider_runtime_statuses
10315 .iter()
10316 .filter(|status| !updates.contains_key(status.provider()))
10317 .cloned()
10318 .chain(updates.values().cloned()),
10319 )
10320 .expect("refreshed provider runtime inventory remains bounded and unique")
10321 },
10322 workspaces: workspaces.into_values().collect(),
10323 session_records: self
10324 .session_records
10325 .lock()
10326 .unwrap_or_else(|poisoned| poisoned.into_inner())
10327 .records
10328 .values()
10329 .cloned()
10330 .collect(),
10331 managed_worktrees: self.managed_worktrees
10332 .lock()
10333 .unwrap_or_else(|poisoned| poisoned.into_inner())
10334 .snapshots(),
10335 launch_inventory: Some(LaunchInventory {
10336 spawn_profiles: Some(
10337 self.spawn_profiles
10338 .iter()
10339 .filter_map(|profile| {
10340 resolved_spawn_profile_summary(profile, &environment_profiles)
10341 })
10342 .collect(),
10343 ),
10344 bundles: Some(
10345 self.bundle_catalog
10346 .read()
10347 .unwrap_or_else(|poisoned| poisoned.into_inner())
10348 .iter()
10349 .map(NodeBundle::receipt)
10350 .collect(),
10351 ),
10352 network_allowlists: {
10355 let catalog = self
10356 .network_allowlist_catalog
10357 .read()
10358 .unwrap_or_else(|poisoned| poisoned.into_inner());
10359 if catalog.is_empty() {
10360 None
10361 } else {
10362 Some(catalog.ids().cloned().collect())
10363 }
10364 },
10365 }),
10366 agent_progress,
10367 }
10368 }
10369
10370 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
10371 assert!(
10372 !matches!(
10373 &event,
10374 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
10375 ),
10376 "terminal frame and agent stream events must use their own dedicated live channel",
10377 );
10378 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10379 let sequence = history
10380 .last_sequence
10381 .checked_add(1)
10382 .expect("node event sequence exhausted");
10383 if history.events.len() == NODE_EVENT_HISTORY_MAX {
10384 if let Some(evicted) = history.events.pop_front() {
10385 history.replay_floor_sequence = evicted
10386 .sequence
10387 .checked_add(1)
10388 .expect("node replay floor sequence exhausted");
10389 history.removed_record_providers.remove(&evicted.sequence);
10390 }
10391 }
10392 match &event {
10393 NodeEvent::HarnessMcpReadCall { .. } => {
10394 panic!("transient harness MCP calls must not enter durable event history")
10395 }
10396 NodeEvent::SessionRecordUpserted { record } => {
10397 history
10398 .record_providers
10399 .insert(record.record_id.clone(), record.provider.clone());
10400 }
10401 NodeEvent::SessionRecordRemoved { record_id } => {
10402 if let Some(provider) = history.record_providers.remove(record_id) {
10403 history.removed_record_providers.insert(sequence, provider);
10404 }
10405 }
10406 NodeEvent::ManagedWorktreeUpserted { .. }
10407 | NodeEvent::ManagedWorktreeRemoved { .. }
10408 | NodeEvent::Control { .. }
10409 | NodeEvent::ControllerChanged { .. }
10410 | NodeEvent::SessionRecordHistorySummarized { .. }
10411 | NodeEvent::WorkspaceAdded { .. }
10412 | NodeEvent::WorkspaceRemoved { .. }
10413 | NodeEvent::ResyncRequired { .. }
10414 | NodeEvent::AgentStream { .. }
10415 | NodeEvent::TerminalFrame { .. } => {}
10416 }
10417 let envelope = NodeEventEnvelope { sequence, event };
10418 history.events.push_back(envelope.clone());
10419 history.last_sequence = sequence;
10420 let _ = self.event_tx.send(envelope.clone());
10421 drop(history);
10422 envelope
10423 }
10424
10425 fn publish_transient(&self, event: NodeEvent) -> bool {
10436 assert!(event.requires_harness_mcp_proxy_capability());
10437 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
10438 let envelope = NodeEventEnvelope { sequence: 0, event };
10439 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
10440 Ok(_) => true,
10441 Err(broadcast::error::SendError(undelivered)) => {
10442 if let Some(NodeEventEnvelope {
10443 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
10444 ..
10445 }) = undelivered.first() {
10446 tracing::warn!(
10452 call_id = call_id.as_str(),
10453 content_type = ?request.content_type,
10454 cause = "no-subscribers",
10455 "harness MCP proxy event could not be published",
10456 );
10457 }
10458 false
10459 }
10460 }
10461 }
10462
10463 fn publish_terminal_frames(&self) {
10464 let snapshot = self.handle.snapshot();
10465 let bindings = self
10466 .session_bindings
10467 .lock()
10468 .unwrap_or_else(|poisoned| poisoned.into_inner());
10469 let candidates = snapshot
10470 .sessions
10471 .iter()
10472 .filter_map(|session| {
10473 let binding = bindings.get(&session.instance_id)?;
10474 if binding.generation != session.generation {
10475 return None;
10476 }
10477 let frame = session.terminal_frame.clone()?;
10478 Some((
10479 SessionAddress {
10480 workspace_id: binding.workspace_id.clone(),
10481 session: SessionKey {
10482 instance_id: session.instance_id,
10483 generation: session.generation,
10484 },
10485 },
10486 frame,
10487 ))
10488 })
10489 .collect();
10490 drop(bindings);
10491 self.publish_terminal_frame_candidates(candidates);
10492 }
10493
10494 fn publish_terminal_frame_candidates(
10495 &self,
10496 candidates: Vec<(SessionAddress, TerminalFrame)>,
10497 ) {
10498 let mut watermarks = self
10499 .terminal_frame_watermarks
10500 .lock()
10501 .unwrap_or_else(|poisoned| poisoned.into_inner());
10502 let mut events = Vec::with_capacity(candidates.len());
10503 for (address, frame) in candidates {
10504 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
10505 let advanced = watermarks
10506 .get(&address.session.instance_id)
10507 .map_or(true, |(current_address, sequence)| {
10508 current_address != &address || frame.sequence > *sequence
10509 });
10510 if !advanced {
10511 continue;
10512 }
10513 if is_first_frame {
10514 if self.controller_state().is_none() {
10522 tracing::warn!(
10523 node_id = %self.node_id,
10524 workspace_id = %address.workspace_id,
10525 session = ?address.session,
10526 terminal_size = ?frame.size,
10527 "session producing output with no controller attached",
10528 );
10529 } else {
10530 tracing::info!(
10531 node_id = %self.node_id,
10532 workspace_id = %address.workspace_id,
10533 session = ?address.session,
10534 terminal_size = ?frame.size,
10535 "session produced its first output",
10536 );
10537 }
10538 }
10539 watermarks.insert(
10540 address.session.instance_id,
10541 (address.clone(), frame.sequence),
10542 );
10543 events.push(NodeEvent::TerminalFrame { address, frame });
10544 }
10545 drop(watermarks);
10546 if events.is_empty() {
10547 return;
10548 }
10549 let mut history = self
10550 .history
10551 .lock()
10552 .unwrap_or_else(|poisoned| poisoned.into_inner());
10553 let mut envelopes = Vec::with_capacity(events.len());
10554 for event in events {
10555 let sequence = history
10556 .last_sequence
10557 .checked_add(1)
10558 .expect("node event sequence exhausted");
10559 history.last_sequence = sequence;
10560 envelopes.push(NodeEventEnvelope { sequence, event });
10561 }
10562 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
10563 drop(history);
10564 }
10565
10566 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
10575 let mut history = self
10576 .history
10577 .lock()
10578 .unwrap_or_else(|poisoned| poisoned.into_inner());
10579 let sequence = history
10580 .last_sequence
10581 .checked_add(1)
10582 .expect("node event sequence exhausted");
10583 history.last_sequence = sequence;
10584 drop(history);
10585 tracing::debug!(
10593 source_sequence = chunk.source_sequence,
10594 kind = agent_stream_chunk_kind_label(&chunk.kind),
10595 envelope_sequence = sequence,
10596 "publishing agent stream chunk",
10597 );
10598 let envelope = NodeEventEnvelope {
10599 sequence,
10600 event: NodeEvent::AgentStream { address, chunk },
10601 };
10602 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
10603 }
10604
10605 fn current_sequence(&self) -> u64 {
10606 self.history
10607 .lock()
10608 .unwrap_or_else(|poisoned| poisoned.into_inner())
10609 .last_sequence
10610 }
10611
10612 fn controller_state(&self) -> Option<ControllerState> {
10613 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10614 let lease = controller.as_ref().copied()?;
10615 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
10616 if remaining.is_zero() {
10617 *controller = None;
10618 return None;
10619 }
10620 Some(ControllerState {
10621 connection_id: lease.connection_id,
10622 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
10623 })
10624 }
10625
10626 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
10627 if role != ClientRole::Operator {
10628 tracing::warn!(
10629 node_id = %self.node_id,
10630 connection_id,
10631 "controller lease rejected: observer connections cannot acquire control",
10632 );
10633 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
10634 }
10635 let lease_ms = if lease_ms == 0 {
10636 DEFAULT_CONTROLLER_LEASE_MS
10637 } else {
10638 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
10639 };
10640 let now = Instant::now();
10641 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10642 if let Some(active) = controller.as_ref() {
10643 if active.connection_id != connection_id && active.expires_at > now {
10644 tracing::warn!(
10645 node_id = %self.node_id,
10646 connection_id,
10647 holder_connection_id = active.connection_id,
10648 "controller lease rejected: another operator holds it",
10649 );
10650 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
10651 }
10652 }
10653 let is_new_attach = match controller.as_ref() {
10660 Some(active) => active.connection_id != connection_id,
10661 None => true,
10662 };
10663 *controller = Some(ControllerLease {
10664 connection_id,
10665 expires_at: now + Duration::from_millis(lease_ms),
10666 });
10667 drop(controller);
10668 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
10669 if is_new_attach {
10670 tracing::info!(
10671 node_id = %self.node_id,
10672 connection_id,
10673 lease_ms,
10674 "controller lease acquired",
10675 );
10676 } else {
10677 tracing::debug!(
10678 node_id = %self.node_id,
10679 connection_id,
10680 lease_ms,
10681 "controller lease renewed",
10682 );
10683 }
10684 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
10685 Ok(state)
10686 }
10687
10688 fn release_controller(&self, connection_id: u64) -> bool {
10689 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10690 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
10691 if released {
10692 *controller = None;
10693 }
10694 drop(controller);
10695 if released {
10696 tracing::info!(
10697 node_id = %self.node_id,
10698 connection_id,
10699 "controller lease released",
10700 );
10701 self.publish(NodeEvent::ControllerChanged { controller: None });
10702 }
10703 released
10704 }
10705
10706 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
10707 if role != ClientRole::Operator {
10708 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
10709 }
10710 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10711 let now = Instant::now();
10712 match controller.as_ref().copied() {
10713 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
10714 Some(active) if active.expires_at <= now => {
10715 *controller = None;
10716 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
10717 }
10718 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
10719 }
10720 }
10721
10722 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
10723 if !self
10724 .workspaces
10725 .read()
10726 .unwrap_or_else(|poisoned| poisoned.into_inner())
10727 .contains_key(&address.workspace_id)
10728 {
10729 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10730 }
10731 let binding = self
10732 .session_bindings
10733 .lock()
10734 .unwrap_or_else(|poisoned| poisoned.into_inner())
10735 .get(&address.session.instance_id)
10736 .cloned()
10737 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10738 if binding.workspace_id != address.workspace_id {
10739 return Err(failure(
10740 NodeFailureCode::SessionWorkspaceMismatch,
10741 "session belongs to another workspace",
10742 ));
10743 }
10744 if binding.generation != address.session.generation {
10745 return Err(failure(
10746 NodeFailureCode::StaleGeneration,
10747 &format!(
10748 "stale session generation: expected {}, received {}",
10749 binding.generation.0,
10750 address.session.generation.0,
10751 ),
10752 ));
10753 }
10754 let snapshot = self.handle.snapshot();
10755 let Some(current) = snapshot
10756 .sessions
10757 .iter()
10758 .find(|item| item.instance_id == address.session.instance_id)
10759 else {
10760 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
10761 };
10762 if current.generation != address.session.generation {
10763 return Err(failure(
10764 NodeFailureCode::StaleGeneration,
10765 &format!(
10766 "stale session generation: expected {}, received {}",
10767 current.generation.0,
10768 address.session.generation.0,
10769 ),
10770 ));
10771 }
10772 Ok(current.agent_id.clone())
10773 }
10774
10775 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
10776 self.dispatch_envelope(self.prepare_command(command))
10777 }
10778
10779 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
10780 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
10781 CommandEnvelope {
10782 id: CommandId(id),
10783 command,
10784 }
10785 }
10786
10787 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
10788 let command_id = envelope.id;
10789 self.handle
10790 .dispatch(envelope)
10791 .map_err(|error| match error {
10792 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
10793 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
10794 })?;
10795 Ok(command_id)
10796 }
10797
10798 fn record_rejected_command(
10807 &self,
10808 command_id: CommandId,
10809 message: String,
10810 unsupported_transport: bool,
10811 ) {
10812 let now = Instant::now();
10813 let mut reasons = self.rejected_command_reasons.lock()
10814 .unwrap_or_else(|poisoned| poisoned.into_inner());
10815 reasons.retain(|_, reason| reason.expires_at > now);
10816 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
10817 if let Some(&oldest) = reasons.keys().next() {
10818 reasons.remove(&oldest);
10819 }
10820 }
10821 reasons.insert(
10822 command_id,
10823 RejectedCommandReason {
10824 message,
10825 unsupported_transport,
10826 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
10827 },
10828 );
10829 }
10830
10831 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
10838 let now = Instant::now();
10839 let mut reasons = self.rejected_command_reasons.lock()
10840 .unwrap_or_else(|poisoned| poisoned.into_inner());
10841 match reasons.remove(&command_id) {
10842 Some(reason) if reason.expires_at > now => Some(reason),
10843 _ => None,
10844 }
10845 }
10846
10847 async fn dispatch_bounded(
10848 &self,
10849 command: ControlCommand,
10850 timeout_duration: Duration,
10851 ) -> Result<CommandId, NodeFailure> {
10852 let deadline = Instant::now() + timeout_duration;
10853 loop {
10854 match self.dispatch(command.clone()) {
10855 Ok(command_id) => return Ok(command_id),
10856 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
10857 sleep(Duration::from_millis(2)).await;
10858 }
10859 Err(error) => return Err(error),
10860 }
10861 }
10862 }
10863
10864 fn internal_session_snapshot(
10865 &self,
10866 address: &SessionAddress,
10867 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
10868 self.validate_address(address)?;
10869 self.handle
10870 .snapshot()
10871 .sessions
10872 .clone()
10873 .into_iter()
10874 .find(|session| {
10875 session.instance_id == address.session.instance_id
10876 && session.generation == address.session.generation
10877 })
10878 .ok_or_else(|| {
10879 failure(
10880 NodeFailureCode::UnknownSession,
10881 "session snapshot is unavailable",
10882 )
10883 })
10884 }
10885
10886 fn require_acp_session(
10894 &self,
10895 address: &SessionAddress,
10896 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
10897 let snapshot = self.internal_session_snapshot(address)?;
10898 if snapshot.transport != TransportKind::Acp {
10899 return Err(failure(
10900 NodeFailureCode::UnsupportedTransport,
10901 "this verb requires a session spawned over the ACP transport",
10902 ));
10903 }
10904 Ok(snapshot)
10905 }
10906
10907 async fn dispatch_history_bounded(
10908 &self,
10909 address: &SessionAddress,
10910 command: ControlCommand,
10911 operation: HistoryOperation,
10912 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
10913 self.validate_address(address)?;
10914 let started_after = self.current_sequence();
10915 let command_id = self
10916 .dispatch_bounded(
10917 command,
10918 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
10919 )
10920 .await?;
10921 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
10922 let mut scan_after = started_after;
10923 let mut accepted_after = None;
10924 loop {
10925 let events = self
10926 .history
10927 .lock()
10928 .unwrap_or_else(|poisoned| poisoned.into_inner())
10929 .events
10930 .iter()
10931 .filter(|envelope| envelope.sequence > scan_after)
10932 .cloned()
10933 .collect::<Vec<_>>();
10934 for envelope in events {
10935 scan_after = scan_after.max(envelope.sequence);
10936 let NodeEvent::Control {
10937 address: event_address,
10938 event,
10939 } = envelope.event
10940 else {
10941 continue;
10942 };
10943 if event_address != *address {
10944 continue;
10945 }
10946 if event.command_id == Some(command_id) {
10947 match &event.event {
10948 ControlEventKind::CommandRejected { .. } => {
10949 return Err(failure(
10950 NodeFailureCode::InvalidRequest,
10951 "history request was rejected",
10952 ));
10953 }
10954 ControlEventKind::HistoryRequested {
10955 operation: requested,
10956 ..
10957 } if requested == &operation => {
10958 accepted_after = Some(envelope.sequence);
10959 continue;
10960 }
10961 _ => {}
10962 }
10963 }
10964 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
10965 match (&operation, &event.event) {
10966 (
10967 HistoryOperation::Discover { .. },
10968 ControlEventKind::HistoryDiscovered { .. },
10969 )
10970 | (
10971 HistoryOperation::Load { .. },
10972 ControlEventKind::HistoryLoaded { .. },
10973 ) => return self.internal_session_snapshot(address),
10974 (_, ControlEventKind::HistoryFailed { .. }) => {
10975 return Err(failure(
10976 NodeFailureCode::BackendOperationFailed,
10977 "history operation failed",
10978 ));
10979 }
10980 _ => {}
10981 }
10982 }
10983 }
10984 if Instant::now() >= deadline {
10985 return Err(failure(
10986 NodeFailureCode::BackendBusy,
10987 if accepted_after.is_some() {
10988 "history operation did not settle before the bounded deadline"
10989 } else {
10990 "history operation was not accepted before the bounded deadline"
10991 },
10992 ));
10993 }
10994 sleep(Duration::from_millis(2)).await;
10995 }
10996 }
10997
10998 async fn discover_history(
10999 &self,
11000 address: &SessionAddress,
11001 limit: u16,
11002 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11003 let query = HistoryQuery {
11004 working_directory: Some(self.workspace_root(&address.workspace_id)?),
11005 limit,
11006 };
11007 query
11008 .validate()
11009 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11010 let snapshot = self
11011 .dispatch_history_bounded(
11012 address,
11013 ControlCommand::DiscoverHistory {
11014 instance_id: address.session.instance_id,
11015 query: query.clone(),
11016 },
11017 HistoryOperation::Discover { query },
11018 )
11019 .await?;
11020 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11021 return Err(failure(
11022 NodeFailureCode::BackendOperationFailed,
11023 "history discovery settled into an invalid state",
11024 ));
11025 }
11026 Ok(snapshot.history.candidates)
11027 }
11028
11029 async fn load_history(
11030 &self,
11031 address: &SessionAddress,
11032 candidate_id: String,
11033 ) -> Result<HistorySessionRecord, NodeFailure> {
11034 validate_candidate_id(&candidate_id)
11035 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11036 let snapshot = self
11037 .dispatch_history_bounded(
11038 address,
11039 ControlCommand::LoadHistory {
11040 instance_id: address.session.instance_id,
11041 candidate_id: candidate_id.clone(),
11042 },
11043 HistoryOperation::Load {
11044 candidate_id: candidate_id.clone(),
11045 },
11046 )
11047 .await?;
11048 if snapshot.history.pending.is_some()
11049 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11050 {
11051 return Err(failure(
11052 NodeFailureCode::BackendOperationFailed,
11053 "history load settled into an invalid state",
11054 ));
11055 }
11056 snapshot.history.loaded.ok_or_else(|| {
11057 failure(
11058 NodeFailureCode::BackendOperationFailed,
11059 "history load produced no bounded session",
11060 )
11061 })
11062 }
11063
11064 async fn export_context_pack_for_session_record(
11071 &self,
11072 record_id: &SessionRecordId,
11073 session: &SessionAddress,
11074 allow_clean_detachment: bool,
11075 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11076 let outcome = self
11082 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11083 .await;
11084 if let Err(failure) = &outcome {
11085 tracing::warn!(
11086 record = %record_id,
11087 code = ?failure.code,
11088 reason = %failure.message,
11089 "session-record context-pack export refused",
11090 );
11091 }
11092 outcome
11093 }
11094
11095 async fn export_context_pack_for_session_record_inner(
11096 &self,
11097 record_id: &SessionRecordId,
11098 session: &SessionAddress,
11099 allow_clean_detachment: bool,
11100 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11101 let step = |name: &'static str| move |failure: NodeFailure| {
11106 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11107 failure
11108 };
11109 let record = self.record(record_id).map_err(step("record-lookup"))?;
11110 let identity = match record.provider_session.clone() {
11126 Some(identity) => {
11127 if identity.key != ProviderSessionKey::SessionId {
11128 return Err(step("provider-identity-key")(failure(
11129 NodeFailureCode::BackendOperationFailed,
11130 "managed session provider identity cannot bind history exactly",
11131 )));
11132 }
11133 Some(identity)
11134 }
11135 None if record.state == ManagedSessionState::IdentityPending => {
11136 return Err(step("provider-session-identity")(failure(
11137 NodeFailureCode::BackendOperationFailed,
11138 "managed session has not yet observed its provider session identity",
11139 )));
11140 }
11141 None => None,
11142 };
11143 self.revalidate_session_record_context_export(
11144 &record,
11145 identity.as_ref(),
11146 session,
11147 allow_clean_detachment,
11148 ).map_err(step("revalidate-before-discovery"))?;
11149 let candidates = self
11150 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11151 .await
11152 .map_err(step("discover-history"))?;
11153 let candidate_id = select_history_candidate(
11158 &candidates,
11159 identity.as_ref().map(|identity| identity.id.as_str()),
11160 ).map_err(step("unique-candidate"))?;
11161 let loaded = self.load_history(session, candidate_id.clone()).await
11162 .map_err(step("load-history"))?;
11163 if let Some(identity) = identity.as_ref() {
11164 if loaded.session_id != identity.id {
11165 return Err(failure(
11166 NodeFailureCode::SessionRecordConflict,
11167 "loaded history identity does not match the managed session record",
11168 ));
11169 }
11170 }
11171 self.revalidate_session_record_context_export(
11172 &record,
11173 identity.as_ref(),
11174 session,
11175 allow_clean_detachment,
11176 )?;
11177 self.revalidate_loaded_history(session, &candidate_id, &loaded)
11178 .map_err(step("revalidate-loaded-history"))?;
11179 let pack = self.materialize_context_pack(session).await
11180 .map_err(step("materialize-context-pack"))?;
11181 self.commit_context_pack_for_session_record(
11182 &record,
11183 identity.as_ref(),
11184 session,
11185 &candidate_id,
11186 &loaded,
11187 pack,
11188 allow_clean_detachment,
11189 )
11190 }
11191
11192 async fn export_context_pack(
11193 &self,
11194 address: &SessionAddress,
11195 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11196 let pack = self.materialize_context_pack(address).await?;
11197 self.commit_context_pack(pack)
11198 }
11199
11200 async fn materialize_context_pack(
11201 &self,
11202 address: &SessionAddress,
11203 ) -> Result<NodeContextPack, NodeFailure> {
11204 let snapshot = self.internal_session_snapshot(address)?;
11205 if !context_pack_source_status_is_usable(&snapshot.status) {
11206 return Err(failure(
11207 NodeFailureCode::InvalidRequest,
11208 "context export requires a running or successfully exited source session",
11209 ));
11210 }
11211 if snapshot.history.pending.is_some()
11212 || snapshot.history.loaded_candidate_id.is_none()
11213 {
11214 return Err(failure(
11215 NodeFailureCode::InvalidRequest,
11216 "context export requires settled loaded history",
11217 ));
11218 }
11219 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11220 failure(
11221 NodeFailureCode::InvalidRequest,
11222 "context export requires settled loaded history",
11223 )
11224 })?;
11225 let source_provider = snapshot.agent_id.clone();
11226 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11227 let repository = self
11228 .context_pack_repository(&address.workspace_id)
11229 .await?;
11230 let current = self.internal_session_snapshot(address)?;
11231 if !context_pack_source_status_is_usable(¤t.status)
11232 || current.agent_id != source_provider
11233 || current.history.pending.is_some()
11234 || current.history.loaded_candidate_id != loaded_candidate_id
11235 || current.history.loaded.as_ref() != Some(&loaded)
11236 {
11237 return Err(failure(
11238 NodeFailureCode::ContextPackMaterializationFailed,
11239 "context source changed during bounded repository capture",
11240 ));
11241 }
11242 NodeContextPack::export_with_repository(
11243 ContextPackLineageReceipt {
11244 source_node_id: self.node_id.clone(),
11245 source_session: address.clone(),
11246 source_provider,
11247 },
11248 &loaded,
11249 Some(repository),
11250 )
11251 .map_err(|_| {
11252 failure(
11253 NodeFailureCode::ContextPackMaterializationFailed,
11254 "bounded context pack export failed",
11255 )
11256 })
11257 }
11258
11259 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
11265 let mut store = self
11266 .context_pack_store
11267 .lock()
11268 .unwrap_or_else(|poisoned| poisoned.into_inner());
11269 let Some(store) = store.as_mut() else {
11270 return Ok(());
11271 };
11272 store.commit(pack).map_err(|_| {
11273 failure(
11274 NodeFailureCode::ContextPackMaterializationFailed,
11275 "durable context pack commit failed",
11276 )
11277 })
11278 }
11279
11280 fn commit_context_pack(
11281 &self,
11282 pack: NodeContextPack,
11283 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11284 self.commit_context_pack_durably(&pack)?;
11285 self.context_catalog
11286 .write()
11287 .unwrap_or_else(|poisoned| poisoned.into_inner())
11288 .insert(pack)
11289 .map_err(|_| {
11290 failure(
11291 NodeFailureCode::ContextPackMaterializationFailed,
11292 "context pack catalog is full",
11293 )
11294 })
11295 }
11296
11297 fn commit_context_pack_for_session_record(
11298 &self,
11299 expected: &ManagedSessionRecord,
11300 identity: Option<&ProviderSessionIdentity>,
11301 session: &SessionAddress,
11302 candidate_id: &str,
11303 loaded: &HistorySessionRecord,
11304 pack: NodeContextPack,
11305 allow_clean_detachment: bool,
11306 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11307 let current_root = self.workspace_root(&expected.workspace_id)?;
11308 let _transaction = self
11309 .state_transaction
11310 .lock()
11311 .unwrap_or_else(|poisoned| poisoned.into_inner());
11312 let records = self
11313 .session_records
11314 .lock()
11315 .unwrap_or_else(|poisoned| poisoned.into_inner());
11316 let current = records.records.get(&expected.record_id).ok_or_else(|| {
11317 failure(
11318 NodeFailureCode::SessionRecordConflict,
11319 "managed session record disappeared during context export",
11320 )
11321 })?;
11322 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
11323 return Err(failure(
11324 NodeFailureCode::SessionRecordConflict,
11325 "managed session record changed during context export",
11326 ));
11327 }
11328 let bindings = self
11329 .session_bindings
11330 .lock()
11331 .unwrap_or_else(|poisoned| poisoned.into_inner());
11332 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
11333 failure(
11334 NodeFailureCode::SessionRecordConflict,
11335 "managed session runtime binding disappeared during context export",
11336 )
11337 })?;
11338 let runtime = self
11339 .handle
11340 .snapshot()
11341 .sessions
11342 .iter()
11343 .find(|snapshot| {
11344 snapshot.instance_id == session.session.instance_id
11345 && snapshot.generation == session.session.generation
11346 })
11347 .cloned()
11348 .ok_or_else(|| {
11349 failure(
11350 NodeFailureCode::SessionRecordConflict,
11351 "managed session runtime disappeared during context export",
11352 )
11353 })?;
11354 let receipt = pack.receipt();
11355 if !session_record_context_export_binding_is_exact(
11356 expected,
11357 current,
11358 identity,
11359 session,
11360 binding,
11361 &runtime.agent_id,
11362 ¤t_root,
11363 allow_clean_detachment,
11364 ) || !session_record_context_export_source_is_usable(
11365 current,
11366 &runtime.status,
11367 allow_clean_detachment,
11368 ) || runtime.history.pending.is_some()
11369 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
11370 || runtime.history.loaded.as_ref() != Some(loaded)
11371 || receipt.lineage.source_node_id != self.node_id
11372 || receipt.lineage.source_session != *session
11373 || receipt.lineage.source_provider != current.provider
11374 || receipt.source_message_count != loaded.message_count
11375 {
11376 return Err(failure(
11377 NodeFailureCode::SessionRecordConflict,
11378 "managed session source changed before context catalog commit",
11379 ));
11380 }
11381 self.commit_context_pack_durably(&pack)?;
11382 self.context_catalog
11383 .write()
11384 .unwrap_or_else(|poisoned| poisoned.into_inner())
11385 .insert(pack)
11386 .map_err(|_| {
11387 failure(
11388 NodeFailureCode::ContextPackMaterializationFailed,
11389 "context pack catalog is full",
11390 )
11391 })
11392 }
11393
11394 async fn context_pack_repository(
11395 &self,
11396 workspace_id: &WorkspaceId,
11397 ) -> Result<ContextPackRepository, NodeFailure> {
11398 let first = self
11399 .context_pack_repository_observation(workspace_id)
11400 .await?;
11401 let second = self
11402 .context_pack_repository_observation(workspace_id)
11403 .await?;
11404 stable_context_pack_repository(first, second)
11405 }
11406
11407 async fn context_pack_repository_observation(
11408 &self,
11409 workspace_id: &WorkspaceId,
11410 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
11411 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
11412 let head = if git.is_repository {
11413 let root = self.workspace_root(workspace_id)?;
11414 match resolve_base_commit_with_timeout(
11415 &root,
11416 "HEAD",
11417 GIT_COMMAND_TIMEOUT_MS,
11418 )
11419 .await
11420 {
11421 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
11422 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
11423 ContextPackRepositoryHead::Unborn
11424 }
11425 Err(_) => {
11426 return Err(failure(
11427 NodeFailureCode::ContextPackMaterializationFailed,
11428 "context pack HEAD observation failed",
11429 ));
11430 }
11431 }
11432 } else {
11433 ContextPackRepositoryHead::NotRepository
11434 };
11435 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
11436 for selected_path in CONTEXT_PACK_SELECTED_FILES {
11437 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
11438 failure(
11439 NodeFailureCode::InvalidRepositoryPath,
11440 "context pack selected file path is invalid",
11441 )
11442 })?;
11443 match self.read_workspace_file(workspace_id.clone(), path).await {
11444 Ok(WorkspaceFileRead {
11445 content: WorkspaceFileContent::Utf8 { text, byte_len },
11446 ..
11447 }) => files.push(ContextPackRepositoryFileSource::utf8(
11448 selected_path,
11449 text,
11450 byte_len,
11451 )),
11452 Ok(WorkspaceFileRead {
11453 content: WorkspaceFileContent::NonUtf8 { byte_len },
11454 ..
11455 }) => files.push(ContextPackRepositoryFileSource::skipped(
11456 selected_path,
11457 ContextPackSelectedFileSkipReason::NonUtf8,
11458 Some(byte_len),
11459 )),
11460 Ok(WorkspaceFileRead {
11461 content: WorkspaceFileContent::TooLarge { .. },
11462 ..
11463 }) => files.push(ContextPackRepositoryFileSource::skipped(
11464 selected_path,
11465 ContextPackSelectedFileSkipReason::TooLarge,
11466 None,
11467 )),
11468 Err(error) => {
11469 let reason = if matches!(
11470 error.code,
11471 NodeFailureCode::InvalidRepositoryPath
11472 | NodeFailureCode::RepositoryFileNotRegular
11473 | NodeFailureCode::RepositoryPathUnsafe
11474 ) {
11475 ContextPackSelectedFileSkipReason::Unsafe
11476 } else {
11477 ContextPackSelectedFileSkipReason::Unavailable
11478 };
11479 files.push(ContextPackRepositoryFileSource::skipped(
11480 selected_path,
11481 reason,
11482 None,
11483 ));
11484 }
11485 }
11486 }
11487 Ok(ContextPackRepositoryObservation { head, git, files })
11488 }
11489
11490 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
11491 let referenced_by_binding = self
11492 .session_bindings
11493 .lock()
11494 .unwrap_or_else(|poisoned| poisoned.into_inner())
11495 .values()
11496 .any(|binding| {
11497 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
11498 });
11499 let referenced_by_record = self
11500 .session_records
11501 .lock()
11502 .unwrap_or_else(|poisoned| poisoned.into_inner())
11503 .records
11504 .values()
11505 .any(|record| record.context_id.as_ref() == Some(context_id));
11506 let referenced_by_materialization = self
11507 .materializations
11508 .lock()
11509 .unwrap_or_else(|poisoned| poisoned.into_inner())
11510 .values()
11511 .any(|ownership| {
11512 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
11513 });
11514 if referenced_by_binding
11515 || referenced_by_record
11516 || referenced_by_materialization
11517 {
11518 return Err(failure(
11519 NodeFailureCode::ContextPackBusy,
11520 "context pack is still referenced",
11521 ));
11522 }
11523 let mut catalog = self.context_catalog
11524 .write()
11525 .unwrap_or_else(|poisoned| poisoned.into_inner());
11526 if catalog.get(context_id).is_none() {
11527 return Err(failure(
11528 NodeFailureCode::UnknownContextPack,
11529 "context pack is unavailable",
11530 ));
11531 }
11532 self.spawn_idempotency
11533 .lock()
11534 .unwrap_or_else(|poisoned| poisoned.into_inner())
11535 .entries
11536 .retain(|_, entry| !entry.value.references_context(context_id));
11537 let removed = catalog.remove(context_id);
11538 debug_assert!(removed.is_some(), "validated context pack remains present");
11539 Ok(())
11540 }
11541
11542 async fn dispatch_input_bounded(
11543 &self,
11544 address: &SessionAddress,
11545 action: InputAction,
11546 ) -> Result<(), NodeFailure> {
11547 let started_after = self.current_sequence();
11548 let command_id = self
11549 .dispatch_bounded(
11550 ControlCommand::SendInput {
11551 instance_id: address.session.instance_id,
11552 action,
11553 },
11554 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11555 )
11556 .await?;
11557 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
11561 let mut scan_after = started_after;
11562 let mut accepted_after = None;
11563 loop {
11564 let events = self
11565 .history
11566 .lock()
11567 .unwrap_or_else(|poisoned| poisoned.into_inner())
11568 .events
11569 .iter()
11570 .filter(|envelope| envelope.sequence > scan_after)
11571 .cloned()
11572 .collect::<Vec<_>>();
11573 for envelope in events {
11574 scan_after = scan_after.max(envelope.sequence);
11575 let NodeEvent::Control {
11576 address: event_address,
11577 event,
11578 } = envelope.event
11579 else {
11580 continue;
11581 };
11582 if event_address != *address {
11583 continue;
11584 }
11585 if event.command_id == Some(command_id) {
11586 match &event.event {
11587 ControlEventKind::CommandRejected { message } => {
11588 return Err(failure(NodeFailureCode::InvalidRequest, message));
11589 }
11590 ControlEventKind::InputRequested { .. } => {
11591 accepted_after = Some(envelope.sequence);
11592 continue;
11593 }
11594 _ => {}
11595 }
11596 }
11597 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11598 match &event.event {
11599 ControlEventKind::InputCompleted { .. } => return Ok(()),
11600 ControlEventKind::InputFailed { message, .. } => {
11601 return Err(failure(
11602 NodeFailureCode::BackendOperationFailed,
11603 message,
11604 ));
11605 }
11606 _ => {}
11607 }
11608 }
11609 }
11610 if Instant::now() >= deadline {
11611 return Err(failure(
11612 NodeFailureCode::BackendBusy,
11613 if accepted_after.is_some() {
11614 "input was accepted but did not settle before the bounded deadline"
11615 } else {
11616 "input was not accepted before the bounded deadline"
11617 },
11618 ));
11619 }
11620 sleep(Duration::from_millis(2)).await;
11621 }
11622 }
11623
11624 async fn dispatch_resize_bounded(
11625 &self,
11626 address: &SessionAddress,
11627 size: gate4agent_types::TerminalSize,
11628 ) -> Result<(), NodeFailure> {
11629 let started_after = self.current_sequence();
11630 let command_id = self
11631 .dispatch_bounded(
11632 ControlCommand::Resize {
11633 instance_id: address.session.instance_id,
11634 size,
11635 },
11636 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11637 )
11638 .await?;
11639 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11640 let mut scan_after = started_after;
11641 let mut accepted_after = None;
11642 loop {
11643 let events = self
11644 .history
11645 .lock()
11646 .unwrap_or_else(|poisoned| poisoned.into_inner())
11647 .events
11648 .iter()
11649 .filter(|envelope| envelope.sequence > scan_after)
11650 .cloned()
11651 .collect::<Vec<_>>();
11652 for envelope in events {
11653 scan_after = scan_after.max(envelope.sequence);
11654 let NodeEvent::Control {
11655 address: event_address,
11656 event,
11657 } = envelope.event
11658 else {
11659 continue;
11660 };
11661 if event_address != *address {
11662 continue;
11663 }
11664 if event.command_id == Some(command_id) {
11665 match &event.event {
11666 ControlEventKind::CommandRejected { message } => {
11667 return Err(failure(NodeFailureCode::InvalidRequest, message));
11668 }
11669 ControlEventKind::ResizeRequested {
11670 size: requested_size,
11671 ..
11672 } if *requested_size == size => {
11673 accepted_after = Some(envelope.sequence);
11674 continue;
11675 }
11676 _ => {}
11677 }
11678 }
11679 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11680 match &event.event {
11681 ControlEventKind::Resized { size: settled_size }
11682 if *settled_size == size =>
11683 {
11684 return Ok(());
11685 }
11686 ControlEventKind::ResizeFailed { message } => {
11687 return Err(failure(
11688 NodeFailureCode::BackendOperationFailed,
11689 message,
11690 ));
11691 }
11692 _ => {}
11693 }
11694 }
11695 }
11696 if Instant::now() >= deadline {
11697 return Err(failure(
11698 NodeFailureCode::BackendBusy,
11699 if accepted_after.is_some() {
11700 "resize was accepted but did not settle before the bounded deadline"
11701 } else {
11702 "resize was not accepted before the bounded deadline"
11703 },
11704 ));
11705 }
11706 sleep(Duration::from_millis(2)).await;
11707 }
11708 }
11709
11710 async fn spawn_session(
11711 &self,
11712 workspace_id: WorkspaceId,
11713 provider: AgentId,
11714 mode: SessionMode,
11715 terminal_size: gate4agent_types::TerminalSize,
11716 initial_prompt: Option<String>,
11717 ) -> Result<SessionAddress, NodeFailure> {
11718 if self.managed_worktrees.lock()
11719 .unwrap_or_else(|poisoned| poisoned.into_inner())
11720 .lease_for_workspace(&workspace_id)
11721 .is_some()
11722 {
11723 return Err(failure(
11724 NodeFailureCode::ManagedWorktreeOwnershipConflict,
11725 "managed worktree targets may only be spawned through their lease request",
11726 ));
11727 }
11728 self
11729 .spawn_session_with_deadline(
11730 workspace_id,
11731 provider,
11732 mode,
11733 terminal_size,
11734 initial_prompt,
11735 None,
11736 None,
11737 None,
11738 None,
11739 None,
11740 SpawnRecordPolicy::ProviderIdentityOnly,
11741 None,
11742 &[],
11743 None,
11744 ApprovalLevel::default(),
11750 None,
11751 None,
11752 )
11753 .await
11754 .map(|(session, _runtime_policy)| session)
11755 }
11756
11757 async fn spawn_session_with_deadline(
11758 &self,
11759 workspace_id: WorkspaceId,
11760 provider: AgentId,
11761 mode: SessionMode,
11762 terminal_size: gate4agent_types::TerminalSize,
11763 initial_prompt: Option<String>,
11764 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
11765 bundle: Option<ResolvedBundleReceipt>,
11766 context: Option<ResolvedContextPackReceipt>,
11767 managed_authority: Option<ManagedWorktreeLeaseId>,
11768 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
11769 record_policy: SpawnRecordPolicy,
11770 deadline: Option<Instant>,
11771 required_capabilities: &[ProviderRuntimeCapability],
11772 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
11773 approval_level: ApprovalLevel,
11774 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
11775 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
11781 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
11782 let reserved_lease = self.managed_worktrees.lock()
11783 .unwrap_or_else(|poisoned| poisoned.into_inner())
11784 .lease_for_workspace(&workspace_id);
11785 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
11786 (Some(expected), Some(provided)) if expected == provided => {}
11787 (None, None) => {}
11788 _ => {
11789 return Err(failure(
11790 NodeFailureCode::ManagedWorktreeOwnershipConflict,
11791 "spawn lacks exact managed worktree lease authority",
11792 ));
11793 }
11794 }
11795 self.ensure_workspace_environment_profile_compatible(
11799 &workspace_id,
11800 environment_profile.as_ref(),
11801 )?;
11802 let runtime_requirement = match (mode, initial_prompt.is_some()) {
11803 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
11804 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
11805 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
11806 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
11807 };
11808 let transport = match mode {
11814 SessionMode::Pty => TransportKind::Pty,
11815 SessionMode::Inline => TransportKind::Pipe,
11816 SessionMode::Acp => TransportKind::Acp,
11817 };
11818 let pipe_transport = self.provider_supports_pipe_transport(&provider);
11819 let acp_transport = self.provider_supports_acp_transport(&provider);
11820 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
11821 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
11822 NodeFailureCode::UnsupportedSpawnCapability,
11823 "pre-admitted provider runtime no longer satisfies spawn requirements",
11824 ))?;
11825 runtime_policy
11826 } else if let Some(deadline) = deadline {
11827 let remaining = spawn_deadline_remaining(deadline)?;
11828 timeout(
11829 remaining,
11830 self.admit_provider_runtime(&provider, runtime_requirement),
11831 )
11832 .await
11833 .map_err(|_| {
11834 failure(
11835 NodeFailureCode::SpawnDeadlineExceeded,
11836 "provider admission exceeded the spawn deadline",
11837 )
11838 })??
11839 } else {
11840 self.admit_provider_runtime(&provider, runtime_requirement).await?
11841 };
11842 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
11843 let runtime_policy =
11855 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
11856 if required_capabilities
11857 .iter()
11858 .any(|capability| !runtime_policy.admits(*capability))
11859 {
11860 return Err(failure(
11861 NodeFailureCode::UnsupportedSpawnCapability,
11862 "provider runtime does not admit a required spawn capability",
11863 ));
11864 }
11865 if let Some(deadline) = deadline {
11866 spawn_deadline_remaining(deadline)?;
11867 }
11868 self.ensure_binding_capacity()?;
11869 let working_directory = self.workspace_root(&workspace_id)?;
11870 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
11871 let session = SessionKey {
11872 instance_id,
11873 generation: SessionGeneration(1),
11874 };
11875 let address = SessionAddress {
11876 workspace_id: workspace_id.clone(),
11877 session,
11878 };
11879 tracing::info!(
11887 node_id = %self.node_id,
11888 workspace_id = %workspace_id,
11889 provider = %provider,
11890 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
11891 session = ?address.session,
11892 "session spawn request accepted",
11893 );
11894 #[cfg(feature = "dig2-station-probe")]
11899 let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
11900 use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
11901 match acquire_lease(
11902 &self.browser_station_leases,
11903 profile_id,
11904 instance_id,
11905 unix_time_ms(),
11906 ) {
11907 Ok(guard) => Some(guard),
11908 Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
11909 return Err(failure(
11910 NodeFailureCode::BrowserStationProfileBusy,
11911 "dig2browser station profile is already leased by another session",
11912 ));
11913 }
11914 Err(BrowserStationLeaseError::CapacityExceeded) => {
11915 return Err(failure(
11916 NodeFailureCode::BrowserStationProfileBusy,
11917 "dig2browser station profile lease capacity exhausted",
11918 ));
11919 }
11920 Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
11921 return Err(failure(
11922 NodeFailureCode::BrowserStationProfileBusy,
11923 "session already holds a dig2browser station profile lease",
11924 ));
11925 }
11926 }
11927 } else {
11928 None
11929 };
11930 #[cfg(not(feature = "dig2-station-probe"))]
11931 let _ = browser_profile_id;
11932 let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
11933 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != provider
11942 {
11943 return Err(failure(
11944 NodeFailureCode::BindingMismatch,
11945 "harness MCP reservation does not match the exact PTY/ACP provider",
11946 ));
11947 }
11948 prepared.verify_helper_program().map_err(harness_mcp_failure)?;
11949 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
11950 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
11951 })?;
11952 let launch = prepared.launch();
11957 let mut env_entries = vec![
11958 (
11959 OsString::from(launch.endpoint_env.as_str()),
11960 prepared.endpoint().as_os_str().to_os_string(),
11961 ),
11962 (
11963 OsString::from(launch.token_env.as_str()),
11964 prepared.token().expose().into(),
11965 ),
11966 (
11967 OsString::from(launch.program_env.as_str()),
11968 prepared.helper_program().as_os_str().to_os_string(),
11969 ),
11970 ];
11971 if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
11972 env_entries.push(trace_entry);
11973 }
11974 let overlay = NativeMcpServerLaunchOverlay::new(
11975 provider.clone(),
11976 launch.server_name.as_str(),
11977 prepared.helper_program().as_os_str().to_os_string(),
11978 launch.args.iter().map(OsString::from).collect(),
11979 env_entries,
11980 launch.scrub_env.iter().map(OsString::from).collect(),
11981 ).map_err(|_| failure(
11982 NodeFailureCode::HarnessMcpUnavailable,
11983 "harness MCP launch overlay is unavailable",
11984 ))?;
11985 control
11986 .install_native_mcp_server_launch_overlay(instance_id, overlay)
11987 .map_err(|_| failure(
11988 NodeFailureCode::HarnessMcpUnavailable,
11989 "harness MCP launch overlay could not be installed",
11990 ))?;
11991 tracing::info!(
11997 node_id = %self.node_id,
11998 instance_id = ?instance_id,
11999 provider = %provider,
12000 mode = ?mode,
12001 "harness MCP overlay installed for spawn",
12002 );
12003 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
12004 } else {
12005 None
12006 };
12007 let prepared_materialization = self.prepare_session_materialization(
12008 &address,
12009 &provider,
12010 mode,
12011 environment_profile.as_ref(),
12012 bundle.as_ref(),
12013 context.as_ref(),
12014 managed_authority.clone(),
12015 )?;
12016 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
12017 Some((overlay, guard)) => (Some(overlay), Some(guard)),
12018 None => (None, None),
12019 };
12020 let materialization_id = materialization_guard
12021 .as_ref()
12022 .and_then(SessionMaterializationGuard::id)
12023 .cloned();
12024 let mut environment_selection = self.select_environment_profile(
12025 instance_id,
12026 &provider,
12027 mode,
12028 environment_profile.as_ref(),
12029 )?;
12030 let provider_home = materialization_guard
12033 .as_ref()
12034 .and_then(SessionMaterializationGuard::provider_home);
12035 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
12036 &provider,
12037 mode,
12038 approval_level,
12039 network_allowlist.as_ref(),
12040 provider_home,
12041 )?;
12042 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
12043 (prepared, args) if args.is_empty() => prepared,
12044 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
12045 overlay.append_extra_args(args).map_err(|_| {
12046 failure(
12047 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
12048 "network allowlist overlay could not merge with instance launch args",
12049 )
12050 })?;
12051 Some(PreparedNativeLaunchOverlay::Instance(overlay))
12052 }
12053 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
12054 Some(PreparedNativeLaunchOverlay::Instance(
12055 overlay
12056 .into_instance_with_extra_args(args)
12057 .map_err(|_| {
12058 failure(
12059 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
12060 "network allowlist overlay could not merge with environment overlay",
12061 )
12062 })?,
12063 ))
12064 }
12065 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
12066 NativeInstanceLaunchOverlay::new(
12067 provider.clone(),
12068 match mode {
12069 SessionMode::Pty => TransportKind::Pty,
12070 SessionMode::Acp => TransportKind::Acp,
12071 SessionMode::Inline => TransportKind::Pipe,
12072 },
12073 Vec::new(),
12074 args,
12075 )
12076 .map_err(|_| {
12077 failure(
12078 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
12079 "network allowlist overlay is invalid for this transport",
12080 )
12081 })?,
12082 )),
12083 };
12084 let mut instance_overlay = environment_overlay
12085 .map(|overlay| self.install_prepared_launch_overlay(instance_id, overlay))
12086 .transpose()?
12087 .flatten();
12088 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12089 let record_id = self.bind_spawn_session_with_materialization(
12090 &address,
12091 provider.clone(),
12092 mode,
12093 runtime_policy,
12094 record_policy,
12095 environment_profile,
12096 bundle,
12097 context,
12098 materialization_id,
12099 )?;
12100 if let Some(selection) = environment_selection.take() {
12101 selection.retain();
12102 }
12103 if let Some(overlay) = instance_overlay.take() {
12104 overlay.retain();
12105 }
12106 if let Some(guard) = materialization_guard.take() {
12107 guard.retain();
12108 }
12109 let agent_id = provider;
12113 let register_command_id = match self.dispatch_bounded(
12114 ControlCommand::Register {
12115 instance_id,
12116 agent_id,
12117 transport,
12118 },
12119 dispatch_timeout,
12120 )
12121 .await {
12122 Ok(command_id) => command_id,
12123 Err(error) => {
12124 if let Some(binding) = self.remove_binding(&address) {
12125 self.cleanup_session_owned_materialization(&address, &binding)?;
12126 }
12127 if let Some(record_id) = record_id.as_ref() {
12128 self.discard_record(record_id)?;
12129 }
12130 return Err(spawn_dispatch_error(error, deadline));
12131 }
12132 };
12133 let start_timeout = match spawn_dispatch_timeout(deadline) {
12134 Ok(timeout) => timeout,
12135 Err(error) => {
12136 let recovery = self
12137 .rollback_spawn(
12138 &address,
12139 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12140 )
12141 .await;
12142 return match recovery {
12143 Ok(()) => {
12144 if let Some(record_id) = record_id.as_ref() {
12145 self.discard_record(record_id)?;
12146 }
12147 Err(error)
12148 }
12149 Err(recovery_error) => {
12150 if let Some(record_id) = record_id.as_ref() {
12151 self.mark_record_error(
12152 record_id,
12153 "spawn deadline elapsed and registration recovery failed",
12154 )?;
12155 }
12156 Err(recovery_error)
12157 }
12158 };
12159 }
12160 };
12161 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12162 if let Err(recovery_error) = self
12163 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12164 .await
12165 {
12166 return Err(recovery_error);
12167 }
12168 if let Some(record_id) = record_id.as_ref() {
12169 self.discard_record(record_id)?;
12170 }
12171 return Err(start_error);
12172 }
12173 let start_result = self
12174 .dispatch_bounded(
12175 ControlCommand::Start {
12176 instance_id,
12177 runtime_policy,
12178 request: StartRequest {
12179 working_directory,
12180 terminal_size,
12181 initial_prompt,
12182 session_options: None,
12183 approval_level,
12184 },
12185 },
12186 start_timeout,
12187 )
12188 .await;
12189 let start_command_id = match start_result {
12190 Ok(command_id) => command_id,
12191 Err(start_error) => {
12192 let start_error = spawn_dispatch_error(start_error, deadline);
12193 let recovery = self
12194 .rollback_spawn(
12195 &address,
12196 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12197 )
12198 .await;
12199 return match recovery {
12200 Ok(()) => {
12201 if let Some(record_id) = record_id.as_ref() {
12202 self.discard_record(record_id)?;
12203 }
12204 Err(start_error)
12205 }
12206 Err(recovery_error) => {
12207 let diagnostic = format!(
12208 "start failed and registration recovery failed: {}",
12209 recovery_error.message,
12210 );
12211 if let Some(record_id) = record_id.as_ref() {
12212 self.mark_record_error(record_id, &diagnostic)?;
12213 }
12214 Err(failure(recovery_error.code, &diagnostic))
12215 }
12216 };
12217 }
12218 };
12219
12220 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12221 loop {
12222 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12231 .or_else(|| self.take_rejected_command_reason(start_command_id))
12232 {
12233 self.rollback_spawn(
12234 &address,
12235 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12236 ).await.map_err(|error| {
12237 failure(
12238 error.code,
12239 &format!(
12240 "spawn was rejected ({}) and registration recovery failed: {}",
12241 reason.message, error.message
12242 ),
12243 )
12244 })?;
12245 if let Some(record_id) = record_id.as_ref() {
12246 self.discard_record(record_id)?;
12247 }
12248 let code = if reason.unsupported_transport {
12249 NodeFailureCode::UnsupportedTransport
12250 } else {
12251 NodeFailureCode::BackendOperationFailed
12252 };
12253 return Err(failure(code, &reason.message));
12254 }
12255 if let Some(current) = self
12256 .handle
12257 .snapshot()
12258 .sessions
12259 .iter()
12260 .find(|current| current.instance_id == instance_id)
12261 {
12262 if current.generation != session.generation {
12263 let divergence = failure(
12264 NodeFailureCode::StaleGeneration,
12265 "spawn generation diverged",
12266 );
12267 return match self
12268 .rollback_spawn(
12269 &address,
12270 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12271 )
12272 .await
12273 {
12274 Ok(()) => {
12275 if let Some(record_id) = record_id.as_ref() {
12276 self.discard_record(record_id)?;
12277 }
12278 Err(divergence)
12279 }
12280 Err(recovery_error) => {
12281 if let Some(record_id) = record_id.as_ref() {
12282 self.mark_record_error(
12283 record_id,
12284 "spawn generation diverged and recovery failed",
12285 )?;
12286 }
12287 Err(recovery_error)
12288 }
12289 };
12290 }
12291 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12292 if let Some(overlay) = harness_mcp_overlay.take() {
12293 overlay.retain();
12294 }
12295 #[cfg(feature = "dig2-station-probe")]
12296 if let Some(guard) = browser_station_lease_guard.take() {
12297 guard.retain();
12298 }
12299 return Ok((address, runtime_policy));
12300 }
12301 }
12302 if Instant::now() >= commit_deadline {
12303 self.rollback_spawn(
12304 &address,
12305 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12306 ).await.map_err(|error| {
12307 failure(
12308 error.code,
12309 &format!(
12310 "start did not commit and registration recovery failed: {}",
12311 error.message
12312 ),
12313 )
12314 })?;
12315 if let Some(record_id) = record_id.as_ref() {
12316 self.discard_record(record_id)?;
12317 }
12318 return Err(failure(
12319 if deadline.is_some() {
12320 NodeFailureCode::SpawnDeadlineExceeded
12321 } else {
12322 NodeFailureCode::BackendBusy
12323 },
12324 "start did not commit before the bounded deadline; registration was removed",
12325 ));
12326 }
12327 sleep(Duration::from_millis(2)).await;
12328 }
12329 }
12330
12331 fn effective_spawn_runtime_policy(
12332 &self,
12333 runtime_policy: ProviderRuntimePolicy,
12334 ) -> ProviderRuntimePolicy {
12335 #[cfg(feature = "fixture")]
12336 let runtime_policy = if self.fixture_semantic_hook_policy {
12337 ProviderRuntimePolicy::new(true, true, true, true, false, true)
12338 .expect("monitoring Hook fixture policy is internally valid")
12339 } else {
12340 runtime_policy
12341 };
12342 runtime_policy
12343 }
12344
12345 fn verify_harness_mcp_before_start(
12346 &self,
12347 prepared: Option<&PreparedHarnessMcpSpawn>,
12348 ) -> Result<(), NodeFailure> {
12349 prepared
12350 .map(PreparedHarnessMcpSpawn::verify_helper_program)
12351 .transpose()
12352 .map(|_| ())
12353 .map_err(harness_mcp_failure)
12354 }
12355
12356 async fn rollback_spawn(
12357 &self,
12358 address: &SessionAddress,
12359 commit_timeout: Duration,
12360 ) -> Result<(), NodeFailure> {
12361 self.dispatch_bounded(
12362 ControlCommand::Remove {
12363 instance_id: address.session.instance_id,
12364 },
12365 commit_timeout,
12366 )
12367 .await?;
12368 self.wait_until_removed(address, commit_timeout).await?;
12369 if let Some(binding) = self.remove_binding(address) {
12370 self.cleanup_session_owned_materialization(address, &binding)?;
12371 }
12372 Ok(())
12373 }
12374
12375 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12376 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12377 self.dispatch_bounded(
12378 ControlCommand::Remove {
12379 instance_id: address.session.instance_id,
12380 },
12381 commit_timeout,
12382 )
12383 .await?;
12384 self.wait_until_removed(address, commit_timeout).await?;
12385 let removed = self.remove_binding(address);
12386 if let Some(binding) = removed.as_ref() {
12387 self.cleanup_session_owned_materialization(address, binding)?;
12388 }
12389 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12390 let snapshot = self.managed_worktrees.lock()
12391 .unwrap_or_else(|poisoned| poisoned.into_inner())
12392 .release_session(
12393 &lease_id,
12394 self.incarnation_id,
12395 address.session.instance_id,
12396 address.session.generation,
12397 unix_time_ms(),
12398 );
12399 if self.persist_state().is_err() {
12400 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12401 }
12402 if let Some(snapshot) = snapshot {
12403 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12404 }
12405 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12406 }
12407 Ok(())
12408 }
12409
12410 async fn wait_until_removed(
12456 &self,
12457 address: &SessionAddress,
12458 commit_timeout: Duration,
12459 ) -> Result<(), NodeFailure> {
12460 let deadline = Instant::now() + commit_timeout;
12461 let mut next_dispatch = Instant::now();
12462 loop {
12463 let snapshot = self.handle.snapshot();
12464 let status = snapshot
12465 .sessions
12466 .iter()
12467 .find(|session| session.instance_id == address.session.instance_id)
12468 .map(|session| session.status.clone());
12469 let Some(status) = status else {
12470 return Ok(());
12471 };
12472 let now = Instant::now();
12473 if now >= deadline {
12474 return Err(failure(
12475 NodeFailureCode::BackendBusy,
12476 "remove did not commit before the bounded deadline; session binding was retained",
12477 ));
12478 }
12479 if status.allows_remove() && now >= next_dispatch {
12480 let _ = self.dispatch(ControlCommand::Remove {
12481 instance_id: address.session.instance_id,
12482 });
12483 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
12484 }
12485 sleep(Duration::from_millis(2)).await;
12486 }
12487 }
12488
12489 fn resync(&self, after_sequence: u64) -> NodeResponse {
12490 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12491 let event_sequence = history.last_sequence;
12492 let oldest_available_sequence = history.replay_floor_sequence;
12493 let events = history
12494 .events
12495 .iter()
12496 .filter(|event| event.sequence > after_sequence)
12497 .cloned()
12498 .collect();
12499 drop(history);
12500 NodeResponse::Resync {
12501 event_sequence,
12502 oldest_available_sequence,
12503 snapshot: self.snapshot(),
12504 events,
12505 }
12506 }
12507}
12508
12509fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
12514 match kind {
12515 AgentStreamChunkKindV1::Text { .. } => "text",
12516 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
12517 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
12518 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
12519 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
12520 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
12521 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
12522 }
12523}
12524
12525async fn accept_connections(
12539 endpoint: &str,
12540 call_home: Option<std::net::SocketAddr>,
12541 shared: Arc<NodeShared>,
12542) -> Result<(), NodeServerError> {
12543 let mut connections = JoinSet::new();
12544 let result = match call_home {
12545 None => accept_connections_inner(endpoint, shared, &mut connections).await,
12546 Some(relay) => {
12547 let listener =
12548 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
12549 let dialler = call_home_loop(relay, Arc::clone(&shared));
12550 tokio::pin!(listener);
12551 tokio::pin!(dialler);
12552 tokio::select! {
12553 result = &mut listener => result,
12554 result = &mut dialler => result,
12555 }
12556 }
12557 };
12558 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
12559 while !connections.is_empty() {
12560 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
12561 if remaining.is_zero()
12562 || timeout(remaining, connections.join_next()).await.is_err()
12563 {
12564 break;
12565 }
12566 }
12567 connections.abort_all();
12568 while connections.join_next().await.is_some() {}
12569 result
12570}
12571
12572const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
12576
12577async fn call_home_loop(
12591 relay: std::net::SocketAddr,
12592 shared: Arc<NodeShared>,
12593) -> Result<(), NodeServerError> {
12594 let mut failures = 0_usize;
12595 loop {
12596 if shared.shutdown.load(Ordering::Acquire) {
12597 return Ok(());
12598 }
12599 let permit = tokio::select! {
12600 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12601 permit.map_err(|_| NodeServerError::PreauthClosed)?
12602 }
12603 _ = shared.shutdown_notify.notified() => return Ok(()),
12604 };
12605 match dial_relay_and_serve(relay, Arc::clone(&shared), permit).await {
12606 Ok(()) => {
12607 failures = 0;
12611 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed");
12612 }
12613 Err(error) => {
12614 failures = failures.saturating_add(1);
12615 tracing::warn!(
12616 node_id = %shared.node_id,
12617 %relay,
12618 cause = %error,
12619 attempt = failures,
12620 "call-home connection failed",
12621 );
12622 let backoff = CALL_HOME_BACKOFFS_MS
12623 [failures.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)];
12624 tokio::select! {
12625 _ = sleep(Duration::from_millis(backoff)) => {}
12626 _ = shared.shutdown_notify.notified() => return Ok(()),
12627 }
12628 }
12629 }
12630 }
12631}
12632
12633async fn dial_relay_and_serve(
12635 relay: std::net::SocketAddr,
12636 shared: Arc<NodeShared>,
12637 preauth_permit: OwnedSemaphorePermit,
12638) -> Result<(), NodeServerError> {
12639 let mut stream = tokio::net::TcpStream::connect(relay)
12640 .await
12641 .map_err(NodeServerError::CallHomeTransport)?;
12642 let _ = stream.set_nodelay(true);
12646 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
12647 .await
12648 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12649 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
12650 serve_connection(stream, shared, preauth_permit).await
12651}
12652
12653async fn accept_connections_inner(
12654 endpoint: &str,
12655 shared: Arc<NodeShared>,
12656 connections: &mut JoinSet<Result<(), NodeServerError>>,
12657) -> Result<(), NodeServerError> {
12658 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
12659 loop {
12660 while connections.try_join_next().is_some() {}
12661 if shared.shutdown.load(Ordering::Acquire) {
12662 return Ok(());
12663 }
12664 let preauth_permit = tokio::select! {
12665 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12666 permit.map_err(|_| NodeServerError::PreauthClosed)?
12667 }
12668 _ = shared.shutdown_notify.notified() => return Ok(()),
12669 };
12670 let server = tokio::select! {
12671 result = listener.accept() => result?,
12672 _ = shared.shutdown_notify.notified() => return Ok(()),
12673 };
12674 let connection_shared = Arc::clone(&shared);
12675 connections.spawn(async move {
12676 serve_connection(server, connection_shared, preauth_permit).await
12677 });
12678 if shared.shutdown.load(Ordering::Acquire) {
12679 return Ok(());
12680 }
12681 }
12682}
12683
12684async fn serve_connection<S>(
12692 mut pipe: S,
12693 shared: Arc<NodeShared>,
12694 preauth_permit: OwnedSemaphorePermit,
12695) -> Result<(), NodeServerError>
12696where
12697 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
12698{
12699 let hello_frame = timeout(
12700 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
12701 read_json_frame_limited_body_timeout(
12702 &mut pipe,
12703 MAX_NODE_HELLO_FRAME_BYTES,
12704 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12705 ),
12706 )
12707 .await
12708 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
12709 let ClientFrame::Hello(hello) = hello_frame else {
12710 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
12711 };
12712 if hello.build_stamp != BUILD_STAMP {
12713 return Err(NodeServerError::Handshake(format!(
12714 "build stamp mismatch: local={BUILD_STAMP} remote={}",
12715 hello.build_stamp,
12716 )));
12717 }
12718 let compatibility = match hello.compatibility.as_ref() {
12719 Some(offer) => {
12720 let mut selected = node_compatibility_support(&shared)?
12721 .negotiate(offer)
12722 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12723 project_negotiated_provider_ids(&mut selected);
12724 Some(selected)
12725 }
12726 None => None,
12727 };
12728 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
12729 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
12730 (Some(offer), Some(selected)) => negotiated_auth_proof(
12731 shared.access_token.as_bytes(),
12732 AuthDirection::Server,
12733 hello.role,
12734 &hello.client_nonce,
12735 &server_nonce,
12736 offer,
12737 selected,
12738 ),
12739 (None, None) => auth_proof(
12740 shared.access_token.as_bytes(),
12741 AuthDirection::Server,
12742 hello.role,
12743 &hello.client_nonce,
12744 &server_nonce,
12745 ),
12746 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
12747 }
12748 .map_err(NodeServerError::Authentication)?;
12749 write_json_frame_limited(
12750 &mut pipe,
12751 &ServerFrame::Challenge(ServerChallenge {
12752 build_stamp: BUILD_STAMP.to_owned(),
12753 server_nonce,
12754 server_proof,
12755 compatibility: compatibility.clone(),
12756 }),
12757 MAX_NODE_HELLO_FRAME_BYTES,
12758 )
12759 .await?;
12760 let authentication = timeout(
12761 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
12762 read_json_frame_limited_body_timeout(
12763 &mut pipe,
12764 MAX_NODE_HELLO_FRAME_BYTES,
12765 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12766 ),
12767 )
12768 .await
12769 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
12770 let ClientFrame::Authenticate(authentication) = authentication else {
12771 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
12772 };
12773 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
12774 (Some(offer), Some(selected)) => negotiated_auth_proof(
12775 shared.access_token.as_bytes(),
12776 AuthDirection::Client,
12777 hello.role,
12778 &hello.client_nonce,
12779 &server_nonce,
12780 offer,
12781 selected,
12782 ),
12783 (None, None) => auth_proof(
12784 shared.access_token.as_bytes(),
12785 AuthDirection::Client,
12786 hello.role,
12787 &hello.client_nonce,
12788 &server_nonce,
12789 ),
12790 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
12791 }
12792 .map_err(NodeServerError::Authentication)?;
12793 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
12794 return Err(NodeServerError::Handshake("access denied".to_owned()));
12795 }
12796 let selected_capabilities = compatibility
12797 .as_ref()
12798 .map(|selected| selected.capabilities.clone())
12799 .unwrap_or_default();
12800 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
12801 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
12802 });
12803 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
12804 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
12805 });
12806 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
12807 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
12808 });
12809 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
12810 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
12811 });
12812 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
12813 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
12814 });
12815 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
12816 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
12817 }) && selected_capabilities.iter().any(|capability| {
12818 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
12819 });
12820 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
12821 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
12822 });
12823 let include_session_bundles = selected_capabilities.iter().any(|capability| {
12824 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
12825 });
12826 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
12827 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
12828 });
12829 let include_agent_progress = selected_capabilities.iter().any(|capability| {
12830 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
12831 });
12832 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
12833 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
12834 });
12835 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
12836 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
12837 });
12838 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
12839 .try_acquire_owned()
12840 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
12841 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
12842 let _release_guard = ControllerReleaseGuard {
12843 shared: Arc::clone(&shared),
12844 connection_id,
12845 };
12846 drop(preauth_permit);
12847 let _authenticated_permit = authenticated_permit;
12848 let mut event_rx = shared.event_tx.subscribe();
12849 let mut terminal_event_rx = terminal_event_subscription(
12850 &shared,
12851 include_terminal_frame_events,
12852 );
12853 let mut agent_stream_event_rx = agent_stream_event_subscription(
12854 &shared,
12855 include_agent_stream_events,
12856 );
12857 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
12858 &shared,
12859 include_harness_mcp_proxy,
12860 );
12861 write_json_frame(
12862 &mut pipe,
12863 &ServerFrame::Hello(NodeHello {
12864 build_stamp: BUILD_STAMP.to_owned(),
12865 incarnation_id: shared.incarnation_id,
12866 connection_id,
12867 role: hello.role,
12868 event_sequence: shared.current_sequence(),
12869 controller: shared.controller_state(),
12870 snapshot: snapshot_for_wire(
12871 &shared,
12872 include_provider_runtime_status,
12873 include_open_provider_ids,
12874 include_spawn_profiles,
12875 include_managed_worktrees,
12876 include_child_environment_profiles,
12877 include_session_bundles,
12878 include_history_context_packs,
12879 include_agent_progress,
12880 include_session_task_correlation,
12881 ),
12882 compatibility,
12883 }),
12884 )
12885 .await?;
12886 let (mut reader, mut writer) = tokio::io::split(pipe);
12887 let (frame_tx, mut frame_rx) = mpsc::channel(16);
12888 let reader_task = tokio::spawn(async move {
12889 loop {
12890 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
12891 &mut reader,
12892 MAX_NODE_CLIENT_FRAME_BYTES,
12893 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12894 )
12895 .await;
12896 let terminal = frame.is_err();
12897 if frame_tx.send(frame).await.is_err() || terminal {
12898 break;
12899 }
12900 }
12901 });
12902 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
12903 let mut pending_events = Vec::new();
12904 let mut resync_required = false;
12905 let mut discard_events_through = 0_u64;
12906 loop {
12907 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
12912 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
12913 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
12914 for _ in 0..durable_drain_budget {
12915 match event_rx.try_recv() {
12916 Ok(event) => queue_connection_event(
12917 &mut pending_events,
12918 event,
12919 discard_events_through,
12920 ),
12921 Err(broadcast::error::TryRecvError::Empty) => break,
12922 Err(broadcast::error::TryRecvError::Lagged(_)) => {
12923 resync_required = true;
12924 break;
12925 }
12926 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
12927 }
12928 }
12929 if let Some(receiver) = terminal_event_rx.as_mut() {
12930 match receiver.try_recv() {
12931 Ok(events) => queue_connection_event_batch(
12932 &mut pending_events,
12933 &events,
12934 discard_events_through,
12935 ),
12936 Err(broadcast::error::TryRecvError::Empty) => {}
12937 Err(broadcast::error::TryRecvError::Lagged(_)) => {
12938 resync_required = true;
12939 }
12940 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
12941 }
12942 }
12943 if let Some(receiver) = agent_stream_event_rx.as_mut() {
12944 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
12954 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
12955 for _ in 0..agent_stream_drain_budget {
12956 match receiver.try_recv() {
12957 Ok(events) => queue_connection_event_batch(
12958 &mut pending_events,
12959 &events,
12960 discard_events_through,
12961 ),
12962 Err(broadcast::error::TryRecvError::Empty) => break,
12963 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
12964 tracing::warn!(
12965 connection_id,
12966 dropped,
12967 "agent stream event subscription lagged; forcing a resync",
12968 );
12969 resync_required = true;
12970 break;
12971 }
12972 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
12973 }
12974 }
12975 }
12976 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
12977 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
12983 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
12984 for _ in 0..harness_mcp_proxy_drain_budget {
12985 match receiver.try_recv() {
12986 Ok(events) => queue_connection_event_batch(
12987 &mut pending_events,
12988 &events,
12989 discard_events_through,
12990 ),
12991 Err(broadcast::error::TryRecvError::Empty) => break,
12992 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
12993 tracing::warn!(
12994 connection_id,
12995 dropped,
12996 "harness MCP proxy event subscription lagged; forcing a resync",
12997 );
12998 resync_required = true;
12999 break;
13000 }
13001 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13002 }
13003 }
13004 }
13005 if resync_required {
13006 let event = resync_required_event(&shared);
13007 apply_connection_resync_watermark(
13008 &mut pending_events,
13009 &mut discard_events_through,
13010 event.sequence,
13011 );
13012 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13013 resync_required = false;
13014 continue;
13015 }
13016 if !pending_events.is_empty() {
13017 pending_events.sort_unstable_by_key(|event| event.sequence);
13018 let burst_len = connection_event_burst_len(pending_events.len());
13019 shared
13020 .connection_events_sent
13021 .fetch_add(burst_len as u64, Ordering::Relaxed);
13022 for event in pending_events.drain(..burst_len) {
13023 let event = if include_managed_worktrees {
13024 Some(event)
13025 } else {
13026 project_event_without_managed_worktrees(event)
13027 };
13028 let event = event.and_then(|event| if include_open_provider_ids {
13029 Some(event)
13030 } else {
13031 project_event_legacy_provider_ids(&shared, event)
13032 });
13033 let event = event.map(|event| if include_child_environment_profiles {
13034 event
13035 } else {
13036 project_event_without_child_environment_profile(event)
13037 });
13038 let event = event.map(|event| if include_session_bundles {
13039 event
13040 } else {
13041 project_event_without_session_bundle(event)
13042 });
13043 let event = event.map(|event| {
13044 project_event_history_for_wire(event, include_history_context_packs)
13045 });
13046 let event = event.map(|event| if include_history_context_packs {
13047 event
13048 } else {
13049 project_event_without_context_pack(event)
13050 });
13051 let event = event.map(|event| if include_session_task_correlation {
13052 event
13053 } else {
13054 project_event_without_session_task_binding(event)
13055 });
13056 let exact_harness_controller = hello.role == ClientRole::Operator
13057 && shared.controller_state().is_some_and(|controller| {
13058 controller.connection_id == connection_id
13059 });
13060 let event = event.and_then(|event| {
13061 if include_harness_mcp_proxy && exact_harness_controller {
13062 Some(event)
13063 } else {
13064 project_event_without_harness_mcp_proxy(event)
13065 }
13066 });
13067 if let Some(event) = event {
13068 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13069 }
13070 }
13071 }
13072 tokio::select! {
13073 biased;
13074 frame = frame_rx.recv() => {
13075 let frame = match frame {
13076 Some(frame) => frame,
13077 None => break,
13078 };
13079 let frame = match frame {
13080 Ok(frame) => frame,
13081 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13082 Err(error) => return Err(error.into()),
13083 };
13084 let ClientFrame::Request(request) = frame else {
13085 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13086 };
13087 #[cfg(feature = "fixture")]
13088 let probe_spawn_managed_worktree_v2 = matches!(
13089 &request.request,
13090 NodeRequest::SpawnManagedWorktreeV2 { .. }
13091 );
13092 let requires_open_provider_ids =
13093 request_requires_open_provider_ids(&shared, &request.request);
13094 let requires_child_environment_profile =
13095 request_requires_child_environment_profile(&shared, &request.request);
13096 let requires_session_bundle =
13097 request_requires_session_bundle(&shared, &request.request);
13098 let requires_history_context_pack =
13099 request_requires_history_context_pack(&shared, &request.request);
13100 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13101 ResponseEnvelope {
13102 request_id: request.request_id,
13103 result: Err(failure(
13104 NodeFailureCode::UnsupportedCapability,
13105 "open provider IDs were not negotiated",
13106 )),
13107 }
13108 } else if requires_child_environment_profile
13109 && !include_child_environment_profiles
13110 {
13111 ResponseEnvelope {
13112 request_id: request.request_id,
13113 result: Err(failure(
13114 NodeFailureCode::UnsupportedCapability,
13115 "child environment profiles were not negotiated",
13116 )),
13117 }
13118 } else if requires_session_bundle && !include_session_bundles {
13119 ResponseEnvelope {
13120 request_id: request.request_id,
13121 result: Err(failure(
13122 NodeFailureCode::UnsupportedCapability,
13123 "session bundles were not negotiated",
13124 )),
13125 }
13126 } else if requires_history_context_pack && !include_history_context_packs {
13127 ResponseEnvelope {
13128 request_id: request.request_id,
13129 result: Err(failure(
13130 NodeFailureCode::UnsupportedCapability,
13131 "history context packs were not negotiated",
13132 )),
13133 }
13134 } else if request_uses_unnegotiated_capability(
13135 &request.request,
13136 &selected_capabilities,
13137 )
13138 {
13139 ResponseEnvelope {
13140 request_id: request.request_id,
13141 result: Err(failure(
13142 NodeFailureCode::UnsupportedCapability,
13143 "request capability was not negotiated",
13144 )),
13145 }
13146 } else {
13147 process_request(&shared, connection_id, hello.role, request).await
13148 };
13149 #[cfg(feature = "fixture")]
13150 if probe_spawn_managed_worktree_v2 {
13151 shared
13152 .fixture_spawn_managed_worktree_v2_failure_probe
13153 .record(reply.result.as_ref().err().map(|failure| failure.code));
13154 }
13155 if !include_provider_runtime_status {
13156 clear_response_provider_runtime_status(&mut reply);
13157 }
13158 if !include_managed_worktrees {
13159 project_response_without_managed_worktrees(&mut reply);
13160 }
13161 if !include_open_provider_ids {
13162 project_response_legacy_provider_ids(&shared, &mut reply);
13163 }
13164 if !include_child_environment_profiles {
13165 project_response_without_child_environment_profile(&mut reply);
13166 }
13167 if !include_session_bundles {
13168 project_response_without_session_bundle(&mut reply);
13169 }
13170 project_response_history_for_wire(
13171 &mut reply,
13172 include_history_context_packs,
13173 );
13174 if !include_history_context_packs {
13175 project_response_without_context_pack(&mut reply);
13176 }
13177 if !include_agent_progress {
13178 project_response_without_agent_progress(&mut reply);
13179 }
13180 if !include_session_task_correlation {
13181 project_response_without_session_task_binding(&mut reply);
13182 }
13183 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13184 }
13185 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13186 event = event_rx.recv() => {
13187 match event {
13188 Ok(event) => queue_connection_event(
13189 &mut pending_events,
13190 event,
13191 discard_events_through,
13192 ),
13193 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13194 Err(broadcast::error::RecvError::Closed) => break,
13195 }
13196 }
13197 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13198 match events {
13199 Ok(events) => queue_connection_event_batch(
13200 &mut pending_events,
13201 &events,
13202 discard_events_through,
13203 ),
13204 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13205 Err(broadcast::error::RecvError::Closed) => break,
13206 }
13207 }
13208 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13209 match events {
13210 Ok(events) => queue_connection_event_batch(
13211 &mut pending_events,
13212 &events,
13213 discard_events_through,
13214 ),
13215 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13216 Err(broadcast::error::RecvError::Closed) => break,
13217 }
13218 }
13219 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13220 match events {
13221 Ok(events) => queue_connection_event_batch(
13222 &mut pending_events,
13223 &events,
13224 discard_events_through,
13225 ),
13226 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13227 Err(broadcast::error::RecvError::Closed) => break,
13228 }
13229 }
13230 _ = shared.shutdown_notify.notified() => break,
13231 }
13232 }
13233 Ok(())
13234}
13235
13236async fn receive_terminal_event_batch(
13237 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13238) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13239 match receiver {
13240 Some(receiver) => receiver.recv().await,
13241 None => std::future::pending().await,
13242 }
13243}
13244
13245fn terminal_event_subscription(
13246 shared: &NodeShared,
13247 enabled: bool,
13248) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13249 enabled.then(|| shared.terminal_event_tx.subscribe())
13250}
13251
13252fn agent_stream_event_subscription(
13256 shared: &NodeShared,
13257 enabled: bool,
13258) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13259 enabled.then(|| shared.agent_stream_event_tx.subscribe())
13260}
13261
13262fn harness_mcp_proxy_event_subscription(
13267 shared: &NodeShared,
13268 enabled: bool,
13269) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13270 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13271}
13272
13273fn queue_connection_event(
13274 pending: &mut Vec<NodeEventEnvelope>,
13275 event: NodeEventEnvelope,
13276 discard_through: u64,
13277) {
13278 if event.sequence <= discard_through
13279 && !event.event.requires_harness_mcp_proxy_capability()
13280 {
13281 return;
13282 }
13283 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13284 if let Some(index) = pending.iter().position(|current| {
13285 matches!(
13286 ¤t.event,
13287 NodeEvent::TerminalFrame {
13288 address: current_address,
13289 ..
13290 } if current_address == address
13291 )
13292 }) {
13293 pending.remove(index);
13294 }
13295 }
13296 pending.push(event);
13297}
13298
13299fn project_event_without_harness_mcp_proxy(
13300 envelope: NodeEventEnvelope,
13301) -> Option<NodeEventEnvelope> {
13302 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13303}
13304
13305fn queue_connection_event_batch(
13306 pending: &mut Vec<NodeEventEnvelope>,
13307 events: &[NodeEventEnvelope],
13308 discard_through: u64,
13309) {
13310 for event in events {
13311 queue_connection_event(pending, event.clone(), discard_through);
13312 }
13313}
13314
13315fn apply_connection_resync_watermark(
13316 pending: &mut Vec<NodeEventEnvelope>,
13317 discard_through: &mut u64,
13318 marker_sequence: u64,
13319) {
13320 *discard_through = (*discard_through).max(marker_sequence);
13321 pending.retain(|event| event.sequence > *discard_through);
13322}
13323
13324fn connection_event_burst_len(pending_len: usize) -> usize {
13325 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13326}
13327
13328fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13329 let history = shared
13330 .history
13331 .lock()
13332 .unwrap_or_else(|poisoned| poisoned.into_inner());
13333 let sequence = history.last_sequence;
13334 let oldest_available_sequence = history.replay_floor_sequence;
13335 NodeEventEnvelope {
13336 sequence,
13337 event: NodeEvent::ResyncRequired {
13338 oldest_available_sequence,
13339 },
13340 }
13341}
13342
13343fn node_compatibility_support(
13344 shared: &NodeShared,
13345) -> Result<NodeCompatibilitySupport, NodeServerError> {
13346 let mut support = node_compatibility_support_for_manifest(
13347 &shared.provider_contracts,
13348 &shared.provider_adapter_contracts,
13349 )?;
13350 if shared
13351 .delivery_store
13352 .lock()
13353 .unwrap_or_else(|poisoned| poisoned.into_inner())
13354 .is_some()
13355 {
13356 support.capabilities.push(
13357 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13358 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13359 );
13360 }
13361 if shared.harness_mcp_registry.is_some() {
13362 support.capabilities.push(
13363 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
13364 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13365 );
13366 }
13367 Ok(support)
13368}
13369
13370fn node_compatibility_support_for_manifest(
13371 provider_contracts: &[ProviderContractSupport],
13372 provider_adapter_contracts: &[ProviderAdapterContractSupport],
13373) -> Result<NodeCompatibilitySupport, NodeServerError> {
13374 Ok(NodeCompatibilitySupport {
13375 build_stamp: BUILD_STAMP.to_owned(),
13376 capabilities: baseline_capabilities()?,
13377 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
13378 path_semantics: platform::path_semantics(),
13379 local_transport: platform::local_transport(),
13380 state_schema: StateSchemaSupport {
13381 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
13382 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13383 },
13384 provider_contracts: provider_contracts.to_vec(),
13385 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
13386 })
13387}
13388
13389fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
13390 let capabilities = [
13391 NODE_COMPATIBILITY_METADATA_CAPABILITY,
13392 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
13393 NODE_REPOSITORY_PATH_CAPABILITY,
13394 NODE_WORKSPACE_FILE_READ_CAPABILITY,
13395 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
13396 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
13397 NODE_GIT_READ_CAPABILITY,
13398 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
13399 NODE_PROVIDER_ID_OPEN_CAPABILITY,
13400 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
13401 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
13402 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
13403 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
13404 NODE_ACP_CONTROL_CAPABILITY,
13405 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
13406 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
13407 NODE_WORKTREE_SELECTION_CAPABILITY,
13408 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
13409 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
13410 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
13411 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
13412 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
13413 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
13414 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
13415 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
13416 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
13417 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
13418 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
13419 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
13420 ]
13421 .into_iter()
13422 .map(|capability| {
13423 CapabilityId::new(capability)
13424 .map_err(|error| NodeServerError::Handshake(error.to_string()))
13425 })
13426 .collect::<Result<Vec<_>, _>>()?;
13427 #[cfg(windows)]
13428 let capabilities = {
13429 let mut capabilities = capabilities;
13430 capabilities.push(
13431 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
13432 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13433 );
13434 capabilities
13435 };
13436 Ok(capabilities)
13437}
13438
13439fn request_uses_unnegotiated_capability(
13440 request: &NodeRequest,
13441 selected_capabilities: &[CapabilityId],
13442) -> bool {
13443 let selected = |required: &str| {
13444 selected_capabilities
13445 .iter()
13446 .any(|capability| capability.as_str() == required)
13447 };
13448 request
13449 .required_capability()
13450 .is_some_and(|required| !selected(required))
13451 || (request.requires_worktree_selection_capability()
13452 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
13453 || (request.requires_spawn_spec_defaults_overrides_capability()
13454 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
13455 || (request.requires_spawn_profile_revision_capability()
13456 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
13457 || (request.requires_child_environment_profile_capability()
13458 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
13459 || (request.requires_session_bundle_materialization_capability()
13460 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
13461 || (request.requires_history_context_pack_capability()
13462 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
13463}
13464
13465fn snapshot_for_wire(
13466 shared: &NodeShared,
13467 include_provider_runtime_status: bool,
13468 include_open_provider_ids: bool,
13469 include_spawn_profiles: bool,
13470 include_managed_worktrees: bool,
13471 include_child_environment_profiles: bool,
13472 include_session_bundles: bool,
13473 include_history_context_packs: bool,
13474 include_agent_progress: bool,
13475 include_session_task_correlation: bool,
13476) -> NodeSnapshot {
13477 let mut snapshot = shared.snapshot();
13478 if !include_provider_runtime_status {
13479 snapshot.provider_runtime_statuses.clear();
13480 }
13481 if !include_open_provider_ids {
13482 project_snapshot_legacy_provider_ids(&mut snapshot);
13483 }
13484 if !include_managed_worktrees {
13485 snapshot.managed_worktrees.clear();
13486 for workspace in &mut snapshot.workspaces {
13487 workspace.managed_worktree_profiles = None;
13488 }
13489 }
13490 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
13491 if !include_spawn_profiles {
13492 inventory.spawn_profiles = None;
13493 }
13494 if !include_session_bundles {
13495 inventory.bundles = None;
13496 }
13497 if inventory.spawn_profiles.is_none()
13498 && inventory.bundles.is_none()
13499 && inventory.network_allowlists.is_none()
13500 {
13501 snapshot.launch_inventory = None;
13502 }
13503 }
13504 if !include_child_environment_profiles {
13505 clear_snapshot_child_environment_profiles(&mut snapshot);
13506 }
13507 if !include_session_bundles {
13508 clear_snapshot_session_bundles(&mut snapshot);
13509 }
13510 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
13511 if !include_history_context_packs {
13512 clear_snapshot_context_packs(&mut snapshot);
13513 }
13514 if !include_agent_progress {
13515 snapshot.agent_progress.clear();
13516 }
13517 if !include_session_task_correlation {
13518 clear_snapshot_session_task_bindings(&mut snapshot);
13519 }
13520 snapshot
13521}
13522
13523fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
13524 for record in &mut snapshot.session_records {
13525 record.task_binding = None;
13526 }
13527}
13528
13529fn project_event_without_session_task_binding(
13530 mut envelope: NodeEventEnvelope,
13531) -> NodeEventEnvelope {
13532 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
13533 record.task_binding = None;
13534 }
13535 envelope
13536}
13537
13538fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
13539 let Ok(response) = reply.result.as_mut() else { return };
13540 match response {
13541 NodeResponse::Snapshot { snapshot, .. } => {
13542 clear_snapshot_session_task_bindings(snapshot);
13543 }
13544 NodeResponse::Resync { snapshot, events, .. } => {
13545 clear_snapshot_session_task_bindings(snapshot);
13546 for event in events {
13547 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
13548 record.task_binding = None;
13549 }
13550 }
13551 }
13552 NodeResponse::SessionRecordUpdated { record }
13553 | NodeResponse::ProviderSessionIndexed { record }
13554 | NodeResponse::NativeSessionIndexed { record, .. }
13555 | NodeResponse::SessionRecordResumed { record, .. } => {
13556 record.task_binding = None;
13557 }
13558 _ => {}
13559 }
13560}
13561
13562fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
13563 let Ok(response) = reply.result.as_mut() else { return };
13564 match response {
13565 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
13566 snapshot.agent_progress.clear();
13567 }
13568 _ => {}
13569 }
13570}
13571
13572fn project_event_without_managed_worktrees(
13573 mut envelope: NodeEventEnvelope,
13574) -> Option<NodeEventEnvelope> {
13575 if matches!(
13576 envelope.event,
13577 NodeEvent::ManagedWorktreeUpserted { .. }
13578 | NodeEvent::ManagedWorktreeRemoved { .. }
13579 ) {
13580 return None;
13581 }
13582 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
13583 workspace.managed_worktree_profiles = None;
13584 }
13585 Some(envelope)
13586}
13587
13588fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
13589 if matches!(
13590 reply.result,
13591 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
13592 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
13593 ) {
13594 reply.result = Err(failure(
13595 NodeFailureCode::UnsupportedCapability,
13596 "managed worktree capability was not negotiated",
13597 ));
13598 return;
13599 }
13600 let Ok(response) = reply.result.as_mut() else { return };
13601 match response {
13602 NodeResponse::Snapshot { snapshot, .. } => {
13603 snapshot.managed_worktrees.clear();
13604 for workspace in &mut snapshot.workspaces {
13605 workspace.managed_worktree_profiles = None;
13606 }
13607 }
13608 NodeResponse::Resync { snapshot, events, .. } => {
13609 snapshot.managed_worktrees.clear();
13610 for workspace in &mut snapshot.workspaces {
13611 workspace.managed_worktree_profiles = None;
13612 }
13613 *events = std::mem::take(events)
13614 .into_iter()
13615 .filter_map(project_event_without_managed_worktrees)
13616 .collect();
13617 }
13618 NodeResponse::WorkspaceRegistered { workspace }
13619 | NodeResponse::StandaloneWorkspaceCreated { workspace }
13620 | NodeResponse::WorktreeCreated { workspace, .. } => {
13621 workspace.managed_worktree_profiles = None;
13622 }
13623 NodeResponse::WorkspaceInspected { inspection } => {
13624 inspection.git.managed_worktree = None;
13625 }
13626 _ => {}
13627 }
13628}
13629
13630fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
13631 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
13632 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13633 });
13634 if includes_open_provider_ids {
13635 return;
13636 }
13637 compatibility
13638 .provider_contracts
13639 .retain(|contract| provider_id_is_legacy(&contract.provider));
13640 compatibility
13641 .provider_adapter_contracts
13642 .retain(|contract| provider_id_is_legacy(&contract.provider));
13643}
13644
13645fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
13646 if let Some(spec) = match request {
13647 NodeRequest::SpawnSpec { spec }
13648 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13649 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13650 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13651 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13652 _ => None,
13653 } {
13654 return shared
13655 .resolve_spawn_spec(spec)
13656 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
13657 }
13658 request_requires_open_provider_ids_with(
13659 request,
13660 |session| shared.validate_address(session).ok(),
13661 |record_id| shared.record(record_id).ok().map(|record| record.provider),
13662 )
13663}
13664
13665fn request_requires_child_environment_profile(
13666 shared: &NodeShared,
13667 request: &NodeRequest,
13668) -> bool {
13669 if let Some(spec) = match request {
13670 NodeRequest::SpawnSpec { spec }
13671 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13672 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13673 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13674 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13675 _ => None,
13676 } {
13677 return shared
13678 .resolve_spawn_spec(spec)
13679 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
13680 }
13681 match request {
13682 NodeRequest::Resume { session, .. }
13683 | NodeRequest::Prompt { session, .. }
13684 | NodeRequest::Paste { session, .. }
13685 | NodeRequest::Input { session, .. }
13686 | NodeRequest::TerminalBytes { session, .. }
13687 | NodeRequest::TerminalControl { session, .. }
13688 | NodeRequest::Resize { session, .. }
13689 | NodeRequest::Interrupt { session }
13690 | NodeRequest::Stop { session, .. }
13691 | NodeRequest::Remove { session }
13692 | NodeRequest::ResolveInteraction { session, .. }
13693 | NodeRequest::SetSessionMode { session, .. }
13694 | NodeRequest::SetSessionConfigOption { session, .. }
13695 | NodeRequest::SetSessionModel { session, .. }
13696 | NodeRequest::DiscoverHistory { session, .. }
13697 | NodeRequest::LoadHistory { session, .. }
13698 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13699 | NodeRequest::ExportContextPack { session } => shared
13700 .session_bindings
13701 .lock()
13702 .unwrap_or_else(|poisoned| poisoned.into_inner())
13703 .get(&session.session.instance_id)
13704 .filter(|binding| {
13705 binding.workspace_id == session.workspace_id
13706 && binding.generation == session.session.generation
13707 })
13708 .map_or(true, |binding| binding.environment_profile.is_some()),
13709 NodeRequest::RenameSessionRecord { record_id, .. }
13710 | NodeRequest::SetSessionTask { record_id, .. }
13711 | NodeRequest::ResumeSessionRecord { record_id, .. }
13712 | NodeRequest::ForgetSessionRecord { record_id } => shared
13713 .record(record_id)
13714 .map_or(true, |record| record.environment_profile.is_some()),
13715 NodeRequest::ArmHarnessMcpReservation { .. }
13716 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13717 | NodeRequest::ActivateHarnessMcpReservation { .. }
13718 | NodeRequest::AbortHarnessMcpReservation { .. }
13719 | NodeRequest::PutHarnessMcpReplyChunk { .. }
13720 | NodeRequest::RejectHarnessMcpCall { .. }
13721 | NodeRequest::Snapshot
13722 | NodeRequest::Resync { .. }
13723 | NodeRequest::BeginDeliveryStage { .. }
13724 | NodeRequest::PutDeliveryBlobChunk { .. }
13725 | NodeRequest::CommitDeliveryStage { .. }
13726 | NodeRequest::AbortDeliveryStage { .. }
13727 | NodeRequest::BrowseHostDirectories { .. }
13728 | NodeRequest::InspectWorkspace { .. }
13729 | NodeRequest::ReadWorkspaceFile { .. }
13730 | NodeRequest::WriteWorkspaceFile { .. }
13731 | NodeRequest::CreateWorkspaceFile { .. }
13732 | NodeRequest::CreateWorkspaceDirectory { .. }
13733 | NodeRequest::ReadGitHistory { .. }
13734 | NodeRequest::ReadGitDiff { .. }
13735 | NodeRequest::AcquireController { .. }
13736 | NodeRequest::ReleaseController
13737 | NodeRequest::Spawn { .. }
13738 | NodeRequest::SpawnSpec { .. }
13739 | NodeRequest::RegisterWorkspace { .. }
13740 | NodeRequest::CreateStandaloneWorkspace { .. }
13741 | NodeRequest::UnregisterWorkspace { .. }
13742 | NodeRequest::CreateWorktree { .. }
13743 | NodeRequest::RemoveWorktree { .. }
13744 | NodeRequest::SpawnManagedWorktree { .. }
13745 | NodeRequest::SpawnManagedWorktreeV2 { .. }
13746 | NodeRequest::CleanupManagedWorktree { .. }
13747 | NodeRequest::IndexProviderSession { .. }
13748 | NodeRequest::IndexNativeSession { .. }
13749 | NodeRequest::CatalogNativeSessions { .. }
13750 | NodeRequest::PageNativeSessions { .. }
13751 | NodeRequest::PreviewNativeSession { .. }
13752 | NodeRequest::PreviewSessionRecord { .. }
13753 | NodeRequest::ForgetContextPack { .. }
13754 | NodeRequest::ResolveDurableContextPack { .. }
13755 | NodeRequest::ReadContextPack { .. }
13756 | NodeRequest::Shutdown => false,
13757 }
13758}
13759
13760fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
13761 if let Some(spec) = match request {
13762 NodeRequest::SpawnSpec { spec }
13763 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13764 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13765 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13766 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13767 _ => None,
13768 } {
13769 return shared
13770 .resolve_spawn_spec(spec)
13771 .is_ok_and(|resolved| resolved.bundle_id.is_some());
13772 }
13773 match request {
13774 NodeRequest::Resume { session, .. }
13775 | NodeRequest::Prompt { session, .. }
13776 | NodeRequest::Paste { session, .. }
13777 | NodeRequest::Input { session, .. }
13778 | NodeRequest::TerminalBytes { session, .. }
13779 | NodeRequest::TerminalControl { session, .. }
13780 | NodeRequest::Resize { session, .. }
13781 | NodeRequest::Interrupt { session }
13782 | NodeRequest::Stop { session, .. }
13783 | NodeRequest::Remove { session }
13784 | NodeRequest::ResolveInteraction { session, .. }
13785 | NodeRequest::SetSessionMode { session, .. }
13786 | NodeRequest::SetSessionConfigOption { session, .. }
13787 | NodeRequest::SetSessionModel { session, .. }
13788 | NodeRequest::DiscoverHistory { session, .. }
13789 | NodeRequest::LoadHistory { session, .. }
13790 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13791 | NodeRequest::ExportContextPack { session } => shared
13792 .session_bindings
13793 .lock()
13794 .unwrap_or_else(|poisoned| poisoned.into_inner())
13795 .get(&session.session.instance_id)
13796 .filter(|binding| {
13797 binding.workspace_id == session.workspace_id
13798 && binding.generation == session.session.generation
13799 })
13800 .map_or(true, |binding| binding.bundle.is_some()),
13801 NodeRequest::RenameSessionRecord { record_id, .. }
13802 | NodeRequest::SetSessionTask { record_id, .. }
13803 | NodeRequest::ResumeSessionRecord { record_id, .. }
13804 | NodeRequest::ForgetSessionRecord { record_id } => shared
13805 .record(record_id)
13806 .map_or(true, |record| record.bundle.is_some()),
13807 NodeRequest::ArmHarnessMcpReservation { .. }
13808 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13809 | NodeRequest::ActivateHarnessMcpReservation { .. }
13810 | NodeRequest::AbortHarnessMcpReservation { .. }
13811 | NodeRequest::PutHarnessMcpReplyChunk { .. }
13812 | NodeRequest::RejectHarnessMcpCall { .. }
13813 | NodeRequest::Snapshot
13814 | NodeRequest::Resync { .. }
13815 | NodeRequest::BeginDeliveryStage { .. }
13816 | NodeRequest::PutDeliveryBlobChunk { .. }
13817 | NodeRequest::CommitDeliveryStage { .. }
13818 | NodeRequest::AbortDeliveryStage { .. }
13819 | NodeRequest::BrowseHostDirectories { .. }
13820 | NodeRequest::InspectWorkspace { .. }
13821 | NodeRequest::ReadWorkspaceFile { .. }
13822 | NodeRequest::WriteWorkspaceFile { .. }
13823 | NodeRequest::CreateWorkspaceFile { .. }
13824 | NodeRequest::CreateWorkspaceDirectory { .. }
13825 | NodeRequest::ReadGitHistory { .. }
13826 | NodeRequest::ReadGitDiff { .. }
13827 | NodeRequest::AcquireController { .. }
13828 | NodeRequest::ReleaseController
13829 | NodeRequest::Spawn { .. }
13830 | NodeRequest::SpawnSpec { .. }
13831 | NodeRequest::RegisterWorkspace { .. }
13832 | NodeRequest::CreateStandaloneWorkspace { .. }
13833 | NodeRequest::UnregisterWorkspace { .. }
13834 | NodeRequest::CreateWorktree { .. }
13835 | NodeRequest::RemoveWorktree { .. }
13836 | NodeRequest::SpawnManagedWorktree { .. }
13837 | NodeRequest::SpawnManagedWorktreeV2 { .. }
13838 | NodeRequest::CleanupManagedWorktree { .. }
13839 | NodeRequest::IndexProviderSession { .. }
13840 | NodeRequest::IndexNativeSession { .. }
13841 | NodeRequest::CatalogNativeSessions { .. }
13842 | NodeRequest::PageNativeSessions { .. }
13843 | NodeRequest::PreviewNativeSession { .. }
13844 | NodeRequest::PreviewSessionRecord { .. }
13845 | NodeRequest::ForgetContextPack { .. }
13846 | NodeRequest::ResolveDurableContextPack { .. }
13847 | NodeRequest::ReadContextPack { .. }
13848 | NodeRequest::Shutdown => false,
13849 }
13850}
13851
13852fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
13853 if request.requires_history_context_pack_capability() {
13854 return true;
13855 }
13856 if let Some(spec) = match request {
13857 NodeRequest::SpawnSpec { spec }
13858 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13859 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13860 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13861 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13862 _ => None,
13863 } {
13864 return shared
13865 .resolve_spawn_spec(spec)
13866 .map_or(true, |resolved| resolved.context_id.is_some());
13867 }
13868 match request {
13869 NodeRequest::Resume { session, .. }
13870 | NodeRequest::Prompt { session, .. }
13871 | NodeRequest::Paste { session, .. }
13872 | NodeRequest::Input { session, .. }
13873 | NodeRequest::TerminalBytes { session, .. }
13874 | NodeRequest::TerminalControl { session, .. }
13875 | NodeRequest::Resize { session, .. }
13876 | NodeRequest::Interrupt { session }
13877 | NodeRequest::Stop { session, .. }
13878 | NodeRequest::Remove { session }
13879 | NodeRequest::ResolveInteraction { session, .. }
13880 | NodeRequest::SetSessionMode { session, .. }
13881 | NodeRequest::SetSessionConfigOption { session, .. }
13882 | NodeRequest::SetSessionModel { session, .. } => shared
13883 .session_bindings
13884 .lock()
13885 .unwrap_or_else(|poisoned| poisoned.into_inner())
13886 .get(&session.session.instance_id)
13887 .filter(|binding| {
13888 binding.workspace_id == session.workspace_id
13889 && binding.generation == session.session.generation
13890 })
13891 .map_or(true, |binding| binding.context.is_some()),
13892 NodeRequest::RenameSessionRecord { record_id, .. }
13893 | NodeRequest::SetSessionTask { record_id, .. }
13894 | NodeRequest::ResumeSessionRecord { record_id, .. }
13895 | NodeRequest::ForgetSessionRecord { record_id } => shared
13896 .record(record_id)
13897 .map_or(true, |record| record.context.is_some()),
13898 NodeRequest::ArmHarnessMcpReservation { .. }
13899 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13900 | NodeRequest::ActivateHarnessMcpReservation { .. }
13901 | NodeRequest::AbortHarnessMcpReservation { .. }
13902 | NodeRequest::PutHarnessMcpReplyChunk { .. }
13903 | NodeRequest::RejectHarnessMcpCall { .. }
13904 | NodeRequest::Snapshot
13905 | NodeRequest::Resync { .. }
13906 | NodeRequest::BeginDeliveryStage { .. }
13907 | NodeRequest::PutDeliveryBlobChunk { .. }
13908 | NodeRequest::CommitDeliveryStage { .. }
13909 | NodeRequest::AbortDeliveryStage { .. }
13910 | NodeRequest::BrowseHostDirectories { .. }
13911 | NodeRequest::InspectWorkspace { .. }
13912 | NodeRequest::ReadWorkspaceFile { .. }
13913 | NodeRequest::WriteWorkspaceFile { .. }
13914 | NodeRequest::CreateWorkspaceFile { .. }
13915 | NodeRequest::CreateWorkspaceDirectory { .. }
13916 | NodeRequest::ReadGitHistory { .. }
13917 | NodeRequest::ReadGitDiff { .. }
13918 | NodeRequest::AcquireController { .. }
13919 | NodeRequest::ReleaseController
13920 | NodeRequest::Spawn { .. }
13921 | NodeRequest::SpawnSpec { .. }
13922 | NodeRequest::RegisterWorkspace { .. }
13923 | NodeRequest::CreateStandaloneWorkspace { .. }
13924 | NodeRequest::UnregisterWorkspace { .. }
13925 | NodeRequest::CreateWorktree { .. }
13926 | NodeRequest::RemoveWorktree { .. }
13927 | NodeRequest::SpawnManagedWorktree { .. }
13928 | NodeRequest::SpawnManagedWorktreeV2 { .. }
13929 | NodeRequest::CleanupManagedWorktree { .. }
13930 | NodeRequest::IndexProviderSession { .. }
13931 | NodeRequest::IndexNativeSession { .. }
13932 | NodeRequest::CatalogNativeSessions { .. }
13933 | NodeRequest::PageNativeSessions { .. }
13934 | NodeRequest::PreviewNativeSession { .. }
13935 | NodeRequest::PreviewSessionRecord { .. }
13936 | NodeRequest::DiscoverHistory { .. }
13937 | NodeRequest::LoadHistory { .. }
13938 | NodeRequest::ExportContextPackForSessionRecord { .. }
13939 | NodeRequest::ExportContextPack { .. }
13940 | NodeRequest::ForgetContextPack { .. }
13941 | NodeRequest::ResolveDurableContextPack { .. }
13942 | NodeRequest::ReadContextPack { .. }
13943 | NodeRequest::Shutdown => false,
13944 }
13945}
13946
13947fn request_requires_open_provider_ids_with(
13948 request: &NodeRequest,
13949 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
13950 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
13951) -> bool {
13952 match request {
13953 NodeRequest::Spawn { provider, .. } => !provider_id_is_legacy(provider),
13954 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
13955 NodeRequest::CatalogNativeSessions { route, .. }
13956 | NodeRequest::PageNativeSessions { route, .. } => {
13957 !provider_id_is_legacy(&route.provider)
13958 }
13959 NodeRequest::PreviewNativeSession { selection, .. }
13960 | NodeRequest::IndexNativeSession { selection, .. } => {
13961 !provider_id_is_legacy(&selection.route.provider)
13962 }
13963 NodeRequest::SpawnSpec { .. }
13964 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
13965 NodeRequest::SpawnManagedWorktree { .. }
13966 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
13967 NodeRequest::Resume { session, .. }
13968 | NodeRequest::Prompt { session, .. }
13969 | NodeRequest::Paste { session, .. }
13970 | NodeRequest::Input { session, .. }
13971 | NodeRequest::TerminalBytes { session, .. }
13972 | NodeRequest::TerminalControl { session, .. }
13973 | NodeRequest::Resize { session, .. }
13974 | NodeRequest::Interrupt { session }
13975 | NodeRequest::Stop { session, .. }
13976 | NodeRequest::Remove { session }
13977 | NodeRequest::ResolveInteraction { session, .. }
13978 | NodeRequest::SetSessionMode { session, .. }
13979 | NodeRequest::SetSessionConfigOption { session, .. }
13980 | NodeRequest::SetSessionModel { session, .. }
13981 | NodeRequest::DiscoverHistory { session, .. }
13982 | NodeRequest::LoadHistory { session, .. }
13983 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13984 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
13985 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
13986 NodeRequest::RenameSessionRecord { record_id, .. }
13987 | NodeRequest::SetSessionTask { record_id, .. }
13988 | NodeRequest::ResumeSessionRecord { record_id, .. }
13989 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
13990 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
13991 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
13992 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
13993 NodeRequest::ArmHarnessMcpReservation { .. }
13994 | NodeRequest::ActivateHarnessMcpReservation { .. }
13995 | NodeRequest::AbortHarnessMcpReservation { .. }
13996 | NodeRequest::PutHarnessMcpReplyChunk { .. }
13997 | NodeRequest::RejectHarnessMcpCall { .. }
13998 | NodeRequest::Snapshot
13999 | NodeRequest::Resync { .. }
14000 | NodeRequest::BeginDeliveryStage { .. }
14001 | NodeRequest::PutDeliveryBlobChunk { .. }
14002 | NodeRequest::CommitDeliveryStage { .. }
14003 | NodeRequest::AbortDeliveryStage { .. }
14004 | NodeRequest::BrowseHostDirectories { .. }
14005 | NodeRequest::InspectWorkspace { .. }
14006 | NodeRequest::ReadWorkspaceFile { .. }
14007 | NodeRequest::WriteWorkspaceFile { .. }
14008 | NodeRequest::CreateWorkspaceFile { .. }
14009 | NodeRequest::CreateWorkspaceDirectory { .. }
14010 | NodeRequest::ReadGitHistory { .. }
14011 | NodeRequest::ReadGitDiff { .. }
14012 | NodeRequest::AcquireController { .. }
14013 | NodeRequest::ReleaseController
14014 | NodeRequest::RegisterWorkspace { .. }
14015 | NodeRequest::CreateStandaloneWorkspace { .. }
14016 | NodeRequest::UnregisterWorkspace { .. }
14017 | NodeRequest::CreateWorktree { .. }
14018 | NodeRequest::RemoveWorktree { .. }
14019 | NodeRequest::CleanupManagedWorktree { .. }
14020 | NodeRequest::Shutdown => false,
14021 NodeRequest::ForgetContextPack { .. } => true,
14022 NodeRequest::ResolveDurableContextPack { .. }
14028 | NodeRequest::ReadContextPack { .. } => true,
14029 }
14030}
14031
14032fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14033 snapshot.enabled_providers.retain(provider_id_is_legacy);
14034 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14035 snapshot
14036 .provider_runtime_statuses
14037 .as_slice()
14038 .iter()
14039 .filter(|status| provider_id_is_legacy(status.provider()))
14040 .cloned(),
14041 )
14042 .expect("a filtered provider runtime projection remains valid");
14043 snapshot
14044 .session_records
14045 .retain(|record| provider_id_is_legacy(&record.provider));
14046 for workspace in &mut snapshot.workspaces {
14047 workspace
14048 .sessions
14049 .retain(|session| provider_id_is_legacy(&session.agent_id));
14050 }
14051}
14052
14053fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14054 for record in &mut snapshot.session_records {
14055 record.environment_profile = None;
14056 }
14057 if let Some(profiles) = snapshot
14058 .launch_inventory
14059 .as_mut()
14060 .and_then(|inventory| inventory.spawn_profiles.as_mut())
14061 {
14062 for profile in profiles {
14063 profile.environment_profile = None;
14064 }
14065 }
14066}
14067
14068fn project_event_without_child_environment_profile(
14069 mut envelope: NodeEventEnvelope,
14070) -> NodeEventEnvelope {
14071 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14072 record.environment_profile = None;
14073 }
14074 envelope
14075}
14076
14077fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14078 let contains_environment_profile = match reply.result.as_ref() {
14079 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14080 receipt.environment_profile.is_some()
14081 }
14082 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14083 receipt.spawn.environment_profile.is_some()
14084 }
14085 _ => false,
14086 };
14087 if contains_environment_profile {
14088 reply.result = Err(failure(
14089 NodeFailureCode::UnsupportedCapability,
14090 "child environment profiles were not negotiated",
14091 ));
14092 return;
14093 }
14094 let Ok(response) = reply.result.as_mut() else {
14095 return;
14096 };
14097 match response {
14098 NodeResponse::Snapshot { snapshot, .. } => {
14099 clear_snapshot_child_environment_profiles(snapshot);
14100 }
14101 NodeResponse::Resync { snapshot, events, .. } => {
14102 clear_snapshot_child_environment_profiles(snapshot);
14103 for event in events {
14104 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14105 record.environment_profile = None;
14106 }
14107 }
14108 }
14109 NodeResponse::SessionRecordUpdated { record }
14110 | NodeResponse::ProviderSessionIndexed { record }
14111 | NodeResponse::NativeSessionIndexed { record, .. }
14112 | NodeResponse::SessionRecordResumed { record, .. } => {
14113 record.environment_profile = None;
14114 }
14115 NodeResponse::Armed { .. }
14116 | NodeResponse::Spawned { .. }
14117 | NodeResponse::Activated { .. }
14118 | NodeResponse::Aborted { .. }
14119 | NodeResponse::ReplyChunkAccepted { .. }
14120 | NodeResponse::CallRejected { .. }
14121 | NodeResponse::DeliveryStageBegun { .. }
14122 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14123 | NodeResponse::DeliveryCommitted { .. }
14124 | NodeResponse::DeliveryStageAborted { .. }
14125 | NodeResponse::WorkspaceInspected { .. }
14126 | NodeResponse::HostDirectoriesBrowsed { .. }
14127 | NodeResponse::WorkspaceFileRead { .. }
14128 | NodeResponse::WorkspaceFileWritten { .. }
14129 | NodeResponse::WorkspaceFileCreated { .. }
14130 | NodeResponse::WorkspaceDirectoryCreated { .. }
14131 | NodeResponse::GitHistoryRead { .. }
14132 | NodeResponse::GitDiffRead { .. }
14133 | NodeResponse::Controller { .. }
14134 | NodeResponse::SpawnAccepted { .. }
14135 | NodeResponse::SpawnSpecAccepted { .. }
14136 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14137 | NodeResponse::ManagedWorktreeCleanup { .. }
14138 | NodeResponse::NativeSessionsCataloged { .. }
14139 | NodeResponse::NativeSessionsPaged { .. }
14140 | NodeResponse::NativeSessionPreviewed { .. }
14141 | NodeResponse::SessionRecordPreviewed { .. }
14142 | NodeResponse::HistoryDiscovered { .. }
14143 | NodeResponse::HistoryLoaded { .. }
14144 | NodeResponse::ContextPackForSessionRecordExported { .. }
14145 | NodeResponse::ContextPackExported { .. }
14146 | NodeResponse::ContextPackForgotten { .. }
14147 | NodeResponse::DurableContextPackResolved { .. }
14148 | NodeResponse::ContextPackBytesRead { .. }
14149 | NodeResponse::SessionRecordForgotten { .. }
14150 | NodeResponse::WorkspaceRegistered { .. }
14151 | NodeResponse::StandaloneWorkspaceCreated { .. }
14152 | NodeResponse::WorkspaceUnregistered { .. }
14153 | NodeResponse::WorktreeCreated { .. }
14154 | NodeResponse::WorktreeRemoved { .. }
14155 | NodeResponse::Accepted
14156 | NodeResponse::ShuttingDown => {}
14157 }
14158}
14159
14160fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14161 for record in &mut snapshot.session_records {
14162 record.bundle = None;
14163 }
14164}
14165
14166fn project_event_without_session_bundle(
14167 mut envelope: NodeEventEnvelope,
14168) -> NodeEventEnvelope {
14169 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14170 record.bundle = None;
14171 }
14172 envelope
14173}
14174
14175fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14176 let contains_bundle = match reply.result.as_ref() {
14177 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14178 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14179 receipt.spawn.bundle.is_some()
14180 }
14181 _ => false,
14182 };
14183 if contains_bundle {
14184 reply.result = Err(failure(
14185 NodeFailureCode::UnsupportedCapability,
14186 "session bundles were not negotiated",
14187 ));
14188 return;
14189 }
14190 let Ok(response) = reply.result.as_mut() else {
14191 return;
14192 };
14193 match response {
14194 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14195 NodeResponse::Resync { snapshot, events, .. } => {
14196 clear_snapshot_session_bundles(snapshot);
14197 for event in events {
14198 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14199 record.bundle = None;
14200 }
14201 }
14202 }
14203 NodeResponse::SessionRecordUpdated { record }
14204 | NodeResponse::ProviderSessionIndexed { record }
14205 | NodeResponse::NativeSessionIndexed { record, .. }
14206 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14207 NodeResponse::Armed { .. }
14208 | NodeResponse::Spawned { .. }
14209 | NodeResponse::Activated { .. }
14210 | NodeResponse::Aborted { .. }
14211 | NodeResponse::ReplyChunkAccepted { .. }
14212 | NodeResponse::CallRejected { .. }
14213 | NodeResponse::DeliveryStageBegun { .. }
14214 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14215 | NodeResponse::DeliveryCommitted { .. }
14216 | NodeResponse::DeliveryStageAborted { .. }
14217 | NodeResponse::WorkspaceInspected { .. }
14218 | NodeResponse::HostDirectoriesBrowsed { .. }
14219 | NodeResponse::WorkspaceFileRead { .. }
14220 | NodeResponse::WorkspaceFileWritten { .. }
14221 | NodeResponse::WorkspaceFileCreated { .. }
14222 | NodeResponse::WorkspaceDirectoryCreated { .. }
14223 | NodeResponse::GitHistoryRead { .. }
14224 | NodeResponse::GitDiffRead { .. }
14225 | NodeResponse::Controller { .. }
14226 | NodeResponse::SpawnAccepted { .. }
14227 | NodeResponse::SpawnSpecAccepted { .. }
14228 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14229 | NodeResponse::ManagedWorktreeCleanup { .. }
14230 | NodeResponse::NativeSessionsCataloged { .. }
14231 | NodeResponse::NativeSessionsPaged { .. }
14232 | NodeResponse::NativeSessionPreviewed { .. }
14233 | NodeResponse::SessionRecordPreviewed { .. }
14234 | NodeResponse::HistoryDiscovered { .. }
14235 | NodeResponse::HistoryLoaded { .. }
14236 | NodeResponse::ContextPackForSessionRecordExported { .. }
14237 | NodeResponse::ContextPackExported { .. }
14238 | NodeResponse::ContextPackForgotten { .. }
14239 | NodeResponse::DurableContextPackResolved { .. }
14240 | NodeResponse::ContextPackBytesRead { .. }
14241 | NodeResponse::SessionRecordForgotten { .. }
14242 | NodeResponse::WorkspaceRegistered { .. }
14243 | NodeResponse::StandaloneWorkspaceCreated { .. }
14244 | NodeResponse::WorkspaceUnregistered { .. }
14245 | NodeResponse::WorktreeCreated { .. }
14246 | NodeResponse::WorktreeRemoved { .. }
14247 | NodeResponse::Accepted
14248 | NodeResponse::ShuttingDown => {}
14249 }
14250}
14251
14252fn project_snapshot_history_for_wire(
14253 snapshot: &mut NodeSnapshot,
14254 include_history_metadata: bool,
14255) {
14256 for workspace in &mut snapshot.workspaces {
14257 for session in &mut workspace.sessions {
14258 if include_history_metadata {
14259 session.history.loaded = None;
14260 } else {
14261 session.history = Default::default();
14262 }
14263 }
14264 }
14265}
14266
14267fn project_event_history_for_wire(
14268 mut envelope: NodeEventEnvelope,
14269 include_history_metadata: bool,
14270) -> NodeEventEnvelope {
14271 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14272 for session in &mut workspace.sessions {
14273 if include_history_metadata {
14274 session.history.loaded = None;
14275 } else {
14276 session.history = Default::default();
14277 }
14278 }
14279 }
14280 envelope
14281}
14282
14283fn project_response_history_for_wire(
14284 reply: &mut ResponseEnvelope,
14285 include_history_metadata: bool,
14286) {
14287 let Ok(response) = reply.result.as_mut() else {
14288 return;
14289 };
14290 match response {
14291 NodeResponse::Snapshot { snapshot, .. } => {
14292 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14293 }
14294 NodeResponse::Resync {
14295 snapshot, events, ..
14296 } => {
14297 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14298 for event in events {
14299 *event = project_event_history_for_wire(
14300 event.clone(),
14301 include_history_metadata,
14302 );
14303 }
14304 }
14305 NodeResponse::WorkspaceRegistered { workspace }
14306 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14307 | NodeResponse::WorktreeCreated { workspace, .. } => {
14308 for session in &mut workspace.sessions {
14309 if include_history_metadata {
14310 session.history.loaded = None;
14311 } else {
14312 session.history = Default::default();
14313 }
14314 }
14315 }
14316 _ => {}
14317 }
14318}
14319
14320fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14321 for record in &mut snapshot.session_records {
14322 record.context_id = None;
14323 record.context = None;
14324 record.exported_context = None;
14325 }
14326}
14327
14328fn project_event_without_context_pack(
14329 mut envelope: NodeEventEnvelope,
14330) -> NodeEventEnvelope {
14331 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14332 record.context_id = None;
14333 record.context = None;
14334 record.exported_context = None;
14335 }
14336 envelope
14337}
14338
14339fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14340 let contains_context = match reply.result.as_ref() {
14341 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14342 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14343 receipt.spawn.context.is_some()
14344 }
14345 Ok(NodeResponse::HistoryDiscovered { .. })
14346 | Ok(NodeResponse::HistoryLoaded { .. })
14347 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14348 | Ok(NodeResponse::ContextPackExported { .. })
14349 | Ok(NodeResponse::ContextPackForgotten { .. })
14350 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14351 _ => false,
14352 };
14353 if contains_context {
14354 reply.result = Err(failure(
14355 NodeFailureCode::UnsupportedCapability,
14356 "history context packs were not negotiated",
14357 ));
14358 return;
14359 }
14360 let Ok(response) = reply.result.as_mut() else {
14361 return;
14362 };
14363 match response {
14364 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
14365 NodeResponse::Resync {
14366 snapshot, events, ..
14367 } => {
14368 clear_snapshot_context_packs(snapshot);
14369 for event in events {
14370 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14371 record.context_id = None;
14372 record.context = None;
14373 record.exported_context = None;
14374 }
14375 }
14376 }
14377 NodeResponse::SessionRecordUpdated { record }
14378 | NodeResponse::ProviderSessionIndexed { record }
14379 | NodeResponse::NativeSessionIndexed { record, .. }
14380 | NodeResponse::SessionRecordResumed { record, .. } => {
14381 record.context_id = None;
14382 record.context = None;
14383 record.exported_context = None;
14384 }
14385 _ => {}
14386 }
14387}
14388
14389fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
14390 let contains_open_record = match reply.result.as_ref() {
14391 Ok(NodeResponse::SessionRecordUpdated { record })
14392 | Ok(NodeResponse::ProviderSessionIndexed { record })
14393 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
14394 !provider_id_is_legacy(&record.provider)
14395 }
14396 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
14397 !provider_id_is_legacy(&selection.route.provider)
14398 || !provider_id_is_legacy(&record.provider)
14399 }
14400 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14401 !provider_id_is_legacy(&receipt.provider)
14402 }
14403 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14404 !provider_id_is_legacy(&receipt.spawn.provider)
14405 }
14406 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
14407 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
14408 !provider_id_is_legacy(&route.provider)
14409 }
14410 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
14411 !provider_id_is_legacy(&selection.route.provider)
14412 }
14413 _ => false,
14414 };
14415 if contains_open_record {
14416 reply.result = Err(failure(
14417 NodeFailureCode::UnsupportedCapability,
14418 "open provider IDs were not negotiated",
14419 ));
14420 return;
14421 }
14422 let Ok(response) = reply.result.as_mut() else {
14423 return;
14424 };
14425 match response {
14426 NodeResponse::Snapshot { snapshot, .. } => {
14427 project_snapshot_legacy_provider_ids(snapshot);
14428 }
14429 NodeResponse::Resync { snapshot, events, .. } => {
14430 project_snapshot_legacy_provider_ids(snapshot);
14431 *events = std::mem::take(events)
14432 .into_iter()
14433 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
14434 .collect();
14435 }
14436 NodeResponse::WorkspaceRegistered { workspace }
14437 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14438 | NodeResponse::WorktreeCreated { workspace, .. } => {
14439 workspace
14440 .sessions
14441 .retain(|session| provider_id_is_legacy(&session.agent_id));
14442 }
14443 NodeResponse::Armed { .. }
14444 | NodeResponse::Spawned { .. }
14445 | NodeResponse::Activated { .. }
14446 | NodeResponse::Aborted { .. }
14447 | NodeResponse::ReplyChunkAccepted { .. }
14448 | NodeResponse::CallRejected { .. }
14449 | NodeResponse::DeliveryStageBegun { .. }
14450 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14451 | NodeResponse::DeliveryCommitted { .. }
14452 | NodeResponse::DeliveryStageAborted { .. }
14453 | NodeResponse::WorkspaceInspected { .. }
14454 | NodeResponse::HostDirectoriesBrowsed { .. }
14455 | NodeResponse::WorkspaceFileRead { .. }
14456 | NodeResponse::WorkspaceFileWritten { .. }
14457 | NodeResponse::WorkspaceFileCreated { .. }
14458 | NodeResponse::WorkspaceDirectoryCreated { .. }
14459 | NodeResponse::GitHistoryRead { .. }
14460 | NodeResponse::GitDiffRead { .. }
14461 | NodeResponse::Controller { .. }
14462 | NodeResponse::SpawnAccepted { .. }
14463 | NodeResponse::SpawnSpecAccepted { .. }
14464 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14465 | NodeResponse::ManagedWorktreeCleanup { .. }
14466 | NodeResponse::NativeSessionsCataloged { .. }
14467 | NodeResponse::NativeSessionsPaged { .. }
14468 | NodeResponse::NativeSessionPreviewed { .. }
14469 | NodeResponse::SessionRecordPreviewed { .. }
14470 | NodeResponse::HistoryDiscovered { .. }
14471 | NodeResponse::HistoryLoaded { .. }
14472 | NodeResponse::ContextPackForSessionRecordExported { .. }
14473 | NodeResponse::ContextPackExported { .. }
14474 | NodeResponse::ContextPackForgotten { .. }
14475 | NodeResponse::DurableContextPackResolved { .. }
14476 | NodeResponse::ContextPackBytesRead { .. }
14477 | NodeResponse::SessionRecordUpdated { .. }
14478 | NodeResponse::ProviderSessionIndexed { .. }
14479 | NodeResponse::NativeSessionIndexed { .. }
14480 | NodeResponse::SessionRecordResumed { .. }
14481 | NodeResponse::SessionRecordForgotten { .. }
14482 | NodeResponse::WorkspaceUnregistered { .. }
14483 | NodeResponse::WorktreeRemoved { .. }
14484 | NodeResponse::Accepted
14485 | NodeResponse::ShuttingDown => {}
14486 }
14487}
14488
14489fn project_event_legacy_provider_ids(
14490 shared: &NodeShared,
14491 mut envelope: NodeEventEnvelope,
14492) -> Option<NodeEventEnvelope> {
14493 let include = match &mut envelope.event {
14494 NodeEvent::HarnessMcpReadCall { .. } => true,
14495 NodeEvent::Control { address, .. }
14496 | NodeEvent::AgentStream { address, .. }
14497 | NodeEvent::TerminalFrame { address, .. } => shared
14498 .validate_address(address)
14499 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
14500 NodeEvent::WorkspaceAdded { workspace } => {
14501 workspace
14502 .sessions
14503 .retain(|session| provider_id_is_legacy(&session.agent_id));
14504 true
14505 }
14506 NodeEvent::SessionRecordUpserted { record } => {
14507 provider_id_is_legacy(&record.provider)
14508 }
14509 NodeEvent::SessionRecordHistorySummarized { .. } => false,
14510 NodeEvent::SessionRecordRemoved { .. } => shared
14511 .history
14512 .lock()
14513 .unwrap_or_else(|poisoned| poisoned.into_inner())
14514 .removed_record_providers
14515 .get(&envelope.sequence)
14516 .is_some_and(provider_id_is_legacy),
14517 NodeEvent::ControllerChanged { .. }
14518 | NodeEvent::ManagedWorktreeUpserted { .. }
14519 | NodeEvent::ManagedWorktreeRemoved { .. }
14520 | NodeEvent::WorkspaceRemoved { .. }
14521 | NodeEvent::ResyncRequired { .. } => true,
14522 };
14523 include.then_some(envelope)
14524}
14525
14526fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
14527 let Ok(response) = reply.result.as_mut() else {
14528 return;
14529 };
14530 match response {
14531 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14532 snapshot.provider_runtime_statuses.clear();
14533 }
14534 NodeResponse::Armed { .. }
14535 | NodeResponse::Spawned { .. }
14536 | NodeResponse::Activated { .. }
14537 | NodeResponse::Aborted { .. }
14538 | NodeResponse::ReplyChunkAccepted { .. }
14539 | NodeResponse::CallRejected { .. }
14540 | NodeResponse::DeliveryStageBegun { .. }
14541 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14542 | NodeResponse::DeliveryCommitted { .. }
14543 | NodeResponse::DeliveryStageAborted { .. }
14544 | NodeResponse::WorkspaceInspected { .. }
14545 | NodeResponse::HostDirectoriesBrowsed { .. }
14546 | NodeResponse::WorkspaceFileRead { .. }
14547 | NodeResponse::WorkspaceFileWritten { .. }
14548 | NodeResponse::WorkspaceFileCreated { .. }
14549 | NodeResponse::WorkspaceDirectoryCreated { .. }
14550 | NodeResponse::GitHistoryRead { .. }
14551 | NodeResponse::GitDiffRead { .. }
14552 | NodeResponse::Controller { .. }
14553 | NodeResponse::SpawnAccepted { .. }
14554 | NodeResponse::SpawnSpecAccepted { .. }
14555 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14556 | NodeResponse::ManagedWorktreeCleanup { .. }
14557 | NodeResponse::NativeSessionsCataloged { .. }
14558 | NodeResponse::NativeSessionsPaged { .. }
14559 | NodeResponse::NativeSessionPreviewed { .. }
14560 | NodeResponse::SessionRecordPreviewed { .. }
14561 | NodeResponse::HistoryDiscovered { .. }
14562 | NodeResponse::HistoryLoaded { .. }
14563 | NodeResponse::ContextPackForSessionRecordExported { .. }
14564 | NodeResponse::ContextPackExported { .. }
14565 | NodeResponse::ContextPackForgotten { .. }
14566 | NodeResponse::DurableContextPackResolved { .. }
14567 | NodeResponse::ContextPackBytesRead { .. }
14568 | NodeResponse::SessionRecordUpdated { .. }
14569 | NodeResponse::ProviderSessionIndexed { .. }
14570 | NodeResponse::NativeSessionIndexed { .. }
14571 | NodeResponse::SessionRecordResumed { .. }
14572 | NodeResponse::SessionRecordForgotten { .. }
14573 | NodeResponse::WorkspaceRegistered { .. }
14574 | NodeResponse::StandaloneWorkspaceCreated { .. }
14575 | NodeResponse::WorkspaceUnregistered { .. }
14576 | NodeResponse::WorktreeCreated { .. }
14577 | NodeResponse::WorktreeRemoved { .. }
14578 | NodeResponse::Accepted
14579 | NodeResponse::ShuttingDown => {}
14580 }
14581}
14582
14583struct ControllerReleaseGuard {
14584 shared: Arc<NodeShared>,
14585 connection_id: u64,
14586}
14587
14588impl Drop for ControllerReleaseGuard {
14589 fn drop(&mut self) {
14590 self.shared.release_controller(self.connection_id);
14591 }
14592}
14593
14594struct AbortTaskOnDrop(AbortHandle);
14595
14596impl Drop for AbortTaskOnDrop {
14597 fn drop(&mut self) {
14598 self.0.abort();
14599 }
14600}
14601
14602fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
14607 std::env::var(name)
14608 .ok()
14609 .and_then(|value| value.parse::<u64>().ok())
14610 .filter(|value| *value > 0)
14611 .unwrap_or(default)
14612 .min(max)
14613}
14614
14615fn workspace_inspection_time_budget_ms() -> u64 {
14616 env_config_bounded(
14617 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
14618 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
14619 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
14620 )
14621}
14622
14623fn workspace_inspection_entry_cap() -> usize {
14624 env_config_bounded(
14625 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
14626 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
14627 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
14628 ) as usize
14629}
14630
14631struct WorkspaceWalkBudget {
14637 deadline: Instant,
14638 entry_cap: usize,
14639 entries_visited: u64,
14640 time_budget_exceeded: bool,
14641 entry_cap_exceeded: bool,
14642}
14643
14644impl WorkspaceWalkBudget {
14645 fn new(deadline: Instant, entry_cap: usize) -> Self {
14646 Self {
14647 deadline,
14648 entry_cap,
14649 entries_visited: 0,
14650 time_budget_exceeded: false,
14651 entry_cap_exceeded: false,
14652 }
14653 }
14654
14655 fn is_exhausted(&self) -> bool {
14656 self.time_budget_exceeded || self.entry_cap_exceeded
14657 }
14658
14659 fn record_visit(&mut self) -> bool {
14662 self.entries_visited += 1;
14663 if self.entries_visited as usize >= self.entry_cap {
14664 self.entry_cap_exceeded = true;
14665 }
14666 if Instant::now() >= self.deadline {
14667 self.time_budget_exceeded = true;
14668 }
14669 self.is_exhausted()
14670 }
14671}
14672
14673fn collect_workspace_entries(
14674 root: &Path,
14675 deadline: Instant,
14676 entry_cap: usize,
14677) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
14678 let mut entries = Vec::new();
14679 let mut truncated = false;
14680 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
14681 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
14682 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
14686 (entries, truncated, budget)
14687}
14688
14689fn workspace_inspection_truncation(
14693 walk_budget: &WorkspaceWalkBudget,
14694 git_time_budget_exceeded: bool,
14695 elapsed: Duration,
14696) -> Option<WorkspaceInspectionTruncationV1> {
14697 if !walk_budget.time_budget_exceeded
14698 && !walk_budget.entry_cap_exceeded
14699 && !git_time_budget_exceeded
14700 {
14701 return None;
14702 }
14703 Some(WorkspaceInspectionTruncationV1 {
14704 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
14705 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
14706 git_time_budget_exceeded,
14707 entries_visited: walk_budget.entries_visited,
14708 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
14709 })
14710}
14711
14712fn walk_workspace_directory(
14713 directory: &Path,
14714 relative_directory: Option<&RepositoryPath>,
14715 depth: usize,
14716 entries: &mut Vec<WorkspaceEntry>,
14717 truncated: &mut bool,
14718 budget: &mut WorkspaceWalkBudget,
14719) {
14720 if budget.is_exhausted() {
14721 *truncated = true;
14722 return;
14723 }
14724 let read_dir = match std::fs::read_dir(directory) {
14725 Ok(read_dir) => read_dir,
14726 Err(_) => {
14727 *truncated = true;
14728 return;
14729 }
14730 };
14731 let mut children = Vec::new();
14732 for child in read_dir {
14733 match child {
14734 Ok(child) => children.push(child),
14735 Err(_) => *truncated = true,
14736 }
14737 }
14738 children.sort_by_key(|child| child.file_name());
14739 for child in children {
14740 if budget.record_visit() {
14741 *truncated = true;
14742 return;
14743 }
14744 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
14745 *truncated = true;
14746 return;
14747 }
14748 let file_type = match child.file_type() {
14749 Ok(file_type) => file_type,
14750 Err(_) => {
14751 *truncated = true;
14752 continue;
14753 }
14754 };
14755 if file_type.is_symlink() {
14756 continue;
14757 }
14758 let name = match child.file_name().into_string() {
14759 Ok(name) => name,
14760 Err(_) => {
14761 *truncated = true;
14762 continue;
14763 }
14764 };
14765 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
14766 continue;
14767 }
14768 let relative_path = match relative_directory {
14769 None => windows_repository_path(name),
14770 Some(parent) => parent
14771 .as_utf8()
14772 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
14773 };
14774 let Some(relative_path) = relative_path else {
14775 *truncated = true;
14776 continue;
14777 };
14778 let kind = if file_type.is_dir() {
14779 WorkspaceEntryKind::Directory
14780 } else if file_type.is_file() {
14781 WorkspaceEntryKind::File
14782 } else {
14783 continue;
14784 };
14785 entries.push(WorkspaceEntry {
14786 relative_path: relative_path.clone(),
14787 kind,
14788 });
14789 if file_type.is_dir() {
14790 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
14791 walk_workspace_directory(
14792 &child.path(),
14793 Some(&relative_path),
14794 depth + 1,
14795 entries,
14796 truncated,
14797 budget,
14798 );
14799 } else {
14800 *truncated = true;
14801 }
14802 }
14803 }
14804}
14805
14806fn windows_repository_path(value: String) -> Option<RepositoryPath> {
14807 if value.contains('\\') {
14808 return None;
14809 }
14810 RepositoryPath::utf8(value).ok()
14811}
14812
14813fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
14814 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
14815}
14816
14817fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
14826 if name.eq_ignore_ascii_case(".git")
14827 || name.eq_ignore_ascii_case("target")
14828 || name.eq_ignore_ascii_case("node_modules")
14829 {
14830 return true;
14831 }
14832 path.join("CACHEDIR.TAG").is_file()
14833}
14834
14835fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
14836 matches!(
14837 status,
14838 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
14839 )
14840}
14841
14842#[derive(Clone, Debug, Eq, PartialEq)]
14843enum ContextPackRepositoryHead {
14844 NotRepository,
14845 Unborn,
14846 Commit(String),
14847}
14848
14849#[derive(Clone, Debug, Eq, PartialEq)]
14850struct ContextPackRepositoryObservation {
14851 head: ContextPackRepositoryHead,
14852 git: GitSnapshot,
14853 files: Vec<ContextPackRepositoryFileSource>,
14854}
14855
14856fn stable_context_pack_repository(
14857 first: ContextPackRepositoryObservation,
14858 second: ContextPackRepositoryObservation,
14859) -> Result<ContextPackRepository, NodeFailure> {
14860 if first != second {
14861 return Err(failure(
14862 NodeFailureCode::ContextPackMaterializationFailed,
14863 "context repository changed during bounded capture",
14864 ));
14865 }
14866 Ok(ContextPackRepository::from_sources(
14867 &second.git,
14868 second.files,
14869 ))
14870}
14871
14872fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
14879 if Instant::now() < deadline {
14880 return false;
14881 }
14882 append_git_diagnostic(
14883 snapshot,
14884 &format!("{stage} skipped: workspace inspection time budget exhausted"),
14885 );
14886 true
14887}
14888
14889async fn run_git_bounded_with_deadline(
14898 root: &str,
14899 arguments: &[&str],
14900 output_limit: usize,
14901 deadline: Instant,
14902) -> io::Result<GitCommandOutput> {
14903 let remaining = deadline.checked_duration_since(Instant::now());
14904 let timeout_ms = remaining
14905 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
14906 .unwrap_or(0)
14907 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
14908 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
14909}
14910
14911async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
14918 let mut snapshot = GitSnapshot {
14919 is_repository: false,
14920 branch: None,
14921 status: Vec::new(),
14922 recent_commits: Vec::new(),
14923 worktrees: Vec::new(),
14924 managed_worktree: None,
14925 truncated: false,
14926 diagnostic: None,
14927 };
14928 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
14929 return (snapshot, true);
14930 }
14931 let repository = match run_git_bounded_with_deadline(
14932 root,
14933 &["rev-parse", "--is-inside-work-tree"],
14934 4 * 1_024,
14935 deadline,
14936 )
14937 .await
14938 {
14939 Ok(output) => output,
14940 Err(error) => {
14941 append_git_diagnostic(
14942 &mut snapshot,
14943 &format!("git inspection unavailable: {error}"),
14944 );
14945 return (snapshot, false);
14946 }
14947 };
14948 snapshot.truncated |= repository.truncated;
14949 if repository.timed_out {
14950 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
14951 return (snapshot, false);
14952 }
14953 if !repository.success
14954 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
14955 {
14956 let stderr = String::from_utf8_lossy(&repository.stderr);
14957 if !stderr.to_ascii_lowercase().contains("not a git repository") {
14958 append_git_diagnostic(&mut snapshot, stderr.trim());
14959 }
14960 return (snapshot, false);
14961 }
14962 snapshot.is_repository = true;
14963
14964 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
14965 return (snapshot, true);
14966 }
14967 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
14968 Ok(output) => {
14969 snapshot.truncated |= output.truncated;
14970 if output.timed_out {
14971 append_git_diagnostic(&mut snapshot, "git branch query timed out");
14972 } else if output.success {
14973 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
14974 if !branch.is_empty() {
14975 snapshot.branch = Some(truncate_git_field(
14976 &branch,
14977 MAX_REPOSITORY_PATH_BYTES,
14978 &mut snapshot.truncated,
14979 ));
14980 }
14981 }
14982 }
14983 Err(error) => append_git_diagnostic(
14984 &mut snapshot,
14985 &format!("git branch query failed: {error}"),
14986 ),
14987 }
14988 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
14989 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
14990 snapshot.truncated |= output.truncated;
14991 if output.success && !output.timed_out {
14992 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
14993 if !head.is_empty() {
14994 snapshot.branch = Some(format!("detached:{head}"));
14995 }
14996 }
14997 }
14998 }
14999
15000 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15001 return (snapshot, true);
15002 }
15003 match run_git_bounded_with_deadline(
15004 root,
15005 &[
15006 "status",
15007 "--porcelain=v1",
15008 "-z",
15009 "--renames",
15010 "--untracked-files=normal",
15011 "--",
15012 ".",
15013 ],
15014 GIT_OUTPUT_MAX_BYTES,
15015 deadline,
15016 )
15017 .await
15018 {
15019 Ok(output) => {
15020 snapshot.truncated |= output.truncated;
15021 if output.timed_out {
15022 append_git_diagnostic(&mut snapshot, "git status timed out");
15023 } else if output.success {
15024 parse_git_status(&output.stdout, &mut snapshot);
15025 } else {
15026 append_git_diagnostic(
15027 &mut snapshot,
15028 String::from_utf8_lossy(&output.stderr).trim(),
15029 );
15030 }
15031 }
15032 Err(error) => append_git_diagnostic(
15033 &mut snapshot,
15034 &format!("git status failed: {error}"),
15035 ),
15036 }
15037
15038 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15039 return (snapshot, true);
15040 }
15041 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15042 match run_git_bounded_with_deadline(
15043 root,
15044 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15045 16 * 1_024,
15046 deadline,
15047 )
15048 .await
15049 {
15050 Ok(output) => {
15051 snapshot.truncated |= output.truncated;
15052 if output.timed_out {
15053 append_git_diagnostic(&mut snapshot, "git log timed out");
15054 } else if output.success {
15055 parse_git_commits(&output.stdout, &mut snapshot);
15056 } else {
15057 let stderr = String::from_utf8_lossy(&output.stderr);
15058 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15059 append_git_diagnostic(&mut snapshot, stderr.trim());
15060 }
15061 }
15062 }
15063 Err(error) => append_git_diagnostic(
15064 &mut snapshot,
15065 &format!("git log failed: {error}"),
15066 ),
15067 }
15068
15069 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15070 return (snapshot, true);
15071 }
15072 match list_git_worktrees_with_deadline(root, deadline).await {
15073 Ok(worktrees) => {
15074 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15075 }
15076 Err(error) => append_git_diagnostic(
15077 &mut snapshot,
15078 &format!("git worktree list failed: {}", error.message),
15079 ),
15080 }
15081 (snapshot, false)
15082}
15083
15084fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15085 let mut remaining = output;
15086 while !remaining.is_empty() {
15087 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15088 snapshot.truncated = true;
15089 break;
15090 }
15091 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15092 snapshot.truncated = true;
15093 break;
15094 };
15095 let record = &remaining[..record_end];
15096 remaining = &remaining[record_end + 1..];
15097 let has_previous_path = record
15098 .get(..2)
15099 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15100 let previous_path_bytes = if has_previous_path {
15101 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15102 snapshot.truncated = true;
15103 break;
15104 };
15105 let previous = &remaining[..previous_end];
15106 remaining = &remaining[previous_end + 1..];
15107 Some(previous)
15108 } else {
15109 None
15110 };
15111 if record.len() < 4
15112 || record[2] != b' '
15113 || !is_git_status_code(record[0])
15114 || !is_git_status_code(record[1])
15115 {
15116 snapshot.truncated = true;
15117 continue;
15118 }
15119 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15120 snapshot.truncated = true;
15121 continue;
15122 };
15123 let previous_path = match previous_path_bytes {
15124 Some(previous) => {
15125 let Some(previous) = windows_repository_path_from_bytes(previous) else {
15126 snapshot.truncated = true;
15127 continue;
15128 };
15129 Some(previous)
15130 }
15131 None => None,
15132 };
15133 snapshot.status.push(GitStatusEntry {
15134 index_status: (record[0] as char).to_string(),
15135 worktree_status: (record[1] as char).to_string(),
15136 path,
15137 previous_path,
15138 });
15139 }
15140}
15141
15142fn is_git_status_code(byte: u8) -> bool {
15143 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15144}
15145
15146fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15147 for line in String::from_utf8_lossy(output).lines() {
15148 let Some((id, summary)) = line.split_once('\u{1f}') else {
15149 continue;
15150 };
15151 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15152 snapshot.truncated = true;
15153 break;
15154 }
15155 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15156 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15157 snapshot.recent_commits.push(GitCommitSummary { id, summary });
15158 }
15159}
15160
15161fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15162 if value.len() <= max_bytes {
15163 return value.to_owned();
15164 }
15165 *truncated = true;
15166 let mut boundary = max_bytes;
15167 while boundary > 0 && !value.is_char_boundary(boundary) {
15168 boundary -= 1;
15169 }
15170 value[..boundary].to_owned()
15171}
15172
15173fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15174 if message.is_empty() {
15175 return;
15176 }
15177 let normalized = message
15178 .chars()
15179 .map(|character| if character.is_control() { ' ' } else { character })
15180 .collect::<String>();
15181 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15182 if !diagnostic.is_empty() {
15183 diagnostic.push_str("; ");
15184 }
15185 diagnostic.push_str(normalized.trim());
15186 snapshot.diagnostic = Some(truncate_git_field(
15187 &diagnostic,
15188 GIT_DIAGNOSTIC_MAX_BYTES,
15189 &mut snapshot.truncated,
15190 ));
15191}
15192
15193async fn run_git_bounded(
15194 root: &str,
15195 arguments: &[&str],
15196 output_limit: usize,
15197) -> io::Result<GitCommandOutput> {
15198 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15199}
15200
15201async fn read_git_history_bounded(
15202 root: &str,
15203 path: Option<&RepositoryPath>,
15204 before: Option<&GitObjectId>,
15205 limit: u16,
15206) -> Result<GitHistoryPage, NodeFailure> {
15207 let path = path
15208 .map(|path| {
15209 path.as_utf8().ok_or_else(|| {
15210 failure(
15211 NodeFailureCode::InvalidRepositoryPath,
15212 "git history path must use UTF-8",
15213 )
15214 })
15215 })
15216 .transpose()?
15217 .unwrap_or(".");
15218 let count = (usize::from(limit) + 1).to_string();
15219 let start = before
15220 .map(|revision| format!("{}^", revision.as_str()))
15221 .unwrap_or_else(|| "HEAD".to_owned());
15222 let arguments = [
15223 "--no-pager",
15224 "log",
15225 "--no-decorate",
15226 "--date=iso-strict",
15227 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15228 "-n",
15229 count.as_str(),
15230 start.as_str(),
15231 "--",
15232 path,
15233 ];
15234 let output = run_git_bounded(root, &arguments, 256 * 1_024)
15235 .await
15236 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15237 if output.timed_out {
15238 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15239 }
15240 if !output.success {
15241 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15242 }
15243 let mut commits = parse_git_history(&output.stdout);
15244 let has_more = commits.len() > usize::from(limit) || output.truncated;
15245 commits.truncate(usize::from(limit));
15246 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15247 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15248}
15249
15250fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15251 String::from_utf8_lossy(output)
15252 .split('\u{1e}')
15253 .filter_map(|record| {
15254 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15255 if fields.len() != 9 {
15256 return None;
15257 }
15258 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15259 let parents = if fields[1].is_empty() {
15260 Vec::new()
15261 } else {
15262 fields[1]
15263 .split(' ')
15264 .map(|value| GitObjectId::new(value.to_owned()))
15265 .collect::<Result<Vec<_>, _>>()
15266 .ok()?
15267 };
15268 Some(GitCommitDetails {
15269 id,
15270 parents,
15271 subject: bounded_git_text(fields[2], 512),
15272 author_name: bounded_git_text(fields[3], 256),
15273 author_email: bounded_git_text(fields[4], 320),
15274 authored_at: bounded_git_text(fields[5], 64),
15275 committer_name: bounded_git_text(fields[6], 256),
15276 committer_email: bounded_git_text(fields[7], 320),
15277 committed_at: bounded_git_text(fields[8], 64),
15278 signature_status: GitSignatureStatus::NoSignature,
15279 signer: None,
15280 })
15281 })
15282 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15283 .collect()
15284}
15285
15286fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15287 let normalized = value
15288 .chars()
15289 .map(|character| if character.is_control() { ' ' } else { character })
15290 .collect::<String>();
15291 if normalized.len() <= max_bytes {
15292 return normalized;
15293 }
15294 let mut boundary = max_bytes;
15295 while boundary > 0 && !normalized.is_char_boundary(boundary) {
15296 boundary -= 1;
15297 }
15298 normalized[..boundary].to_owned()
15299}
15300
15301async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15302 let path = request.path.as_ref().map(|path| {
15303 path.as_utf8()
15304 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15305 }).transpose()?;
15306 let mut arguments = match &request.mode {
15307 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15308 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15309 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15310 };
15311 if let GitDiffMode::Commit { revision } = &request.mode {
15312 arguments.push(revision.as_str());
15313 }
15314 arguments.push("--");
15315 if let Some(path) = path {
15316 arguments.push(path);
15317 }
15318 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15319 .await
15320 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15321 if output.timed_out {
15322 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15323 }
15324 if !output.success {
15325 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15326 }
15327 Ok(GitDiff {
15328 mode: request.mode,
15329 path: request.path,
15330 text: String::from_utf8_lossy(&output.stdout).into_owned(),
15331 truncated: output.truncated,
15332 })
15333}
15334
15335async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15336 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15337 ResponseEnvelope { request_id: envelope.request_id, result }
15338}
15339
15340async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15341 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15342 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15343 }
15344 let read_only = matches!(
15345 &request,
15346 NodeRequest::Snapshot
15347 | NodeRequest::Resync { .. }
15348 | NodeRequest::BrowseHostDirectories { .. }
15349 | NodeRequest::InspectWorkspace { .. }
15350 | NodeRequest::ReadWorkspaceFile { .. }
15351 | NodeRequest::ReadGitHistory { .. }
15352 | NodeRequest::ReadGitDiff { .. }
15353 | NodeRequest::CatalogNativeSessions { .. }
15354 | NodeRequest::PageNativeSessions { .. }
15355 | NodeRequest::PreviewNativeSession { .. }
15356 | NodeRequest::PreviewSessionRecord { .. }
15357 );
15358 let _mutation_guard = if read_only {
15359 None
15360 } else {
15361 let guard = shared.mutation_gate.lock().await;
15362 if shared.shutdown.load(Ordering::Acquire) {
15363 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
15364 }
15365 Some(guard)
15366 };
15367 match request {
15368 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
15369 event_sequence: shared.current_sequence(),
15370 controller: shared.controller_state(),
15371 snapshot: shared.snapshot(),
15372 }),
15373 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
15374 NodeRequest::ArmHarnessMcpReservation {
15375 reservation_id,
15376 activation_digest,
15377 spawn_spec,
15378 launch,
15379 expires_at_unix_ms,
15380 } => {
15381 shared.require_controller(connection_id, role)?;
15382 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15383 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15384 })?;
15385 let expires_at_unix_ms = registry
15386 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
15387 .await
15388 .map_err(harness_mcp_failure)?;
15389 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
15390 }
15391 NodeRequest::SpawnSpecWithHarnessMcp {
15392 reservation_id,
15393 activation_digest,
15394 spec,
15395 deadline_unix_ms,
15396 } => {
15397 shared.require_controller(connection_id, role)?;
15398 let receipt = shared
15399 .spawn_from_spec_with_harness_mcp(
15400 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
15401 )
15402 .await?;
15403 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
15404 }
15405 NodeRequest::ActivateHarnessMcpReservation {
15406 reservation_id,
15407 activation_digest,
15408 record_id,
15409 session,
15410 } => {
15411 shared.require_controller(connection_id, role)?;
15412 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
15413 current.instance_id == session.session.instance_id
15414 && current.generation == session.session.generation
15415 }).and_then(|current| current.process_id).ok_or_else(|| {
15416 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
15417 })?;
15418 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15419 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15420 })?.activate(
15421 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
15422 ).map_err(harness_mcp_failure)?;
15423 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
15424 }
15425 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
15426 shared.require_controller(connection_id, role)?;
15427 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15428 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15429 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
15430 if let (Some(control), Some(instance_id)) =
15431 (shared.native_launch_profile_control.as_ref(), instance_id)
15432 {
15433 control.clear_native_mcp_server_launch_overlay(instance_id);
15434 }
15435 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
15436 }
15437 NodeRequest::PutHarnessMcpReplyChunk {
15438 reservation_id, activation_digest, record_id, session, call_id,
15439 offset, final_chunk, chunk_hex,
15440 } => {
15441 shared.require_controller(connection_id, role)?;
15442 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15443 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15444 })?.put_reply_chunk(
15445 &reservation_id, &activation_digest, &record_id, &session, &call_id,
15446 offset, final_chunk, &chunk_hex,
15447 ).map_err(harness_mcp_failure)?;
15448 Ok(NodeResponse::ReplyChunkAccepted {
15449 reservation_id, activation_digest, record_id, session, call_id,
15450 next_offset, completed,
15451 })
15452 }
15453 NodeRequest::RejectHarnessMcpCall {
15454 reservation_id, activation_digest, record_id, session, call_id, reason,
15455 } => {
15456 shared.require_controller(connection_id, role)?;
15457 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15458 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15459 })?.reject_call(
15460 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
15461 ).map_err(harness_mcp_failure)?;
15462 Ok(NodeResponse::CallRejected {
15463 reservation_id, activation_digest, record_id, session, call_id,
15464 })
15465 }
15466 NodeRequest::BeginDeliveryStage { manifest } => {
15467 shared.require_controller(connection_id, role)?;
15468 shared.begin_delivery_stage(manifest)
15469 }
15470 NodeRequest::PutDeliveryBlobChunk {
15471 stage_id,
15472 blob_digest,
15473 offset,
15474 chunk_hex,
15475 } => {
15476 shared.require_controller(connection_id, role)?;
15477 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
15478 }
15479 NodeRequest::CommitDeliveryStage { stage_id } => {
15480 shared.require_controller(connection_id, role)?;
15481 let receipt = shared.commit_delivery_stage(stage_id)?;
15482 Ok(NodeResponse::DeliveryCommitted { receipt })
15483 }
15484 NodeRequest::AbortDeliveryStage { stage_id } => {
15485 shared.require_controller(connection_id, role)?;
15486 shared.abort_delivery_stage(&stage_id)?;
15487 Ok(NodeResponse::DeliveryStageAborted { stage_id })
15488 }
15489 NodeRequest::BrowseHostDirectories { directory, after } => {
15490 let listing = shared.browse_host_directories(directory, after).await?;
15491 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
15492 }
15493 NodeRequest::InspectWorkspace { workspace_id } => {
15494 let inspection = shared.inspect_workspace(workspace_id).await?;
15495 Ok(NodeResponse::WorkspaceInspected { inspection })
15496 }
15497 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
15498 let file = shared.read_workspace_file(workspace_id, path).await?;
15499 Ok(NodeResponse::WorkspaceFileRead { file })
15500 }
15501 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
15502 shared.require_controller(connection_id, role)?;
15503 let file = shared
15504 .write_workspace_file(workspace_id, path, expected_revision, text)
15505 .await?;
15506 Ok(NodeResponse::WorkspaceFileWritten { file })
15507 }
15508 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
15509 shared.require_controller(connection_id, role)?;
15510 let file = shared.create_workspace_file(workspace_id, path).await?;
15511 Ok(NodeResponse::WorkspaceFileCreated { file })
15512 }
15513 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
15514 shared.require_controller(connection_id, role)?;
15515 let entry = shared
15516 .create_workspace_directory(workspace_id.clone(), path)
15517 .await?;
15518 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
15519 }
15520 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
15521 if role != ClientRole::Operator {
15522 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
15523 }
15524 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
15525 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
15526 }
15527 let page = shared
15528 .read_git_history(workspace_id.clone(), path, before, limit)
15529 .await?;
15530 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
15531 }
15532 NodeRequest::ReadGitDiff { workspace_id, request } => {
15533 if role != ClientRole::Operator {
15534 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
15535 }
15536 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
15537 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
15538 }
15539 NodeRequest::CatalogNativeSessions { route, limit } => {
15540 if role != ClientRole::Operator {
15541 return Err(failure(
15542 NodeFailureCode::ObserverReadOnly,
15543 "native session catalog requires an operator connection",
15544 ));
15545 }
15546 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
15547 return Err(failure(
15548 NodeFailureCode::InvalidRequest,
15549 "native session catalog limit is invalid",
15550 ));
15551 }
15552 let (entries, summary) = shared
15553 .catalog_native_sessions(route.clone(), limit)
15554 .await?;
15555 Ok(NodeResponse::NativeSessionsCataloged {
15556 route,
15557 entries,
15558 summary: Some(summary),
15559 })
15560 }
15561 NodeRequest::PageNativeSessions {
15562 route,
15563 window,
15564 catalog_revision,
15565 recent_cutoff_unix_ms,
15566 after_selection_id,
15567 limit,
15568 } => {
15569 if role != ClientRole::Operator {
15570 return Err(failure(
15571 NodeFailureCode::ObserverReadOnly,
15572 "native session catalog paging requires an operator connection",
15573 ));
15574 }
15575 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
15576 || after_selection_id
15577 .as_deref()
15578 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
15579 {
15580 return Err(failure(
15581 NodeFailureCode::InvalidRequest,
15582 "native session catalog page request is invalid",
15583 ));
15584 }
15585 let page = shared
15586 .page_native_sessions(
15587 route.clone(),
15588 window,
15589 catalog_revision,
15590 recent_cutoff_unix_ms,
15591 after_selection_id,
15592 limit,
15593 )
15594 .await?;
15595 Ok(NodeResponse::NativeSessionsPaged {
15596 route,
15597 page,
15598 })
15599 }
15600 NodeRequest::PreviewNativeSession {
15601 selection,
15602 message_limit,
15603 } => {
15604 if role != ClientRole::Operator {
15605 return Err(failure(
15606 NodeFailureCode::ObserverReadOnly,
15607 "native session preview requires an operator connection",
15608 ));
15609 }
15610 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15611 .contains(&message_limit)
15612 || selection.validate().is_err()
15613 {
15614 return Err(failure(
15615 NodeFailureCode::InvalidRequest,
15616 "native session preview request is invalid",
15617 ));
15618 }
15619 let preview = shared
15620 .preview_native_session(selection.clone(), message_limit)
15621 .await?;
15622 Ok(NodeResponse::NativeSessionPreviewed {
15623 selection,
15624 preview,
15625 })
15626 }
15627 NodeRequest::IndexNativeSession {
15628 selection,
15629 display_name,
15630 } => {
15631 shared.require_controller(connection_id, role)?;
15632 let record = shared
15633 .index_native_session(selection.clone(), display_name)
15634 .await?;
15635 Ok(NodeResponse::NativeSessionIndexed { selection, record })
15636 }
15637 NodeRequest::PreviewSessionRecord {
15638 record_id,
15639 message_limit,
15640 } => {
15641 if role != ClientRole::Operator {
15642 return Err(failure(
15643 NodeFailureCode::ObserverReadOnly,
15644 "native session preview requires an operator connection",
15645 ));
15646 }
15647 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15648 .contains(&message_limit)
15649 {
15650 return Err(failure(
15651 NodeFailureCode::InvalidRequest,
15652 "native session preview request is invalid",
15653 ));
15654 }
15655 let record = shared.record(&record_id)?;
15656 let working_directory = shared.workspace_root(&record.workspace_id)?;
15657 if !platform::roots_equal(
15658 &working_directory,
15659 windows_path_text(&record.canonical_root),
15660 ) {
15661 return Err(failure(
15662 NodeFailureCode::SessionRecordConflict,
15663 "managed session workspace root changed; refusing to preview another directory",
15664 ));
15665 }
15666 let identity = record.provider_session.clone().ok_or_else(|| {
15667 failure(
15668 NodeFailureCode::BackendOperationFailed,
15669 "native session preview is unavailable for this session record",
15670 )
15671 })?;
15672 if identity.key != ProviderSessionKey::SessionId {
15673 return Err(failure(
15674 NodeFailureCode::BackendOperationFailed,
15675 "native session preview is unavailable for this session identity",
15676 ));
15677 }
15678 let preview = shared
15679 .preview_native_session_by_session_id(
15680 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
15681 )
15682 .await?;
15683 if preview.session_id != identity.id {
15684 return Err(failure(
15685 NodeFailureCode::SessionRecordConflict,
15686 "native session preview identity changed while loading",
15687 ));
15688 }
15689 shared.revalidate_session_record_preview(&record, &identity)?;
15690 let preview: gate4agent_types::SessionRecordPreview = preview.into();
15691 shared.publish(NodeEvent::SessionRecordHistorySummarized {
15692 record_id: record_id.clone(),
15693 summary: SessionHistorySummaryV1::from(&preview),
15694 });
15695 Ok(NodeResponse::SessionRecordPreviewed {
15696 record_id,
15697 preview,
15698 })
15699 }
15700 NodeRequest::AcquireController { lease_ms } => {
15701 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
15702 Ok(NodeResponse::Controller { controller: Some(controller) })
15703 }
15704 NodeRequest::ReleaseController => {
15705 shared.release_controller(connection_id);
15706 Ok(NodeResponse::Controller { controller: shared.controller_state() })
15707 }
15708 NodeRequest::RegisterWorkspace { workspace_id, root } => {
15709 shared.require_controller(connection_id, role)?;
15710 let root = require_windows_path(root)?;
15711 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
15712 let workspace = shared.register_workspace(workspace_id, root).await?;
15713 Ok(NodeResponse::WorkspaceRegistered { workspace })
15714 }
15715 NodeRequest::CreateStandaloneWorkspace {
15716 workspace_id,
15717 root,
15718 initial_branch,
15719 } => {
15720 shared.require_controller(connection_id, role)?;
15721 let root = require_windows_path(root)?;
15722 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
15723 let workspace = shared
15724 .create_standalone_workspace(workspace_id, root, initial_branch)
15725 .await?;
15726 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
15727 }
15728 NodeRequest::UnregisterWorkspace { workspace_id } => {
15729 shared.require_controller(connection_id, role)?;
15730 shared.reject_managed_reservation(Some(&workspace_id), None)?;
15731 shared.unregister_workspace(&workspace_id)?;
15732 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
15733 }
15734 NodeRequest::CreateWorktree {
15735 source_workspace_id,
15736 workspace_id,
15737 target_root,
15738 branch,
15739 base,
15740 } => {
15741 shared.require_controller(connection_id, role)?;
15742 let target_root = require_windows_path(target_root)?;
15743 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
15744 let (worktree, workspace) = shared
15745 .create_worktree(
15746 source_workspace_id,
15747 workspace_id,
15748 target_root,
15749 branch,
15750 base,
15751 )
15752 .await?;
15753 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
15754 }
15755 NodeRequest::RemoveWorktree {
15756 source_workspace_id,
15757 target_root,
15758 } => {
15759 shared.require_controller(connection_id, role)?;
15760 let native_target_root = require_windows_path(target_root.clone())?;
15761 shared.reject_managed_reservation(None, Some(&native_target_root))?;
15762 let workspace_id = shared
15763 .remove_worktree(source_workspace_id, native_target_root)
15764 .await?;
15765 Ok(NodeResponse::WorktreeRemoved {
15766 target_root,
15767 workspace_id,
15768 })
15769 }
15770 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
15771 shared.require_controller(connection_id, role)?;
15772 if !terminal_size.is_valid() {
15773 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
15774 }
15775 if let Some(prompt) = initial_prompt.as_deref() {
15776 validate_node_text("spawn initial prompt", prompt)?;
15777 }
15778 let session = shared
15779 .spawn_session(workspace_id, provider, mode, terminal_size, initial_prompt)
15780 .await?;
15781 Ok(NodeResponse::SpawnAccepted { session })
15782 }
15783 NodeRequest::SpawnSpec { spec } => {
15784 shared.require_controller(connection_id, role)?;
15785 let receipt = shared.spawn_from_spec(spec).await?;
15786 Ok(NodeResponse::SpawnSpecAccepted { receipt })
15787 }
15788 NodeRequest::SpawnManagedWorktree { request } => {
15789 shared.require_controller(connection_id, role)?;
15790 let receipt = shared.spawn_managed_worktree(request).await?;
15791 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
15792 }
15793 NodeRequest::SpawnManagedWorktreeV2 { request } => {
15794 shared.require_controller(connection_id, role)?;
15795 let receipt = shared.spawn_managed_worktree_v2(request).await?;
15796 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
15797 }
15798 NodeRequest::CleanupManagedWorktree { lease_id } => {
15799 shared.require_controller(connection_id, role)?;
15800 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
15801 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
15802 }
15803 NodeRequest::DiscoverHistory { session, limit } => {
15804 controlled_session(shared, connection_id, role, &session)?;
15805 let candidates = shared.discover_history(&session, limit).await?;
15806 Ok(NodeResponse::HistoryDiscovered {
15807 session,
15808 candidates,
15809 })
15810 }
15811 NodeRequest::LoadHistory {
15812 session,
15813 candidate_id,
15814 } => {
15815 controlled_session(shared, connection_id, role, &session)?;
15816 let loaded = shared
15817 .load_history(&session, candidate_id)
15818 .await?;
15819 Ok(NodeResponse::HistoryLoaded {
15820 session,
15821 session_id: loaded.session_id,
15822 message_count: loaded.message_count,
15823 completed_turn_count: loaded.completed_turn_count,
15824 })
15825 }
15826 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
15827 shared.require_controller(connection_id, role)?;
15828 let context = shared
15829 .export_context_pack_for_session_record(&record_id, &session, false)
15830 .await?;
15831 Ok(NodeResponse::ContextPackForSessionRecordExported {
15832 record_id,
15833 session,
15834 context,
15835 })
15836 }
15837 NodeRequest::ExportContextPack { session } => {
15838 controlled_session(shared, connection_id, role, &session)?;
15839 let context = shared.export_context_pack(&session).await?;
15840 Ok(NodeResponse::ContextPackExported { context })
15841 }
15842 NodeRequest::ForgetContextPack { context_id } => {
15843 shared.require_controller(connection_id, role)?;
15844 shared.forget_context_pack(&context_id)?;
15845 Ok(NodeResponse::ContextPackForgotten { context_id })
15846 }
15847 NodeRequest::ResolveDurableContextPack { context_id } => {
15848 let context = shared
15852 .context_catalog
15853 .read()
15854 .unwrap_or_else(|poisoned| poisoned.into_inner())
15855 .get(&context_id)
15856 .map(|pack| pack.receipt().clone())
15857 .ok_or_else(|| {
15858 failure(
15859 NodeFailureCode::UnknownContextPack,
15860 "context pack is not present",
15861 )
15862 })?;
15863 Ok(NodeResponse::DurableContextPackResolved { context })
15864 }
15865 NodeRequest::ReadContextPack { digest } => {
15866 let (id, byte_len, bytes) = shared
15871 .context_catalog
15872 .read()
15873 .unwrap_or_else(|poisoned| poisoned.into_inner())
15874 .get_by_digest(&digest)
15875 .map(|pack| {
15876 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
15877 })
15878 .ok_or_else(|| {
15879 failure(
15880 NodeFailureCode::UnknownContextPack,
15881 &format!("context pack {digest} not on this node"),
15882 )
15883 })?;
15884 tracing::debug!(
15885 digest = digest.as_str(),
15886 byte_len,
15887 "read context pack bytes",
15888 );
15889 Ok(NodeResponse::ContextPackBytesRead {
15890 pack: ContextPackBytesRead {
15891 digest,
15892 id,
15893 byte_len,
15894 bytes,
15895 },
15896 })
15897 }
15898 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
15899 let provider = controlled_session(shared, connection_id, role, &session)?;
15900 if let Some(prompt) = initial_prompt.as_deref() {
15901 validate_node_text("resume initial prompt", prompt)?;
15902 }
15903 let runtime_requirement = if initial_prompt.is_some() {
15904 ProviderRuntimeRequirement::ResumeWithPrompt
15905 } else {
15906 ProviderRuntimeRequirement::Resume
15907 };
15908 shared.require_session_runtime_policy(&session, runtime_requirement)?;
15909 let runtime_policy = shared
15910 .admit_provider_runtime(&provider, runtime_requirement)
15911 .await?;
15912 let working_directory = shared.bound_workspace_root(&session)?;
15913 let request = ResumeLaunchRequest {
15914 working_directory,
15915 terminal_size,
15916 initial_prompt,
15917 };
15918 request
15919 .validate()
15920 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
15921 let command = shared.prepare_command(ControlCommand::Resume {
15922 instance_id: session.session.instance_id,
15923 target: ResumeTarget::CurrentProvider,
15924 runtime_policy,
15925 request,
15926 });
15927 shared.arm_resume(&session, command.id, runtime_policy)?;
15928 let dispatch = shared.dispatch_envelope(command);
15929 if let Err(error) = dispatch {
15930 shared.clear_armed_resume(&session);
15931 return Err(error);
15932 }
15933 Ok(NodeResponse::Accepted)
15934 }
15935 NodeRequest::RenameSessionRecord {
15936 record_id,
15937 display_name,
15938 } => {
15939 shared.require_controller(connection_id, role)?;
15940 let record = shared.rename_session_record(&record_id, display_name)?;
15941 Ok(NodeResponse::SessionRecordUpdated { record })
15942 }
15943 NodeRequest::SetSessionTask {
15944 record_id,
15945 expected_revision,
15946 target,
15947 } => {
15948 shared.require_controller(connection_id, role)?;
15949 let record = shared.set_session_task(&record_id, expected_revision, target)?;
15950 Ok(NodeResponse::SessionRecordUpdated { record })
15951 }
15952 NodeRequest::IndexProviderSession {
15953 workspace_id,
15954 provider,
15955 identity,
15956 display_name,
15957 } => {
15958 shared.require_controller(connection_id, role)?;
15959 let record = shared.index_provider_session(
15960 workspace_id,
15961 provider,
15962 identity,
15963 display_name,
15964 )?;
15965 Ok(NodeResponse::ProviderSessionIndexed { record })
15966 }
15967 NodeRequest::ResumeSessionRecord {
15968 record_id,
15969 terminal_size,
15970 initial_prompt,
15971 } => {
15972 shared.require_controller(connection_id, role)?;
15973 if let Some(prompt) = initial_prompt.as_deref() {
15974 validate_node_text("managed resume initial prompt", prompt)?;
15975 }
15976 let (record, session) = shared
15977 .resume_session_record(
15978 &record_id,
15979 terminal_size,
15980 initial_prompt,
15981 )
15982 .await?;
15983 Ok(NodeResponse::SessionRecordResumed { record, session })
15984 }
15985 NodeRequest::ForgetSessionRecord { record_id } => {
15986 shared.require_controller(connection_id, role)?;
15987 shared.forget_session_record(&record_id).await?;
15988 Ok(NodeResponse::SessionRecordForgotten { record_id })
15989 }
15990 NodeRequest::Prompt { session, text } => {
15991 let agent_id = controlled_session(shared, connection_id, role, &session)?;
15992 validate_node_text("prompt", &text)?;
15993 shared.require_session_runtime_policy(
15994 &session,
15995 ProviderRuntimeRequirement::SemanticPrompt,
15996 )?;
15997 let framing = prompt_framing(&agent_id);
15998 shared
15999 .dispatch_input_bounded(
16000 &session,
16001 InputAction::SubmitPrompt(PromptPayload { text, framing }),
16002 )
16003 .await?;
16004 Ok(NodeResponse::Accepted)
16005 }
16006 NodeRequest::Paste { session, text } => {
16007 controlled_session(shared, connection_id, role, &session)?;
16008 validate_node_text("paste", &text)?;
16009 shared.require_session_runtime_policy(
16010 &session,
16011 ProviderRuntimeRequirement::SemanticPrompt,
16012 )?;
16013 shared
16014 .dispatch_input_bounded(
16015 &session,
16016 InputAction::InsertDraft(PromptPayload {
16017 text,
16018 framing: PromptFraming::BracketedPaste,
16019 }),
16020 )
16021 .await?;
16022 Ok(NodeResponse::Accepted)
16023 }
16024 NodeRequest::Input { session, text } => {
16025 controlled_session(shared, connection_id, role, &session)?;
16026 validate_node_text("terminal input", &text)?;
16027 shared
16028 .dispatch_input_bounded(
16029 &session,
16030 InputAction::TerminalText(TerminalText { text }),
16031 )
16032 .await?;
16033 Ok(NodeResponse::Accepted)
16034 }
16035 NodeRequest::TerminalBytes { session, bytes } => {
16036 controlled_session(shared, connection_id, role, &session)?;
16037 let action = terminal_bytes_action(bytes)?;
16038 shared
16039 .dispatch_input_bounded(&session, action)
16040 .await?;
16041 Ok(NodeResponse::Accepted)
16042 }
16043 NodeRequest::TerminalControl { session, control } => {
16044 controlled_session(shared, connection_id, role, &session)?;
16045 shared
16046 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16047 .await?;
16048 Ok(NodeResponse::Accepted)
16049 }
16050 NodeRequest::Resize { session, size } => {
16051 controlled_session(shared, connection_id, role, &session)?;
16052 if !size.is_valid() {
16053 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16054 }
16055 shared.dispatch_resize_bounded(&session, size).await?;
16056 Ok(NodeResponse::Accepted)
16057 }
16058 NodeRequest::Interrupt { session } => {
16059 controlled_session(shared, connection_id, role, &session)?;
16060 shared
16061 .dispatch_input_bounded(
16062 &session,
16063 InputAction::TerminalControl(TerminalControl::Interrupt),
16064 )
16065 .await?;
16066 Ok(NodeResponse::Accepted)
16067 }
16068 NodeRequest::Stop { session, force } => {
16069 controlled_session(shared, connection_id, role, &session)?;
16070 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16071 Ok(NodeResponse::Accepted)
16072 }
16073 NodeRequest::Remove { session } => {
16074 controlled_session(shared, connection_id, role, &session)?;
16075 shared.remove_session(&session).await?;
16076 Ok(NodeResponse::Accepted)
16077 }
16078 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16079 shared.require_controller(connection_id, role)?;
16080 let snapshot = shared.require_acp_session(&session)?;
16081 let interaction = snapshot
16082 .provider
16083 .interactions
16084 .iter()
16085 .find(|interaction| {
16086 interaction_correlation(
16087 session.session.instance_id,
16088 session.session.generation,
16089 interaction.id.0,
16090 ) == correlation_id
16091 })
16092 .ok_or_else(|| failure(
16093 NodeFailureCode::InvalidRequest,
16094 "unknown interaction correlation",
16095 ))?;
16096 if interaction.status != ProviderInteractionStatus::Pending {
16097 return Err(failure(
16098 NodeFailureCode::BackendOperationFailed,
16099 "interaction has already been resolved",
16100 ));
16101 }
16102 response
16103 .validate_for(interaction.interaction_kind)
16104 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16105 let interaction_id = interaction.id;
16106 let response_kind = response.kind();
16107 let response_outcome = response.outcome();
16108 shared.dispatch(ControlCommand::ResolveInteraction {
16109 instance_id: session.session.instance_id,
16110 generation: session.session.generation,
16111 interaction_id,
16112 response,
16113 })?;
16114 tracing::info!(
16120 node_id = %shared.node_id,
16121 session = ?session.session,
16122 correlation_id = %correlation_id,
16123 interaction_id = interaction_id.0,
16124 response_kind = ?response_kind,
16125 outcome = ?response_outcome,
16126 "interaction resolved",
16127 );
16128 Ok(NodeResponse::Accepted)
16129 }
16130 NodeRequest::SetSessionMode { session, mode_id } => {
16131 shared.require_controller(connection_id, role)?;
16132 shared.require_acp_session(&session)?;
16133 shared.dispatch(ControlCommand::SetSessionMode {
16134 instance_id: session.session.instance_id,
16135 mode_id,
16136 })?;
16137 Ok(NodeResponse::Accepted)
16138 }
16139 NodeRequest::SetSessionConfigOption {
16140 session,
16141 option_id,
16142 value_json,
16143 } => {
16144 shared.require_controller(connection_id, role)?;
16145 shared.require_acp_session(&session)?;
16146 shared.dispatch(ControlCommand::SetSessionConfigOption {
16147 instance_id: session.session.instance_id,
16148 option_id,
16149 value_json,
16150 })?;
16151 Ok(NodeResponse::Accepted)
16152 }
16153 NodeRequest::SetSessionModel { session, model_id } => {
16154 shared.require_controller(connection_id, role)?;
16155 shared.require_acp_session(&session)?;
16156 shared.dispatch(ControlCommand::SetSessionModel {
16157 instance_id: session.session.instance_id,
16158 model_id,
16159 })?;
16160 Ok(NodeResponse::Accepted)
16161 }
16162 NodeRequest::Shutdown => {
16163 shared.require_controller(connection_id, role)?;
16164 shared.begin_shutdown_locked().await?;
16165 Ok(NodeResponse::ShuttingDown)
16166 }
16167 }
16168}
16169
16170fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16171 shared.require_controller(connection_id, role)?;
16172 shared.validate_address(session)
16173}
16174
16175fn select_history_candidate(
16181 candidates: &[HistoryCandidateSummary],
16182 provider_session_id: Option<&str>,
16183) -> Result<String, NodeFailure> {
16184 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16185 Some(id) => candidate.session_id_hint == id,
16186 None => true,
16187 });
16188 let candidate = matches.next().ok_or_else(|| {
16189 failure(
16190 NodeFailureCode::SessionRecordConflict,
16191 if provider_session_id.is_some() {
16192 "no history candidate matches the managed provider session"
16193 } else {
16194 "no history candidate exists for the managed session"
16195 },
16196 )
16197 })?;
16198 if matches.next().is_some() {
16199 return Err(failure(
16200 NodeFailureCode::SessionRecordConflict,
16201 if provider_session_id.is_some() {
16202 "history identity is ambiguous for the managed provider session"
16203 } else {
16204 "history is ambiguous without a verified provider session identity"
16205 },
16206 ));
16207 }
16208 Ok(candidate.id.clone())
16209}
16210
16211fn resolved_spawn_profile_summary(
16212 profile: &SpawnProfileDefaults,
16213 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16214) -> Option<SpawnProfileSummary> {
16215 let environment_profile = match profile.environment_profile_id.as_ref() {
16216 None => None,
16217 Some(profile_id) => {
16218 let binding = environment_profiles.get(profile_id)?;
16219 if binding.provider != profile.provider
16220 || binding.native_profile_id(profile.mode).is_none()
16221 {
16222 return None;
16223 }
16224 Some(ResolvedEnvironmentProfileReceipt {
16225 profile_id: binding.id.clone(),
16226 profile_revision: binding.revision.clone(),
16227 network_allowlist: None,
16228 browser_profile_id: None,
16229 })
16230 }
16231 };
16232 Some(SpawnProfileSummary {
16233 id: profile.profile_id.clone(),
16234 revision: profile.revision.clone(),
16235 environment_profile,
16236 })
16237}
16238
16239fn session_record_export_target_matches(
16258 expected: &ManagedSessionRecord,
16259 current: &ManagedSessionRecord,
16260 session: &SessionAddress,
16261 allow_clean_detachment: bool,
16262) -> bool {
16263 if current == expected && current.active_session.as_ref() == Some(session) {
16264 return current.state == ManagedSessionState::Live;
16265 }
16266 allow_clean_detachment
16267 && current.state == ManagedSessionState::Dormant
16268 && current.active_session.is_none()
16269 && current.record_id == expected.record_id
16270 && current.display_name == expected.display_name
16271 && current.provider == expected.provider
16272 && current.mode == expected.mode
16273 && current.workspace_id == expected.workspace_id
16274 && current.canonical_root == expected.canonical_root
16275 && current.provider_session == expected.provider_session
16276 && current.environment_profile == expected.environment_profile
16277 && current.bundle == expected.bundle
16278 && current.context_id == expected.context_id
16279 && current.context == expected.context
16280 && current.exported_context == expected.exported_context
16281 && current.task_binding == expected.task_binding
16282 && current.created_at_unix_ms == expected.created_at_unix_ms
16283 && current.last_error == expected.last_error
16284}
16285
16286fn session_record_context_export_binding_is_exact(
16287 expected: &ManagedSessionRecord,
16288 current: &ManagedSessionRecord,
16289 identity: Option<&ProviderSessionIdentity>,
16290 session: &SessionAddress,
16291 binding: &SessionBinding,
16292 provider: &AgentId,
16293 current_root: &str,
16294 allow_clean_detachment: bool,
16295) -> bool {
16296 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
16297 && current.provider_session.as_ref() == identity
16298 && current.workspace_id == session.workspace_id
16299 && provider == ¤t.provider
16300 && binding.workspace_id == session.workspace_id
16301 && binding.generation == session.session.generation
16302 && binding.record_id.as_ref() == Some(¤t.record_id)
16303 && platform::roots_equal(
16304 current_root,
16305 windows_path_text(¤t.canonical_root),
16306 )
16307}
16308
16309fn session_record_context_export_source_is_usable(
16310 record: &ManagedSessionRecord,
16311 status: &SessionStatus,
16312 allow_clean_detachment: bool,
16313) -> bool {
16314 (record.state == ManagedSessionState::Live
16315 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
16316 && context_pack_source_status_is_usable(status)
16317}
16318
16319fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
16320 PromptFraming::BracketedPaste
16321}
16322
16323fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
16324 if text.len() > MAX_NODE_TEXT_BYTES {
16325 return Err(failure(
16326 NodeFailureCode::InvalidRequest,
16327 &format!(
16328 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
16329 text.len(),
16330 ),
16331 ));
16332 }
16333 Ok(())
16334}
16335
16336fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
16337 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
16338 return Err(failure(
16339 NodeFailureCode::InvalidRequest,
16340 &format!(
16341 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
16342 bytes.len(),
16343 ),
16344 ));
16345 }
16346 Ok(())
16347}
16348
16349fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
16350 validate_terminal_bytes(&bytes)?;
16351 Ok(InputAction::TerminalBytes(bytes))
16352}
16353
16354fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
16355 if root.is_empty()
16356 || root.len() > MAX_WORKSPACE_ROOT_BYTES
16357 || root.chars().any(char::is_control)
16358 {
16359 return Err(failure(
16360 NodeFailureCode::InvalidWorkspaceRoot,
16361 &format!(
16362 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
16363 ),
16364 ));
16365 }
16366 Ok(())
16367}
16368
16369fn canonical_standalone_workspace_candidate(
16370 workspace_id: &WorkspaceId,
16371 root: &str,
16372) -> Result<String, NodeServerError> {
16373 let path = Path::new(root);
16374 match std::fs::symlink_metadata(path) {
16375 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
16376 .map(|workspace| workspace.canonical_root),
16377 Err(error) if error.kind() == io::ErrorKind::NotFound => {
16378 if !path.is_absolute() {
16379 return Err(NodeServerError::InvalidWorkspaceRoot {
16380 workspace_id: workspace_id.clone(),
16381 path: root.to_owned(),
16382 message: "path is not absolute".to_owned(),
16383 });
16384 }
16385 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16386 workspace_id: workspace_id.clone(),
16387 path: root.to_owned(),
16388 message: "path has no standalone directory name".to_owned(),
16389 })?;
16390 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16391 workspace_id: workspace_id.clone(),
16392 path: root.to_owned(),
16393 message: "path has no parent directory".to_owned(),
16394 })?;
16395 let canonical = std::fs::canonicalize(parent)
16396 .map(|parent| parent.join(name))
16397 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
16398 workspace_id: workspace_id.clone(),
16399 path: root.to_owned(),
16400 message: error.to_string(),
16401 })?;
16402 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
16403 NodeServerError::InvalidWorkspaceRoot {
16404 workspace_id: workspace_id.clone(),
16405 path: path.to_string_lossy().into_owned(),
16406 message: "canonical path is not valid Unicode".to_owned(),
16407 }
16408 })?;
16409 let canonical_root = platform::normalize_canonical_root(canonical_root);
16410 validate_workspace_root(workspace_id, &canonical_root)?;
16411 Ok(canonical_root)
16412 }
16413 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
16414 workspace_id: workspace_id.clone(),
16415 path: root.to_owned(),
16416 message: error.to_string(),
16417 }),
16418 }
16419}
16420
16421fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
16422 let Some(revision) = revision else {
16423 return Ok(());
16424 };
16425 if revision.is_empty()
16426 || revision.len() > MAX_REPOSITORY_PATH_BYTES
16427 || revision.chars().any(char::is_control)
16428 {
16429 return Err(failure(
16430 NodeFailureCode::InvalidRequest,
16431 &format!(
16432 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
16433 ),
16434 ));
16435 }
16436 Ok(())
16437}
16438
16439fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16440 let code = match error.kind {
16441 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
16442 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
16443 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
16444 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
16445 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
16446 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
16447 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
16448 };
16449 failure(code, &error.message)
16450}
16451
16452fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
16453 let code = match error.kind {
16454 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
16455 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
16456 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
16457 NodeFailureCode::BackendOperationFailed
16458 }
16459 StandaloneWorkspaceErrorKind::RecoveryRequired => {
16460 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
16461 }
16462 };
16463 failure(code, error.message)
16464}
16465
16466fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16467 let (code, message) = match error.kind {
16468 GitWorktreeErrorKind::Invalid => (
16469 NodeFailureCode::InvalidRequest,
16470 "managed Git input is invalid",
16471 ),
16472 GitWorktreeErrorKind::NotRepository => (
16473 NodeFailureCode::NotGitRepository,
16474 "managed source is not a Git repository",
16475 ),
16476 GitWorktreeErrorKind::Conflict => (
16477 NodeFailureCode::WorktreeConflict,
16478 "managed Git identity conflicts with existing repository state",
16479 ),
16480 GitWorktreeErrorKind::Protected => (
16481 NodeFailureCode::ManagedWorktreeOwnershipConflict,
16482 "managed worktree ownership validation failed",
16483 ),
16484 GitWorktreeErrorKind::Dirty => (
16485 NodeFailureCode::WorktreeDirty,
16486 "managed worktree contains uncommitted changes",
16487 ),
16488 GitWorktreeErrorKind::Locked => (
16489 NodeFailureCode::WorktreeLocked,
16490 "managed worktree is locked",
16491 ),
16492 GitWorktreeErrorKind::Failed => (
16493 NodeFailureCode::BackendOperationFailed,
16494 "managed Git operation failed",
16495 ),
16496 };
16497 NodeFailure { code, message: message.to_owned() }
16498}
16499
16500fn managed_spawn_receipt(
16501 resolved: &ResolvedSpawnSpec,
16502 incarnation_id: NodeIncarnationId,
16503 session: SessionAddress,
16504 lease: ManagedWorktreeLeaseSnapshot,
16505 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
16506 bundle: Option<ResolvedBundleReceipt>,
16507 context: Option<ResolvedContextPackReceipt>,
16508) -> ManagedWorktreeSpawnReceipt {
16509 let mut managed = resolved.clone();
16510 managed.target.worktree_id = Some(lease.workspace_id.clone());
16511 ManagedWorktreeSpawnReceipt {
16512 spawn: managed.receipt_with_materialization(
16513 incarnation_id,
16514 session,
16515 environment_profile,
16516 bundle,
16517 context,
16518 ),
16519 lease,
16520 }
16521}
16522
16523fn validate_selected_worktree(
16524 source_root: &str,
16525 selected_root: &str,
16526 worktrees: &[NativeGitWorktreeSnapshot],
16527) -> Result<(), NodeFailure> {
16528 if !worktrees
16529 .iter()
16530 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
16531 {
16532 return Err(failure(
16533 NodeFailureCode::WorktreeProtected,
16534 "source workspace is not an authoritative non-bare Git worktree root",
16535 ));
16536 }
16537 if !worktrees.iter().any(|worktree| {
16538 worktree_paths_equal(&worktree.path, selected_root)
16539 && !worktree.is_main
16540 && !worktree.is_bare
16541 }) {
16542 return Err(failure(
16543 NodeFailureCode::WorktreeProtected,
16544 "selected workspace is not an eligible Git worktree in the source repository",
16545 ));
16546 }
16547 Ok(())
16548}
16549
16550fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
16551 let code = match error.kind() {
16552 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16553 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16554 NodeFailureCode::RepositoryPathUnsafe
16555 }
16556 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16557 WorkspaceFileReadErrorKind::NotRegularFile => {
16558 NodeFailureCode::RepositoryFileNotRegular
16559 }
16560 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16561 NodeFailureCode::RepositoryFileReadFailed
16562 }
16563 WorkspaceFileReadErrorKind::RevisionConflict => {
16564 NodeFailureCode::RepositoryFileRevisionConflict
16565 }
16566 WorkspaceFileReadErrorKind::AlreadyExists
16567 | WorkspaceFileReadErrorKind::ParentNotFound
16568 | WorkspaceFileReadErrorKind::ParentNotDirectory
16569 | WorkspaceFileReadErrorKind::Canceled => {
16570 NodeFailureCode::RepositoryFileReadFailed
16571 }
16572 };
16573 failure(code, "repository file read failed")
16574}
16575
16576fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
16577 let code = match error.kind() {
16578 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16579 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16580 NodeFailureCode::RepositoryPathUnsafe
16581 }
16582 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16583 WorkspaceFileReadErrorKind::NotRegularFile => {
16584 NodeFailureCode::RepositoryFileNotRegular
16585 }
16586 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16587 NodeFailureCode::RepositoryFileWriteFailed
16588 }
16589 WorkspaceFileReadErrorKind::RevisionConflict => {
16590 NodeFailureCode::RepositoryFileRevisionConflict
16591 }
16592 WorkspaceFileReadErrorKind::AlreadyExists
16593 | WorkspaceFileReadErrorKind::ParentNotFound
16594 | WorkspaceFileReadErrorKind::ParentNotDirectory
16595 | WorkspaceFileReadErrorKind::Canceled => {
16596 NodeFailureCode::RepositoryFileWriteFailed
16597 }
16598 };
16599 failure(code, "repository file write failed")
16600}
16601
16602async fn settle_workspace_entry_create<T>(
16603 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
16604 commit_state: Arc<AtomicU8>,
16605 deadline: Duration,
16606 timeout_message: &'static str,
16607 task_failure_message: &'static str,
16608) -> Result<T, NodeFailure> {
16609 let joined = match timeout(deadline, &mut task).await {
16610 Ok(joined) => joined,
16611 Err(_) => {
16612 if commit_state
16613 .compare_exchange(
16614 WORKSPACE_ENTRY_CREATE_PENDING,
16615 WORKSPACE_ENTRY_CREATE_CANCELED,
16616 Ordering::AcqRel,
16617 Ordering::Acquire,
16618 )
16619 .is_ok()
16620 {
16621 return Err(failure(
16622 NodeFailureCode::RepositoryEntryCreateTimedOut,
16623 timeout_message,
16624 ));
16625 }
16626 debug_assert_eq!(
16627 commit_state.load(Ordering::Acquire),
16628 WORKSPACE_ENTRY_CREATE_COMMITTING,
16629 );
16630 task.await
16631 }
16632 };
16633 joined
16634 .map_err(|_| {
16635 failure(
16636 NodeFailureCode::RepositoryEntryCreateFailed,
16637 task_failure_message,
16638 )
16639 })?
16640 .map_err(workspace_entry_create_failure)
16641}
16642
16643fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
16644 let code = match error.kind() {
16645 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16646 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16647 NodeFailureCode::RepositoryPathUnsafe
16648 }
16649 WorkspaceFileReadErrorKind::AlreadyExists => {
16650 NodeFailureCode::RepositoryEntryAlreadyExists
16651 }
16652 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
16653 NodeFailureCode::RepositoryParentNotFound
16654 }
16655 WorkspaceFileReadErrorKind::ParentNotDirectory
16656 | WorkspaceFileReadErrorKind::NotRegularFile => {
16657 NodeFailureCode::RepositoryParentNotDirectory
16658 }
16659 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16660 NodeFailureCode::RepositoryEntryCreateFailed
16661 }
16662 WorkspaceFileReadErrorKind::RevisionConflict => {
16663 NodeFailureCode::RepositoryEntryCreateFailed
16664 }
16665 WorkspaceFileReadErrorKind::Canceled => {
16666 NodeFailureCode::RepositoryEntryCreateTimedOut
16667 }
16668 };
16669 failure(code, "repository entry create failed")
16670}
16671
16672fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
16673 let value = digest(&SHA256, bytes)
16674 .as_ref()
16675 .iter()
16676 .map(|byte| format!("{byte:02x}"))
16677 .collect();
16678 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
16679}
16680
16681fn managed_spawn_request_digest_v2(
16682 request: &ManagedWorktreeSpawnRequestV2,
16683) -> Result<String, NodeFailure> {
16684 let encoded = serde_json::to_vec(request).map_err(|_| {
16685 failure(
16686 NodeFailureCode::InvalidRequest,
16687 "managed worktree V2 request could not be canonicalized",
16688 )
16689 })?;
16690 let mut value = String::with_capacity(71);
16691 value.push_str("sha256:");
16692 for byte in digest(&SHA256, &encoded).as_ref() {
16693 use std::fmt::Write as _;
16694 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
16695 }
16696 Ok(value)
16697}
16698
16699fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
16700 let code = match error {
16701 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
16702 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
16703 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
16704 NativeSessionPreviewError::UnsupportedProvider
16705 | NativeSessionPreviewError::PreviewUnavailable
16706 | NativeSessionPreviewError::SessionNotFound
16707 | NativeSessionPreviewError::AmbiguousSession => {
16708 NodeFailureCode::BackendOperationFailed
16709 }
16710 };
16711 failure(code, "native session preview failed")
16712}
16713
16714fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
16715 let code = match error.kind() {
16716 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
16717 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
16718 };
16719 failure(code, "host directory browse failed")
16720}
16721
16722fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
16723 let code = match error {
16724 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
16725 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
16726 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
16727 NodeFailureCode::DeliveryStageConflict
16728 }
16729 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
16730 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
16731 NodeFailureCode::DeliveryChunkOutOfOrder
16732 }
16733 DeliveryStoreError::BlobDigestMismatch => {
16734 NodeFailureCode::DeliveryBlobDigestMismatch
16735 }
16736 DeliveryStoreError::BundleDigestMismatch => {
16737 NodeFailureCode::DeliveryBundleDigestMismatch
16738 }
16739 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
16740 DeliveryStoreError::Unavailable
16741 | DeliveryStoreError::Corrupt
16742 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
16743 };
16744 failure(code, "delivery operation failed")
16745}
16746
16747fn validate_workspace_root(
16748 workspace_id: &WorkspaceId,
16749 canonical_root: &str,
16750) -> Result<(), NodeServerError> {
16751 #[cfg(windows)]
16752 if canonical_root.starts_with(r"\\") {
16753 return Err(NodeServerError::InvalidWorkspaceRoot {
16754 workspace_id: workspace_id.clone(),
16755 path: canonical_root.to_owned(),
16756 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
16757 });
16758 }
16759 if !platform::workspace_root_supported(canonical_root)
16760 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
16761 {
16762 return Err(NodeServerError::InvalidWorkspaceRoot {
16763 workspace_id: workspace_id.clone(),
16764 path: canonical_root.to_owned(),
16765 message: "canonical path must be absolute, bounded, and free of control characters"
16766 .to_owned(),
16767 });
16768 }
16769 Ok(())
16770}
16771
16772fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
16773 NodeFailure {
16774 code,
16775 message: node_failure_category(code).to_owned(),
16776 }
16777}
16778
16779fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
16780 let code = match error {
16781 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
16782 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
16783 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
16784 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
16785 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
16786 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
16787 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
16788 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
16789 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
16790 };
16791 failure(code, "harness MCP proxy operation failed")
16792}
16793
16794static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
16798
16799fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
16806 let trace = launch.trace.as_ref()?;
16807 let trace_dir = std::env::var(&trace.dir_env).ok()?;
16808 if trace_dir.is_empty() {
16809 return None;
16810 }
16811 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
16812 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
16813 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
16814}
16815
16816fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
16817 let remaining = deadline.saturating_duration_since(Instant::now());
16818 if remaining.is_zero() {
16819 Err(failure(
16820 NodeFailureCode::SpawnDeadlineExceeded,
16821 "spawn deadline elapsed",
16822 ))
16823 } else {
16824 Ok(remaining)
16825 }
16826}
16827
16828fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
16829 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
16830 match deadline {
16831 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
16832 None => Ok(bounded),
16833 }
16834}
16835
16836fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
16837 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
16838 failure(
16839 NodeFailureCode::SpawnDeadlineExceeded,
16840 "spawn dispatch exceeded the spawn deadline",
16841 )
16842 } else {
16843 error
16844 }
16845}
16846
16847fn spawn_runtime_capabilities(
16848 capabilities: &SpawnRequiredCapabilities,
16849) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
16850 capabilities
16851 .iter()
16852 .map(|capability| match capability.as_str() {
16853 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
16854 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
16855 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
16856 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
16857 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
16858 }
16859 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
16860 _ => Err(failure(
16861 NodeFailureCode::UnsupportedSpawnCapability,
16862 "spawn required capability is unknown",
16863 )),
16864 })
16865 .collect()
16866}
16867
16868fn persistence_failure(_error: io::Error) -> NodeFailure {
16869 NodeFailure {
16870 code: NodeFailureCode::BackendOperationFailed,
16871 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
16872 }
16873}
16874
16875fn node_failure_category(code: NodeFailureCode) -> &'static str {
16876 match code {
16877 NodeFailureCode::InvalidRequest => "invalid-request",
16878 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
16879 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
16880 NodeFailureCode::ReservationNotFound => "reservation-not-found",
16881 NodeFailureCode::ReservationConflict => "reservation-conflict",
16882 NodeFailureCode::ReservationExpired => "reservation-expired",
16883 NodeFailureCode::BindingMismatch => "binding-mismatch",
16884 NodeFailureCode::NotActivated => "not-activated",
16885 NodeFailureCode::CallNotFound => "call-not-found",
16886 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
16887 NodeFailureCode::ResponseTooLarge => "response-too-large",
16888 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
16889 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
16890 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
16891 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
16892 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
16893 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
16894 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
16895 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
16896 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
16897 NodeFailureCode::Unauthorized => "unauthorized",
16898 NodeFailureCode::ObserverReadOnly => "observer-read-only",
16899 NodeFailureCode::ControllerBusy => "controller-busy",
16900 NodeFailureCode::ControllerRequired => "controller-required",
16901 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
16902 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
16903 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
16904 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
16905 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
16906 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
16907 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
16908 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
16909 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
16910 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
16911 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
16912 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
16913 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
16914 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
16915 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
16916 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
16917 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
16918 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
16919 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
16920 NodeFailureCode::GitReadFailed => "git-read-failed",
16921 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
16922 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
16923 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
16924 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
16925 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
16926 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
16927 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
16928 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
16929 NodeFailureCode::TurnInFlight => "turn-in-flight",
16930 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
16931 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
16932 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
16933 "unsupported-network-allowlist-mapping"
16934 }
16935 NodeFailureCode::BrowserStationProbeUnavailable => {
16936 "browser-station-probe-unavailable"
16937 }
16938 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
16939 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
16940 NodeFailureCode::EnvironmentProfileBindingMismatch => {
16941 "environment-profile-binding-mismatch"
16942 }
16943 NodeFailureCode::UnknownBundle => "unknown-bundle",
16944 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
16945 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
16946 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
16947 NodeFailureCode::ContextPackBusy => "context-pack-busy",
16948 NodeFailureCode::ContextPackMaterializationFailed => {
16949 "context-pack-materialization-failed"
16950 }
16951 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
16952 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
16953 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
16954 NodeFailureCode::WorkspaceBusy => "workspace-busy",
16955 NodeFailureCode::LastWorkspace => "last-workspace",
16956 NodeFailureCode::NotGitRepository => "not-git-repository",
16957 NodeFailureCode::WorktreeConflict => "worktree-conflict",
16958 NodeFailureCode::WorktreeProtected => "worktree-protected",
16959 NodeFailureCode::WorktreeDirty => "worktree-dirty",
16960 NodeFailureCode::WorktreeLocked => "worktree-locked",
16961 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
16962 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
16963 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
16964 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
16965 "managed-worktree-profile-revision-mismatch"
16966 }
16967 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
16968 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
16969 "standalone-workspace-recovery-required"
16970 }
16971 NodeFailureCode::UnknownSession => "unknown-session",
16972 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
16973 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
16974 NodeFailureCode::SessionRecordBusy => "session-record-busy",
16975 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
16976 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
16977 NodeFailureCode::WorkspaceRegistrationRequired => {
16978 "workspace-registration-required"
16979 }
16980 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
16981 NodeFailureCode::StaleGeneration => "stale-generation",
16982 NodeFailureCode::BackendBusy => "backend-busy",
16983 NodeFailureCode::BackendDisconnected => "backend-disconnected",
16984 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
16985 NodeFailureCode::ShuttingDown => "shutting-down",
16986 }
16987}
16988
16989fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
16990 NodeServerError::DurableState(io::Error::new(error.kind(), category))
16991}
16992
16993fn durable_state_load_error(error: io::Error) -> NodeServerError {
16994 let category = match session_registry::state_load_refusal(&error) {
16995 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
16996 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
16997 }
16998 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
16999 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17000 }
17001 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17002 DURABLE_STATE_CONFLICT_ERROR
17003 }
17004 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17005 };
17006 durable_state_server_error(error, category)
17007}
17008
17009fn unix_time_ms() -> u64 {
17010 std::time::SystemTime::now()
17011 .duration_since(std::time::UNIX_EPOCH)
17012 .unwrap_or_default()
17013 .as_millis()
17014 .min(u64::MAX as u128) as u64
17015}
17016
17017fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17018 if record.provider_session.is_some() {
17019 ManagedSessionState::Dormant
17020 } else {
17021 ManagedSessionState::Unavailable
17022 }
17023}
17024
17025fn active_record_state(
17026 identity_admitted: bool,
17027 provider_session_is_known: bool,
17028) -> ManagedSessionState {
17029 if identity_admitted && !provider_session_is_known {
17030 ManagedSessionState::IdentityPending
17031 } else {
17032 ManagedSessionState::Live
17033 }
17034}
17035
17036fn managed_worktree_record_holders(
17037 records: &[ManagedSessionRecord],
17038) -> Vec<(SessionRecordId, WorkspaceId)> {
17039 records
17040 .iter()
17041 .filter(|record| record.provider_session.is_some())
17042 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17043 .collect()
17044}
17045
17046fn managed_spawn_record_ownership(
17047 binding: &SessionBinding,
17048 effective_runtime_policy: ProviderRuntimePolicy,
17049) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17050 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17051 if effective_runtime_policy.provider_session_identity {
17052 (binding.record_id.clone(), None)
17053 } else {
17054 (None, binding.record_id.clone())
17055 }
17056}
17057
17058#[derive(Debug, Error)]
17059pub enum NodeServerError {
17060 #[error("node endpoint must be a bounded absolute local transport path")]
17061 InvalidEndpoint,
17062 #[error("node access token must contain 1..=4096 bytes")]
17063 InvalidAccessToken,
17064 #[error("node HTTP observer must listen on a loopback address: {0}")]
17065 InvalidApiListen(std::net::SocketAddr),
17066 #[error("node browser bridge must listen on a loopback address: {0}")]
17067 InvalidBridgeListen(std::net::SocketAddr),
17068 #[error("node browser bridge token must contain 1..=4096 bytes")]
17069 InvalidBridgeToken,
17070 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17071 BridgeUnderlayRequiresBridgeListen,
17072 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17073 BridgeUnderlayRequiresBridgeToken,
17074 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17075 InvalidBridgeUnderlayToken,
17076 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17077 InvalidBridgeUnderlayKey,
17078 #[error("node call-home relay must be a loopback address with a nonzero port: {0}")]
17079 InvalidCallHome(std::net::SocketAddr),
17080 #[error("node could not reach its call-home relay: {0}")]
17081 CallHomeTransport(#[source] std::io::Error),
17082 #[error("node durable state path must be an absolute file path: {0}")]
17083 InvalidStatePath(String),
17084 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17085 InvalidHarnessMcpHelper,
17086 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17087 InvalidNetworkAllowlistCatalogPath(String),
17088 #[error("network allowlist catalog could not be read: {0}")]
17089 NetworkAllowlistCatalogIo(#[source] std::io::Error),
17090 #[error("network allowlist catalog line {line}: {message}")]
17091 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17092 #[error("network allowlist catalog: {0}")]
17093 InvalidNetworkAllowlistCatalog(String),
17094 #[error("network allowlist catalog exceeds the {max}-entry limit")]
17095 NetworkAllowlistCatalogCapacity { max: usize },
17096 #[error("the local state directory is unavailable; supply an explicit state path")]
17097 LocalStateDirectoryUnavailable,
17098 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17099 LocalRuntimeDirectoryUnavailable,
17100 #[error("node durable state failed: {0}")]
17101 DurableState(io::Error),
17102 #[error("node requires at least one configured workspace")]
17103 NoWorkspaces,
17104 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17105 InvalidWorkspaceRoot {
17106 workspace_id: WorkspaceId,
17107 path: String,
17108 message: String,
17109 },
17110 #[error("duplicate workspace ID: {0}")]
17111 DuplicateWorkspaceId(WorkspaceId),
17112 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17113 DuplicateWorkspaceRoot {
17114 first: WorkspaceId,
17115 second: WorkspaceId,
17116 root: String,
17117 },
17118 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17119 ManagedWorktreeProfileRequired(WorkspaceId),
17120 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17121 ManagedWorktreeProfileModeMismatch(WorkspaceId),
17122 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17123 DuplicateManagedWorktreeProfile {
17124 workspace_id: WorkspaceId,
17125 profile_id: WorktreeProfileId,
17126 },
17127 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17128 ManagedWorktreeProfileCapacity {
17129 workspace_id: WorkspaceId,
17130 max: usize,
17131 },
17132 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17133 InvalidManagedWorktreeProfile {
17134 workspace_id: WorkspaceId,
17135 profile_id: WorktreeProfileId,
17136 message: String,
17137 },
17138 #[error("duplicate node environment profile: {0}")]
17139 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17140 #[error("node environment profile provider is unavailable: {0}")]
17141 EnvironmentProfileProviderUnavailable(AgentId),
17142 #[error("node environment profile capacity is {max}")]
17143 EnvironmentProfileCapacity { max: usize },
17144 #[error("node environment profiles reuse native profile ID '{0}'")]
17145 DuplicateNativeEnvironmentProfile(String),
17146 #[error("node native environment profile is invalid: {0}")]
17147 NativeEnvironmentProfile(String),
17148 #[error("node session-environment materialization root must be absolute")]
17149 InvalidSessionEnvironmentRoot,
17150 #[error("node session-environment materializer is unavailable")]
17151 SessionEnvironmentMaterializerRequired,
17152 #[error("node session-environment materializer failed to initialize")]
17153 SessionEnvironmentMaterializer,
17154 #[error("node bundle catalog is invalid: {0}")]
17155 BundleCatalog(String),
17156 #[error("node delivery store failed to initialize: {0}")]
17157 DeliveryStore(String),
17158 #[error("node context pack store failed to initialize")]
17159 ContextPackStore,
17160 #[error("active agent registry failed: {0}")]
17161 Registry(String),
17162 #[error("node provider contract manifest is invalid: {0}")]
17163 ProviderContractManifest(String),
17164 #[error("named pipe I/O failed: {0}")]
17165 Io(#[from] io::Error),
17166 #[error("node HTTP observer failed: {0}")]
17167 HttpApi(io::Error),
17168 #[error("node browser bridge failed: {0}")]
17169 BridgeApi(io::Error),
17170 #[error(transparent)]
17171 Frame(#[from] FrameError),
17172 #[error("node handshake failed: {0}")]
17173 Handshake(String),
17174 #[error("node authentication frame was not received before the bounded deadline")]
17175 AuthenticationTimedOut,
17176 #[error("node authentication primitive failed: {0}")]
17177 Authentication(String),
17178 #[error("node incarnation identity generation failed: {0}")]
17179 IncarnationIdentity(String),
17180 #[error("node pre-authentication limiter is closed")]
17181 PreauthClosed,
17182 #[error("node authenticated connection limit was reached")]
17183 AuthenticatedConnectionLimit,
17184 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17185 SignalStreamClosed,
17186 #[error("node stop-all dispatch failed: {0:?}")]
17187 ShutdownDispatch(NodeFailure),
17188 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17189 ShutdownTimedOut { active_native_sessions: usize },
17190}
17191
17192impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17193 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17194 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17195 match error {
17196 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17197 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17198 E::InvalidEntry { line, message } => {
17199 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17200 }
17201 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17202 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17203 }
17204 }
17205}
17206
17207#[cfg(test)]
17208mod agent_stream_blocked_tests {
17209 use super::*;
17210 use gate4agent_types::{
17211 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17212 ProviderSource,
17213 };
17214
17215 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17216 ProviderSource {
17217 family,
17218 binding: AdapterBinding::new(
17219 AdapterId::new(adapter).unwrap(),
17220 "fixture/v1",
17221 AdapterVerification::SyntheticFixture,
17222 )
17223 .unwrap(),
17224 }
17225 }
17226
17227 fn provider_control_event_at(
17228 family: AdapterFamily,
17229 adapter: &str,
17230 source_sequence: u64,
17231 provider_event: ProviderEvent,
17232 ) -> ControlEvent {
17233 ControlEvent {
17234 sequence: 41,
17235 command_id: None,
17236 instance_id: AgentInstanceId(7),
17237 generation: SessionGeneration(3),
17238 event: ControlEventKind::ProviderEvent {
17239 sequence: 8,
17240 source: provider_source(family, adapter),
17241 source_sequence,
17242 event: provider_event,
17243 },
17244 }
17245 }
17246
17247 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17248 provider_control_event_at(family, "fixture", 9, provider_event)
17249 }
17250
17251 fn denied_host_request(
17252 by: ProviderHostDecisionAuthority,
17253 reason: Option<String>,
17254 ) -> ControlEvent {
17255 provider_control_event_at(
17256 AdapterFamily::Acp,
17257 "claude-code",
17258 2,
17259 ProviderEvent::HostRequestObserved {
17260 method: "terminal/create".to_owned(),
17261 params_json: String::new(),
17262 decision: ProviderHostRequestDecision::Denied { by },
17263 outcome: ProviderHostRequestOutcome::Executed,
17264 reason,
17265 },
17266 )
17267 }
17268
17269 fn blocked(
17270 event: &ControlEvent,
17271 ) -> (
17272 Option<String>,
17273 String,
17274 BlockAuthorityV1,
17275 Option<String>,
17276 String,
17277 Option<String>,
17278 ) {
17279 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17280 chunk.validate().expect("minted chunk must validate");
17281 let AgentStreamChunkKindV1::Blocked {
17282 correlation_id,
17283 tool_class,
17284 authority,
17285 reason_kind,
17286 reason,
17287 help,
17288 } = chunk.kind
17289 else {
17290 panic!("expected AgentStreamChunkKindV1::Blocked");
17291 };
17292 (correlation_id, tool_class, authority, reason_kind, reason, help)
17293 }
17294
17295 #[test]
17299 fn the_enabled_provider_matrix_is_exact() {
17300 let registry = active_registry().unwrap();
17301 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
17302 assert_eq!(
17303 providers
17304 .iter()
17305 .map(|contract| contract.provider.as_str())
17306 .collect::<Vec<_>>(),
17307 vec!["claude", "codex", "grok", "kimi"],
17308 );
17309 assert!(adapters.iter().all(|contract| {
17310 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
17311 }));
17312 }
17313
17314 #[test]
17317 fn gate_denied_host_request_mints_a_blocked_chunk() {
17318 let gate_text =
17319 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
17320 let event = denied_host_request(
17321 ProviderHostDecisionAuthority::Gate,
17322 Some(gate_text.to_owned()),
17323 );
17324 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
17325 assert_eq!(chunk.source_sequence, 2);
17326 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
17327 assert_eq!(authority, BlockAuthorityV1::HostGate);
17328 assert_eq!(reason, gate_text);
17329 assert_eq!(correlation_id, None);
17330 assert_eq!(reason_kind, None);
17331 assert_eq!(help, None);
17332 assert_eq!(tool_class, "Write");
17333 }
17334
17335 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.";
17336
17337 #[test]
17343 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
17344 let event = provider_control_event(
17345 AdapterFamily::Acp,
17346 ProviderEvent::ToolCompleted {
17347 id: "toolu_9".to_owned(),
17348 output: CLASSIFIER_SAMPLE.to_owned(),
17349 is_error: true,
17350 duration_ms: None,
17351 agent_id: None,
17352 non_execution_kind: Some("permission-rule".to_owned()),
17353 },
17354 );
17355 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
17356 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
17357 assert_eq!(reason, "Blocked by classifier");
17358 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
17359 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
17360 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
17361 }
17362
17363 #[test]
17368 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
17369 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.";
17370 let event = provider_control_event(
17371 AdapterFamily::Acp,
17372 ProviderEvent::SessionEnded {
17373 result: "JSON-RPC error -32603: Internal error".to_owned(),
17374 cost_usd: None,
17375 is_error: true,
17376 stop_reason: Some(ProviderStopReason::ProviderError {
17377 code: -32603,
17378 message: message.to_owned(),
17379 vendor_code: Some("usageLimitExceeded".to_owned()),
17380 }),
17381 },
17382 );
17383 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
17384 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
17385 assert_eq!(reason, message);
17386 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
17387 assert_eq!(tool_class, "Session");
17388 }
17389
17390 #[test]
17393 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
17394 let event = provider_control_event(
17395 AdapterFamily::Acp,
17396 ProviderEvent::SessionEnded {
17397 result: "refusal".to_owned(),
17398 cost_usd: None,
17399 is_error: true,
17400 stop_reason: Some(ProviderStopReason::Refusal),
17401 },
17402 );
17403 let (_, _, authority, _, _, _) = blocked(&event);
17404 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
17405 }
17406
17407 #[test]
17411 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
17412 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
17413 let event = provider_control_event(
17414 AdapterFamily::Acp,
17415 ProviderEvent::ToolCompleted {
17416 id: "toolu_1".to_owned(),
17417 output: "command exited with status 1".to_owned(),
17418 is_error: true,
17419 duration_ms: Some(12),
17420 agent_id: None,
17421 non_execution_kind,
17422 },
17423 );
17424 assert!(agent_stream_chunk(&event).is_none());
17425 }
17426 let narrative = provider_control_event(
17427 AdapterFamily::Acp,
17428 ProviderEvent::ToolCompleted {
17429 id: "toolu_2".to_owned(),
17430 output: "the harness blocked the write, approval needed".to_owned(),
17431 is_error: false,
17432 duration_ms: Some(3),
17433 agent_id: None,
17434 non_execution_kind: None,
17435 },
17436 );
17437 assert!(agent_stream_chunk(&narrative).is_none());
17438 }
17439
17440 #[test]
17444 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
17445 for (decision, outcome) in [
17446 (
17447 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17448 ProviderHostRequestOutcome::Executed,
17449 ),
17450 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
17451 (
17452 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17453 ProviderHostRequestOutcome::Failed {
17454 error: "terminal/create spawn failed: os error 3".to_owned(),
17455 },
17456 ),
17457 ] {
17458 let event = provider_control_event_at(
17459 AdapterFamily::Acp,
17460 "claude-code",
17461 3,
17462 ProviderEvent::HostRequestObserved {
17463 method: "fs/read_text_file".to_owned(),
17464 params_json: String::new(),
17465 decision: decision.clone(),
17466 outcome,
17467 reason: None,
17468 },
17469 );
17470 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
17471 }
17472 }
17473
17474 #[test]
17478 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
17479 for (by, expected_authority, expected_reason) in [
17480 (
17481 ProviderHostDecisionAuthority::Operator,
17482 BlockAuthorityV1::Operator,
17483 "declined by the operator",
17484 ),
17485 (
17486 ProviderHostDecisionAuthority::DeadlinePolicy,
17487 BlockAuthorityV1::HostDeadline,
17488 "denied by host policy after the operator deadline expired",
17489 ),
17490 ] {
17491 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
17492 assert_eq!(authority, expected_authority);
17493 assert_eq!(reason, expected_reason);
17494 }
17495 }
17496
17497 #[test]
17501 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
17502 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
17503 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
17504 ProviderHostDecisionAuthority::Policy,
17505 Some(oversized),
17506 ));
17507 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
17508 }
17509}
17510
17511#[cfg(test)]
17512mod standalone_capability_platform_tests {
17513 use super::*;
17514
17515 #[test]
17516 fn standalone_workspace_capability_requires_windows_identity_guards() {
17517 let capabilities = baseline_capabilities().unwrap();
17518 let advertised = capabilities.iter().any(|capability| {
17519 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
17520 });
17521 assert_eq!(advertised, cfg!(windows));
17522
17523 let request = NodeRequest::CreateStandaloneWorkspace {
17524 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
17525 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
17526 initial_branch: None,
17527 };
17528 assert_eq!(
17529 request_uses_unnegotiated_capability(&request, &capabilities),
17530 !cfg!(windows),
17531 );
17532 }
17533}
17534
17535
17536#[cfg(test)]
17537mod station_knobs_resolve_tests {
17538 use super::*;
17539 use gate4agent_catalog::EnvMutation;
17540 use gate4agent_runtime_native::{
17541 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
17542 NativeLaunchProfileId,
17543 };
17544 use std::ffi::OsString;
17545 use std::sync::Arc;
17546
17547 struct EmptyEnvironmentResolver;
17548
17549 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
17550 fn resolve_child_environment(
17551 &self,
17552 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
17553 Ok(vec![EnvMutation {
17554 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
17555 value: None,
17556 }])
17557 }
17558 }
17559
17560 fn agent(value: &str) -> AgentId {
17561 AgentId::new(value).unwrap()
17562 }
17563
17564 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
17565 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
17566 let profile_revision =
17567 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
17568 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
17569 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
17570 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
17571 provider: agent("claude"),
17572 mode: SessionMode::Pty,
17573 terminal_size: gate4agent_types::TerminalSize {
17574 rows: 24,
17575 columns: 80,
17576 },
17577 prompt: None,
17578 bundle_id: None,
17579 context_id: None,
17580 environment_profile_id: Some(profile_id.clone()),
17581 };
17582 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
17583 let catalog = active_registry().unwrap();
17584 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
17585 let control = runtime.native_launch_profile_control();
17586 let node_profile = NodeEnvironmentProfile::new(
17587 profile_id.clone(),
17588 profile_revision,
17589 agent("claude"),
17590 [NativeLaunchProfile::new(
17591 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
17592 agent("claude"),
17593 TransportKind::Pty,
17594 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
17595 Arc::new(EmptyEnvironmentResolver),
17596 )
17597 .unwrap()],
17598 )
17599 .unwrap();
17600 let (binding, native_profiles, _materialization) = node_profile.into_parts();
17601 for native_profile in native_profiles {
17602 runtime.upsert_native_launch_profile(native_profile).unwrap();
17603 }
17604 let workspace = WorkspaceConfig::new(
17605 WorkspaceId::new("primary").unwrap(),
17606 std::env::current_dir().unwrap(),
17607 )
17608 .unwrap();
17609 let shared = NodeShared::new_with_incarnation(
17610 handle,
17611 "fixture-token".to_owned(),
17612 NodeId::new("node-station-knobs-test").unwrap(),
17613 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
17614 vec![workspace],
17615 vec![agent("claude")],
17616 ProviderRuntimeStatuses::default(),
17617 None,
17618 Vec::new(),
17619 Vec::new(),
17620 spawn_profiles,
17621 Some(control),
17622 None,
17623 None,
17624 None,
17625 Vec::new(),
17626 Vec::new(),
17627 Vec::new(),
17628 Vec::new(),
17629 None,
17630 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
17631 );
17632 shared
17633 .environment_profiles
17634 .write()
17635 .unwrap_or_else(|poisoned| poisoned.into_inner())
17636 .insert(binding.id.clone(), binding);
17637 (shared, spawn_defaults)
17638 }
17639
17640 #[test]
17643 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
17644 let (shared, defaults) = station_knobs_fixture();
17645 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
17646 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17647 let spec = crate::protocol::SpawnSpec {
17648 target: crate::protocol::SpawnTarget {
17649 node_id: shared.node_id.clone(),
17650 workspace_id: WorkspaceId::new("primary").unwrap(),
17651 worktree_id: None,
17652 },
17653 profile_id: defaults.profile_id.clone(),
17654 expected_profile_revision: defaults.revision.clone(),
17655 overrides: crate::protocol::SpawnOverrides {
17656 network_allowlist: Some(unknown),
17657 browser_profile_id: Some(browser),
17658 ..crate::protocol::SpawnOverrides::default()
17659 },
17660 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17661 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17662 "station-knobs-unknown-allowlist",
17663 )
17664 .unwrap(),
17665 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17666 };
17667 let resolved = spec.resolve(&defaults).unwrap();
17668 let failure = shared
17669 .resolve_environment_profile(&resolved)
17670 .expect_err("unknown allowlist must refuse");
17671 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
17672 }
17673
17674 #[test]
17680 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
17681 let (shared, defaults) = station_knobs_fixture();
17682 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
17683 #[cfg(not(feature = "dig2-station-probe"))]
17684 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17685 shared
17686 .network_allowlist_catalog
17687 .write()
17688 .unwrap_or_else(|poisoned| poisoned.into_inner())
17689 .insert_id(allowlist.clone())
17690 .unwrap();
17691 let spec = crate::protocol::SpawnSpec {
17692 target: crate::protocol::SpawnTarget {
17693 node_id: shared.node_id.clone(),
17694 workspace_id: WorkspaceId::new("primary").unwrap(),
17695 worktree_id: None,
17696 },
17697 profile_id: defaults.profile_id.clone(),
17698 expected_profile_revision: defaults.revision.clone(),
17699 overrides: crate::protocol::SpawnOverrides {
17700 network_allowlist: Some(allowlist.clone()),
17701 #[cfg(not(feature = "dig2-station-probe"))]
17702 browser_profile_id: Some(browser.clone()),
17703 ..crate::protocol::SpawnOverrides::default()
17704 },
17705 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17706 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
17707 .unwrap(),
17708 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17709 };
17710 let resolved = spec.resolve(&defaults).unwrap();
17711 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
17712 #[cfg(not(feature = "dig2-station-probe"))]
17713 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
17714 let receipt = shared
17715 .resolve_environment_profile(&resolved)
17716 .expect("resolve ok")
17717 .expect("env profile present");
17718 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
17719 #[cfg(not(feature = "dig2-station-probe"))]
17720 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
17721 #[cfg(feature = "dig2-station-probe")]
17722 assert_eq!(receipt.browser_profile_id, None);
17723 assert_eq!(receipt.profile_id.as_str(), "local-claude");
17724
17725 let omitted = crate::protocol::SpawnSpec {
17726 overrides: crate::protocol::SpawnOverrides::default(),
17727 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
17728 .unwrap(),
17729 ..spec
17730 };
17731 let resolved_omit = omitted.resolve(&defaults).unwrap();
17732 let bare = shared
17733 .resolve_environment_profile(&resolved_omit)
17734 .expect("resolve ok")
17735 .expect("env profile present");
17736 assert_eq!(bare.network_allowlist, None);
17737 assert_eq!(bare.browser_profile_id, None);
17738 }
17739
17740 #[cfg(feature = "dig2-station-probe")]
17745 #[test]
17746 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
17747 let (shared, defaults) = station_knobs_fixture();
17748 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17749 let spec = crate::protocol::SpawnSpec {
17750 target: crate::protocol::SpawnTarget {
17751 node_id: shared.node_id.clone(),
17752 workspace_id: WorkspaceId::new("primary").unwrap(),
17753 worktree_id: None,
17754 },
17755 profile_id: defaults.profile_id.clone(),
17756 expected_profile_revision: defaults.revision.clone(),
17757 overrides: crate::protocol::SpawnOverrides {
17758 browser_profile_id: Some(browser),
17759 ..crate::protocol::SpawnOverrides::default()
17760 },
17761 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17762 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17763 "station-knobs-probe-refuse",
17764 )
17765 .unwrap(),
17766 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17767 };
17768 let resolved = spec.resolve(&defaults).unwrap();
17769 let failure = shared
17770 .resolve_environment_profile(&resolved)
17771 .expect_err("browser_profile with failing probe must refuse");
17772 #[cfg(windows)]
17773 {
17774 assert!(
17775 matches!(
17776 failure.code,
17777 NodeFailureCode::BrowserStationUnreachable
17778 | NodeFailureCode::BrowserStationProbeUnavailable
17779 ),
17780 "unexpected code {:?}",
17781 failure.code
17782 );
17783 }
17784 #[cfg(not(windows))]
17785 {
17786 assert_eq!(
17787 failure.code,
17788 NodeFailureCode::BrowserStationProbeUnavailable
17789 );
17790 }
17791 }
17792
17793 #[cfg(feature = "dig2-station-probe")]
17795 #[test]
17796 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
17797 let (shared, defaults) = station_knobs_fixture();
17798 let spec = crate::protocol::SpawnSpec {
17799 target: crate::protocol::SpawnTarget {
17800 node_id: shared.node_id.clone(),
17801 workspace_id: WorkspaceId::new("primary").unwrap(),
17802 worktree_id: None,
17803 },
17804 profile_id: defaults.profile_id.clone(),
17805 expected_profile_revision: defaults.revision.clone(),
17806 overrides: crate::protocol::SpawnOverrides::default(),
17807 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17808 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17809 "station-knobs-probe-omit",
17810 )
17811 .unwrap(),
17812 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17813 };
17814 let resolved = spec.resolve(&defaults).unwrap();
17815 let receipt = shared
17816 .resolve_environment_profile(&resolved)
17817 .expect("omit browser_profile must not probe")
17818 .expect("env profile present");
17819 assert_eq!(receipt.browser_profile_id, None);
17820 }
17821
17822 #[cfg(feature = "dig2-station-probe")]
17826 #[test]
17827 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
17828 let (shared, _defaults) = station_knobs_fixture();
17829 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17830 let workspace_id = WorkspaceId::new("primary").unwrap();
17831 let address_a = SessionAddress {
17832 workspace_id: workspace_id.clone(),
17833 session: SessionKey {
17834 instance_id: AgentInstanceId(101),
17835 generation: SessionGeneration(1),
17836 },
17837 };
17838 let address_b = SessionAddress {
17839 workspace_id: workspace_id.clone(),
17840 session: SessionKey {
17841 instance_id: AgentInstanceId(102),
17842 generation: SessionGeneration(1),
17843 },
17844 };
17845 let guard_a = crate::dig2_station_lease::acquire_lease(
17846 &shared.browser_station_leases,
17847 profile.clone(),
17848 address_a.session.instance_id,
17849 1,
17850 )
17851 .expect("first lease");
17852 let busy = crate::dig2_station_lease::acquire_lease(
17853 &shared.browser_station_leases,
17854 profile.clone(),
17855 address_b.session.instance_id,
17856 2,
17857 )
17858 .expect_err("second holder must refuse");
17859 assert!(matches!(
17860 busy,
17861 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
17862 ));
17863 shared.bind_session_with_policy(
17865 &address_a,
17866 ProviderRuntimePolicy::raw_pty(),
17867 None,
17868 );
17869 guard_a.retain();
17870 assert!(shared.remove_binding(&address_a).is_some());
17871 crate::dig2_station_lease::acquire_lease(
17872 &shared.browser_station_leases,
17873 profile,
17874 address_b.session.instance_id,
17875 3,
17876 )
17877 .expect("reacquire after clear")
17878 .retain();
17879 assert!(
17880 crate::dig2_station_lease::release_lease_for_holder(
17881 &shared.browser_station_leases,
17882 address_b.session.instance_id,
17883 )
17884 );
17885 }
17886
17887 #[cfg(not(feature = "dig2-station-probe"))]
17890 #[test]
17891 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
17892 let (shared, defaults) = station_knobs_fixture();
17893 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17894 let spec = crate::protocol::SpawnSpec {
17895 target: crate::protocol::SpawnTarget {
17896 node_id: shared.node_id.clone(),
17897 workspace_id: WorkspaceId::new("primary").unwrap(),
17898 worktree_id: None,
17899 },
17900 profile_id: defaults.profile_id.clone(),
17901 expected_profile_revision: defaults.revision.clone(),
17902 overrides: crate::protocol::SpawnOverrides {
17903 browser_profile_id: Some(browser.clone()),
17904 ..crate::protocol::SpawnOverrides::default()
17905 },
17906 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17907 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
17908 .unwrap(),
17909 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17910 };
17911 let resolved = spec.resolve(&defaults).unwrap();
17912 let receipt = shared
17913 .resolve_environment_profile(&resolved)
17914 .expect("stub resolve")
17915 .expect("env profile");
17916 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
17917 }
17918
17919
17920 #[test]
17923 fn network_allowlist_catalog_loads_from_temp_file() {
17924 let unique = std::time::SystemTime::now()
17925 .duration_since(std::time::UNIX_EPOCH)
17926 .unwrap()
17927 .as_nanos();
17928 let path = std::env::temp_dir().join(format!(
17929 "gate4agent-node-allowlist-config-{}-{unique}.txt",
17930 std::process::id(),
17931 ));
17932 std::fs::write(
17933 &path,
17934 "# opaque station allowlist ids\negress-default\nlab-egress\n",
17935 )
17936 .unwrap();
17937 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
17938 .expect("temp catalog file must load");
17939 let _ = std::fs::remove_file(&path);
17940 assert_eq!(loaded.len(), 2);
17941 let (shared, defaults) = station_knobs_fixture();
17942 {
17943 let mut catalog = shared
17944 .network_allowlist_catalog
17945 .write()
17946 .unwrap_or_else(|poisoned| poisoned.into_inner());
17947 *catalog = loaded;
17948 }
17949 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
17950 let spec = crate::protocol::SpawnSpec {
17951 target: crate::protocol::SpawnTarget {
17952 node_id: shared.node_id.clone(),
17953 workspace_id: WorkspaceId::new("primary").unwrap(),
17954 worktree_id: None,
17955 },
17956 profile_id: defaults.profile_id.clone(),
17957 expected_profile_revision: defaults.revision.clone(),
17958 overrides: crate::protocol::SpawnOverrides {
17959 network_allowlist: Some(allowlist.clone()),
17960 ..crate::protocol::SpawnOverrides::default()
17961 },
17962 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17963 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17964 "station-knobs-catalog-file",
17965 )
17966 .unwrap(),
17967 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17968 };
17969 let resolved = spec.resolve(&defaults).unwrap();
17970 let receipt = shared
17971 .resolve_environment_profile(&resolved)
17972 .expect("registered via file load")
17973 .expect("env profile present");
17974 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
17975 }
17976
17977 #[test]
17980 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
17981 let (shared, _defaults) = station_knobs_fixture();
17982 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
17983 shared
17984 .network_allowlist_catalog
17985 .write()
17986 .unwrap_or_else(|poisoned| poisoned.into_inner())
17987 .insert_id(allowlist.clone())
17988 .unwrap();
17989 let snapshot = shared.snapshot();
17990 let inventory = snapshot
17991 .launch_inventory
17992 .expect("launch inventory present");
17993 let ids = inventory
17994 .network_allowlists
17995 .expect("non-empty catalog surfaces ids");
17996 assert_eq!(ids, vec![allowlist]);
17997 }
17998
17999 #[test]
18003 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18004 let (shared, defaults) = station_knobs_fixture();
18005 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18006 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18007 id: allowlist.clone(),
18008 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18009 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18010 peer: "127.0.0.1:443".to_owned(),
18011 }],
18012 provider_native: Some(
18013 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18014 codex_network_access: Some(true),
18015 },
18016 ),
18017 };
18018 shared
18019 .network_allowlist_catalog
18020 .write()
18021 .unwrap_or_else(|poisoned| poisoned.into_inner())
18022 .insert(entry)
18023 .unwrap();
18024 let spec = crate::protocol::SpawnSpec {
18025 target: crate::protocol::SpawnTarget {
18026 node_id: shared.node_id.clone(),
18027 workspace_id: WorkspaceId::new("primary").unwrap(),
18028 worktree_id: None,
18029 },
18030 profile_id: defaults.profile_id.clone(),
18031 expected_profile_revision: defaults.revision.clone(),
18032 overrides: crate::protocol::SpawnOverrides {
18033 network_allowlist: Some(allowlist),
18034 ..crate::protocol::SpawnOverrides::default()
18035 },
18036 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18037 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18038 "station-knobs-unsupported-mapping",
18039 )
18040 .unwrap(),
18041 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18042 };
18043 let resolved = spec.resolve(&defaults).unwrap();
18044 assert_eq!(resolved.provider.as_str(), "claude");
18045 let failure = shared
18046 .resolve_environment_profile(&resolved)
18047 .expect_err("claude + codex_network_access must refuse");
18048 assert_eq!(
18049 failure.code,
18050 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18051 );
18052 }
18053
18054 #[test]
18058 fn resolve_codex_network_access_overlay_moderate_only() {
18059 let (shared, defaults) = station_knobs_fixture();
18060 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18061 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18062 id: allowlist.clone(),
18063 permits: Vec::new(),
18064 provider_native: Some(
18065 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18066 codex_network_access: Some(true),
18067 },
18068 ),
18069 };
18070 shared
18071 .network_allowlist_catalog
18072 .write()
18073 .unwrap_or_else(|poisoned| poisoned.into_inner())
18074 .insert(entry)
18075 .unwrap();
18076
18077 let overlay = shared
18078 .resolve_codex_network_access_overlay_args(
18079 &agent("codex"),
18080 SessionMode::Pty,
18081 ApprovalLevel::Moderate,
18082 Some(&allowlist),
18083 )
18084 .expect("Moderate Codex maps network_access");
18085 assert_eq!(
18086 overlay,
18087 vec![
18088 std::ffi::OsString::from("-c"),
18089 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18090 ]
18091 );
18092
18093 let acp = shared
18094 .resolve_codex_network_access_overlay_args(
18095 &agent("codex"),
18096 SessionMode::Acp,
18097 ApprovalLevel::Moderate,
18098 Some(&allowlist),
18099 )
18100 .unwrap();
18101 assert_eq!(acp, overlay);
18102
18103 let inline = shared
18104 .resolve_codex_network_access_overlay_args(
18105 &agent("codex"),
18106 SessionMode::Inline,
18107 ApprovalLevel::Moderate,
18108 Some(&allowlist),
18109 )
18110 .unwrap_err();
18111 assert_eq!(
18112 inline.code,
18113 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18114 );
18115
18116 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18117 let spec = crate::protocol::SpawnSpec {
18118 target: crate::protocol::SpawnTarget {
18119 node_id: shared.node_id.clone(),
18120 workspace_id: WorkspaceId::new("primary").unwrap(),
18121 worktree_id: None,
18122 },
18123 profile_id: defaults.profile_id.clone(),
18124 expected_profile_revision: defaults.revision.clone(),
18125 overrides: crate::protocol::SpawnOverrides {
18126 provider: crate::protocol::SpawnOverride::Set {
18127 value: agent("codex"),
18128 },
18129 approval_level: Some(level),
18130 network_allowlist: Some(allowlist.clone()),
18131 ..crate::protocol::SpawnOverrides::default()
18132 },
18133 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18134 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18135 "codex-net-refuse-{}",
18136 match level {
18137 ApprovalLevel::ReadOnly => "ro",
18138 ApprovalLevel::FullAuto => "fa",
18139 _ => "other",
18140 }
18141 ))
18142 .unwrap(),
18143 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18144 };
18145 let resolved = spec.resolve(&defaults).unwrap();
18146 let failure = shared
18147 .resolve_environment_profile(&resolved)
18148 .expect_err("RO/FullAuto must refuse");
18149 assert_eq!(
18150 failure.code,
18151 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18152 );
18153 }
18154 }
18155
18156 #[test]
18160 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18161 let (shared, _defaults) = station_knobs_fixture();
18162 let allowlist =
18163 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18164 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18165 id: allowlist.clone(),
18166 permits: vec![
18167 crate::network_allowlist_catalog::NetworkPermitSketch {
18168 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18169 peer: "github.com:443".to_owned(),
18170 },
18171 crate::network_allowlist_catalog::NetworkPermitSketch {
18172 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18173 peer: "*.npmjs.org".to_owned(),
18174 },
18175 ],
18176 provider_native: None,
18177 };
18178 shared
18179 .network_allowlist_catalog
18180 .write()
18181 .unwrap_or_else(|poisoned| poisoned.into_inner())
18182 .insert(entry)
18183 .unwrap();
18184
18185 let unique = std::time::SystemTime::now()
18186 .duration_since(std::time::UNIX_EPOCH)
18187 .unwrap()
18188 .as_nanos();
18189 let provider_home = std::env::temp_dir().join(format!(
18190 "gate4agent-claude-provider-home-{}-{unique}",
18191 std::process::id(),
18192 ));
18193 std::fs::create_dir_all(&provider_home).unwrap();
18194
18195 let overlay = shared
18196 .resolve_network_allowlist_overlay_args(
18197 &agent("claude"),
18198 SessionMode::Pty,
18199 ApprovalLevel::Moderate,
18200 Some(&allowlist),
18201 Some(provider_home.as_path()),
18202 )
18203 .expect("Claude PTY settings overlay on unix");
18204 let settings_path = provider_home.join(
18205 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18206 );
18207 assert_eq!(
18208 overlay,
18209 vec![
18210 std::ffi::OsString::from("--settings"),
18211 std::ffi::OsString::from(settings_path.as_os_str()),
18212 ]
18213 );
18214 let body = std::fs::read_to_string(&settings_path).unwrap();
18215 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18216 assert_eq!(value["sandbox"]["enabled"], true);
18217 assert_eq!(
18218 value["sandbox"]["network"]["allowedDomains"],
18219 serde_json::json!(["github.com", "*.npmjs.org"])
18220 );
18221 assert!(settings_path.starts_with(&provider_home));
18222
18223 let acp = shared
18224 .resolve_network_allowlist_overlay_args(
18225 &agent("claude"),
18226 SessionMode::Acp,
18227 ApprovalLevel::Moderate,
18228 Some(&allowlist),
18229 Some(provider_home.as_path()),
18230 )
18231 .expect_err("Claude ACP must refuse settings overlay");
18232 assert_eq!(
18233 acp.code,
18234 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18235 );
18236
18237 let no_home = shared
18238 .resolve_network_allowlist_overlay_args(
18239 &agent("claude"),
18240 SessionMode::Pty,
18241 ApprovalLevel::Moderate,
18242 Some(&allowlist),
18243 None,
18244 )
18245 .expect_err("Claude overlay requires ProviderHome");
18246 assert_eq!(
18247 no_home.code,
18248 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18249 );
18250
18251 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18252 shared
18253 .network_allowlist_catalog
18254 .write()
18255 .unwrap_or_else(|poisoned| poisoned.into_inner())
18256 .insert_id(bare.clone())
18257 .unwrap();
18258 let empty = shared
18259 .resolve_network_allowlist_overlay_args(
18260 &agent("claude"),
18261 SessionMode::Pty,
18262 ApprovalLevel::Moderate,
18263 Some(&bare),
18264 Some(provider_home.as_path()),
18265 )
18266 .unwrap();
18267 assert!(empty.is_empty());
18268
18269 let _ = std::fs::remove_dir_all(&provider_home);
18270 }
18271
18272 #[test]
18274 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18275 let (shared, _defaults) = station_knobs_fixture();
18276 let allowlist =
18277 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18278 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18279 id: allowlist.clone(),
18280 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18281 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18282 peer: "example.com:443".to_owned(),
18283 }],
18284 provider_native: None,
18285 };
18286 shared
18287 .network_allowlist_catalog
18288 .write()
18289 .unwrap_or_else(|poisoned| poisoned.into_inner())
18290 .insert(entry)
18291 .unwrap();
18292 for provider in ["kimi", "grok"] {
18293 let overlay = shared
18294 .resolve_network_allowlist_overlay_args(
18295 &agent(provider),
18296 SessionMode::Pty,
18297 ApprovalLevel::Moderate,
18298 Some(&allowlist),
18299 None,
18300 )
18301 .unwrap();
18302 assert!(
18303 overlay.is_empty(),
18304 "{provider} must not invent network argv from permits"
18305 );
18306 }
18307 }
18308
18309 #[test]
18311 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
18312 let unique = std::time::SystemTime::now()
18313 .duration_since(std::time::UNIX_EPOCH)
18314 .unwrap()
18315 .as_nanos();
18316 let path = std::env::temp_dir().join(format!(
18317 "gate4agent-node-allowlist-json-{}-{unique}.json",
18318 std::process::id(),
18319 ));
18320 std::fs::write(
18321 &path,
18322 r#"{
18323 "schema_version": 2,
18324 "entries": [
18325 {
18326 "id": "egress-default",
18327 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
18328 },
18329 { "id": "lab-egress" }
18330 ]
18331 }"#,
18332 )
18333 .unwrap();
18334 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18335 .expect("json v2 catalog must load");
18336 let _ = std::fs::remove_file(&path);
18337 assert_eq!(loaded.len(), 2);
18338 let entry = loaded
18339 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
18340 .unwrap();
18341 assert_eq!(entry.permits.len(), 1);
18342 let (shared, defaults) = station_knobs_fixture();
18343 {
18344 let mut catalog = shared
18345 .network_allowlist_catalog
18346 .write()
18347 .unwrap_or_else(|poisoned| poisoned.into_inner());
18348 *catalog = loaded;
18349 }
18350 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18351 let spec = crate::protocol::SpawnSpec {
18352 target: crate::protocol::SpawnTarget {
18353 node_id: shared.node_id.clone(),
18354 workspace_id: WorkspaceId::new("primary").unwrap(),
18355 worktree_id: None,
18356 },
18357 profile_id: defaults.profile_id.clone(),
18358 expected_profile_revision: defaults.revision.clone(),
18359 overrides: crate::protocol::SpawnOverrides {
18360 network_allowlist: Some(allowlist.clone()),
18361 ..crate::protocol::SpawnOverrides::default()
18362 },
18363 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18364 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18365 "station-knobs-catalog-json",
18366 )
18367 .unwrap(),
18368 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18369 };
18370 let resolved = spec.resolve(&defaults).unwrap();
18371 let receipt = shared
18372 .resolve_environment_profile(&resolved)
18373 .expect("permits-only entry ok for claude")
18374 .expect("env profile present");
18375 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18376 let inventory = shared
18377 .snapshot()
18378 .launch_inventory
18379 .expect("launch inventory present");
18380 let ids = inventory.network_allowlists.expect("ids surface");
18381 assert_eq!(
18382 ids,
18383 vec![
18384 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
18385 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
18386 ]
18387 );
18388 }
18389
18390
18391}
18392
18393
18394#[cfg(test)]
18395mod workspace_environment_collision_tests {
18396 use super::*;
18397 use crate::session_environment::{
18398 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
18399 };
18400 use gate4agent_catalog::EnvMutation;
18401 use gate4agent_runtime_native::{
18402 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18403 NativeLaunchProfileId,
18404 };
18405 use std::ffi::OsString;
18406 use std::sync::Arc;
18407
18408 struct EmptyEnvironmentResolver;
18409
18410 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18411 fn resolve_child_environment(
18412 &self,
18413 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18414 Ok(vec![EnvMutation {
18415 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18416 value: None,
18417 }])
18418 }
18419 }
18420
18421 fn agent(value: &str) -> AgentId {
18422 AgentId::new(value).unwrap()
18423 }
18424
18425 fn claude_config_dir_collision_fixture() -> (
18427 NodeShared,
18428 ResolvedEnvironmentProfileReceipt,
18429 ResolvedEnvironmentProfileReceipt,
18430 ) {
18431 let catalog = active_registry().unwrap();
18432 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18433 let control = runtime.native_launch_profile_control();
18434 let workspace = WorkspaceConfig::new(
18435 WorkspaceId::new("primary").unwrap(),
18436 std::env::current_dir().unwrap(),
18437 )
18438 .unwrap();
18439 let shared = NodeShared::new_with_incarnation(
18440 handle,
18441 "fixture-token".to_owned(),
18442 NodeId::new("node-env-collision-test").unwrap(),
18443 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18444 vec![workspace],
18445 vec![agent("claude")],
18446 ProviderRuntimeStatuses::default(),
18447 None,
18448 Vec::new(),
18449 Vec::new(),
18450 SpawnProfileRegistry::default(),
18451 Some(control),
18452 None,
18453 None,
18454 None,
18455 Vec::new(),
18456 Vec::new(),
18457 Vec::new(),
18458 Vec::new(),
18459 None,
18460 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18461 );
18462
18463 let mut receipts = Vec::new();
18464 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
18465 let profile_id =
18466 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
18467 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
18468 "claude-config-dir-{suffix}-r1"
18469 ))
18470 .unwrap();
18471 let materialization = NodeSessionMaterializationProfile::new(
18472 Vec::new(),
18473 vec![NodeSessionPathBinding::new(
18474 "CLAUDE_CONFIG_DIR",
18475 NodeSessionPathClass::ProviderHome,
18476 )
18477 .unwrap()],
18478 Vec::new(),
18479 )
18480 .unwrap();
18481 let node_profile = NodeEnvironmentProfile::new_with_materialization(
18482 profile_id.clone(),
18483 profile_revision.clone(),
18484 agent("claude"),
18485 [NativeLaunchProfile::new(
18486 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
18487 .unwrap(),
18488 agent("claude"),
18489 TransportKind::Pty,
18490 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18491 Arc::new(EmptyEnvironmentResolver),
18492 )
18493 .unwrap()],
18494 Some(materialization),
18495 )
18496 .unwrap();
18497 let (binding, native_profiles, materialization) = node_profile.into_parts();
18498 for native_profile in native_profiles {
18499 runtime.upsert_native_launch_profile(native_profile).unwrap();
18500 }
18501 shared
18502 .environment_profiles
18503 .write()
18504 .unwrap_or_else(|poisoned| poisoned.into_inner())
18505 .insert(binding.id.clone(), binding);
18506 shared
18507 .environment_materialization_profiles
18508 .write()
18509 .unwrap_or_else(|poisoned| poisoned.into_inner())
18510 .insert(profile_id.clone(), materialization.unwrap());
18511 receipts.push(ResolvedEnvironmentProfileReceipt {
18512 profile_id,
18513 profile_revision,
18514 network_allowlist: None,
18515 browser_profile_id: None,
18516 });
18517 }
18518 let profile_b = receipts.pop().unwrap();
18519 let profile_a = receipts.pop().unwrap();
18520 (shared, profile_a, profile_b)
18521 }
18522
18523 #[test]
18524 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
18525 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18526 let primary = WorkspaceId::new("primary").unwrap();
18527 let worktree = WorkspaceId::new("worktree-b").unwrap();
18528 let address = SessionAddress {
18529 workspace_id: primary.clone(),
18530 session: SessionKey {
18531 instance_id: AgentInstanceId(41),
18532 generation: SessionGeneration(1),
18533 },
18534 };
18535 shared.bind_session_with_policy(
18536 &address,
18537 ProviderRuntimePolicy::raw_pty(),
18538 Some(profile_a.clone()),
18539 );
18540
18541 let refused = shared
18542 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
18543 .unwrap_err();
18544 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
18545
18546 let refused_none = shared
18548 .ensure_workspace_environment_profile_compatible(&primary, None)
18549 .unwrap_err();
18550 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
18551
18552 shared
18554 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
18555 .unwrap();
18556
18557 shared
18559 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
18560 .unwrap();
18561 }
18562
18563 #[tokio::test]
18564 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
18565 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18566 let primary = WorkspaceId::new("primary").unwrap();
18567 let address = SessionAddress {
18568 workspace_id: primary.clone(),
18569 session: SessionKey {
18570 instance_id: AgentInstanceId(41),
18571 generation: SessionGeneration(1),
18572 },
18573 };
18574 shared.bind_session_with_policy(
18575 &address,
18576 ProviderRuntimePolicy::raw_pty(),
18577 Some(profile_a),
18578 );
18579
18580 let failure = shared
18581 .spawn_session_with_deadline(
18582 primary,
18583 agent("claude"),
18584 SessionMode::Pty,
18585 gate4agent_types::TerminalSize {
18586 rows: 24,
18587 columns: 80,
18588 },
18589 None,
18590 Some(profile_b),
18591 None,
18592 None,
18593 None,
18594 None,
18595 SpawnRecordPolicy::ProviderIdentityOnly,
18596 Some(Instant::now() + Duration::from_secs(30)),
18597 &[],
18598 None,
18599 ApprovalLevel::default(),
18600 None,
18601 None,
18602 )
18603 .await
18604 .unwrap_err();
18605 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
18606 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
18608 assert!(shared.handle.snapshot().sessions.is_empty());
18609 }
18610}
18611
18612#[cfg(all(test, windows))]
18613mod tests {
18614 use super::*;
18615 use gate4agent_types::ProviderActivity;
18616 use crate::session_environment::{
18617 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
18618 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
18619 NodeSessionPathClass,
18620 };
18621 use gate4agent_catalog::EnvMutation;
18622 use gate4agent_runtime_native::{
18623 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
18624 NativeLaunchProfile, NativeLaunchProfileId,
18625 };
18626 use std::ffi::OsString;
18627 use std::sync::Arc;
18628
18629 struct EmptyEnvironmentResolver;
18630
18631 struct FixtureSecretResolver {
18632 deny: Arc<AtomicBool>,
18633 }
18634
18635 impl NodeSecretResolver for FixtureSecretResolver {
18636 fn resolve(
18637 &self,
18638 _reference: &NodeSecretReference,
18639 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
18640 if self.deny.load(Ordering::Acquire) {
18641 Err(NodeSecretResolveError::Denied)
18642 } else {
18643 NodeSecretValue::text("fixture-secret")
18644 .map_err(|_| NodeSecretResolveError::Unavailable)
18645 }
18646 }
18647 }
18648
18649 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18650 fn resolve_child_environment(
18651 &self,
18652 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18653 Ok(vec![EnvMutation {
18654 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18655 value: None,
18656 }])
18657 }
18658 }
18659
18660 fn agent(value: &str) -> AgentId {
18661 AgentId::new(value).unwrap()
18662 }
18663
18664 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
18665 let catalog = active_registry().unwrap();
18666 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18667 let workspace = WorkspaceConfig::new(
18668 WorkspaceId::new("primary").unwrap(),
18669 std::env::current_dir().unwrap(),
18670 )
18671 .unwrap()
18672 .with_worktree_service_mode(mode);
18673 NodeShared::new(
18674 handle,
18675 "fixture-token".to_owned(),
18676 NodeId::new("node-terminal-test").unwrap(),
18677 vec![workspace],
18678 vec![agent("claude")],
18679 )
18680 }
18681
18682 fn terminal_test_shared() -> NodeShared {
18683 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
18684 }
18685
18686 #[cfg(feature = "fixture")]
18687 #[test]
18688 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
18689 let probe = SpawnManagedWorktreeV2FailureProbe::default();
18690 assert_eq!(probe.latest_code(), None);
18691
18692 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
18693 assert_eq!(
18694 probe.latest_code(),
18695 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
18696 );
18697
18698 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
18699 assert_eq!(
18700 probe.latest_code(),
18701 Some(NodeFailureCode::SpawnDeadlineExceeded),
18702 );
18703
18704 probe.record(None);
18705 assert_eq!(probe.latest_code(), None);
18706 }
18707
18708 #[test]
18709 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
18710 let source = temporary_workspace_root("launch-inventory-source");
18711 let allocation = temporary_workspace_root("launch-inventory-allocation");
18712 std::fs::create_dir_all(&source).unwrap();
18713 std::fs::create_dir_all(&allocation).unwrap();
18714 let workspace_id = WorkspaceId::new("primary").unwrap();
18715 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
18716 .unwrap()
18717 .with_worktree_service_mode(WorktreeServiceMode::Managed);
18718 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
18719 workspace = workspace
18720 .with_managed_worktree_profile(
18721 ManagedWorktreeProfile::new(
18722 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
18723 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
18724 &allocation,
18725 "gate4agent",
18726 "HEAD",
18727 ManagedWorktreeRetention::RemoveWhenReleased,
18728 )
18729 .unwrap(),
18730 )
18731 .unwrap();
18732 }
18733 let overflow = workspace.clone().with_managed_worktree_profile(
18734 ManagedWorktreeProfile::new(
18735 WorktreeProfileId::new("overflow").unwrap(),
18736 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
18737 &allocation,
18738 "gate4agent",
18739 "HEAD",
18740 ManagedWorktreeRetention::Retain,
18741 )
18742 .unwrap(),
18743 );
18744 assert!(matches!(
18745 overflow,
18746 Err(NodeServerError::ManagedWorktreeProfileCapacity {
18747 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
18748 ..
18749 }),
18750 ));
18751
18752 let catalog = active_registry().unwrap();
18753 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18754 let shared = NodeShared::new(
18755 handle,
18756 "fixture-token".to_owned(),
18757 NodeId::new("node-launch-inventory").unwrap(),
18758 vec![workspace],
18759 vec![agent("claude")],
18760 );
18761 let snapshot = shared.snapshot();
18762 let launch = snapshot.launch_inventory.as_ref().unwrap();
18763 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
18764 assert!(launch.bundles.as_ref().unwrap().is_empty());
18765 let profiles = snapshot.workspaces[0]
18766 .managed_worktree_profiles
18767 .as_ref()
18768 .unwrap();
18769 assert_eq!(
18770 snapshot.workspaces[0].worktree_service_mode,
18771 Some(WorktreeServiceMode::Managed),
18772 );
18773 assert_eq!(
18774 profiles.profiles.len(),
18775 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
18776 );
18777 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
18778 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
18779 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
18780 }
18781 let legacy = snapshot_for_wire(
18782 &shared,
18783 true,
18784 true,
18785 false,
18786 false,
18787 true,
18788 false,
18789 true,
18790 false,
18791 true,
18792 );
18793 assert!(legacy.launch_inventory.is_none());
18794 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
18795
18796 drop(shared);
18797 std::fs::remove_dir_all(source).unwrap();
18798 std::fs::remove_dir_all(allocation).unwrap();
18799 }
18800
18801 #[test]
18802 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
18803 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
18804 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
18805 }));
18806 let source = gate4agent_types::ProviderSource {
18807 family: AdapterFamily::PtySemantic,
18808 binding: AdapterBinding::new(
18809 gate4agent_types::AdapterId::new("claude").unwrap(),
18810 "fixture/v1",
18811 gate4agent_types::AdapterVerification::SyntheticFixture,
18812 )
18813 .unwrap(),
18814 };
18815 let provider = ProviderSnapshot {
18816 sequence: 17,
18817 session: Some(ProviderSessionIdentity {
18818 key: ProviderSessionKey::SessionId,
18819 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
18820 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
18821 }),
18822 model: Some("PRIVATE_MODEL".to_owned()),
18823 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
18824 completed_turns: 3,
18825 usage: gate4agent_types::TokenUsage {
18826 input_tokens: 10,
18827 output_tokens: 20,
18828 cache_read_tokens: 30,
18829 cache_write_tokens: 40,
18830 reasoning_tokens: 50,
18831 context_window: Some(200_000),
18832 },
18833 lead_activity: ProviderActivity::WaitingForInput,
18834 activity: ProviderActivity::WaitingForInput,
18835 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
18836 active_tools: (0..9)
18837 .map(|index| gate4agent_types::ActiveProviderTool {
18838 id: format!("private-tool-id-{index}"),
18839 name: if index == 0 {
18840 format!(" Read\u{0000}{}", "x".repeat(80))
18841 } else {
18842 format!("Tool-{index}")
18843 },
18844 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
18845 })
18846 .collect(),
18847 interactions: vec![gate4agent_types::ProviderInteraction {
18848 id: gate4agent_types::ProviderInteractionId(17),
18849 source: source.clone(),
18850 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
18851 interaction_kind: ProviderInteractionKind::Approval,
18852 tool_name: " Approve\u{0007}Tool ".to_owned(),
18853 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
18854 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
18855 resume_lead_activity: Some(ProviderActivity::Working),
18856 status: ProviderInteractionStatus::Pending,
18857 }],
18858 subagents: vec![gate4agent_types::ProviderSubagent {
18859 source: source.clone(),
18860 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
18861 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
18862 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
18863 }],
18864 sources: vec![gate4agent_types::ProviderSourceCursor {
18865 source,
18866 sequence: 17,
18867 gap_count: 2,
18868 stale: true,
18869 }],
18870 last_event: Some(ProviderEvent::Error {
18871 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
18872 }),
18873 gap_count: 2,
18874 stale: true,
18875 };
18876 let address = terminal_address(3);
18877 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
18878 assert_eq!(entry.address, address);
18879 assert_eq!(entry.progress.provider_sequence, 17);
18880 assert_eq!(entry.progress.active_tool_count, 9);
18881 assert_eq!(
18882 entry.progress.active_tool_labels.len(),
18883 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
18884 );
18885 assert_eq!(entry.progress.subagent_count, 1);
18886 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
18887 assert!(entry.progress.truncated);
18888 let encoded = serde_json::to_string(&entry).unwrap();
18889 for forbidden in [
18890 "PRIVATE_PROVIDER_SESSION",
18891 "PRIVATE_PROVIDER_PATH",
18892 "PRIVATE_MODEL",
18893 "PRIVATE_TOOL_INVENTORY",
18894 "PRIVATE_CURRENT_PROMPT",
18895 "PRIVATE_TOOL_INPUT_JSON",
18896 "PRIVATE_PROVIDER_REQUEST",
18897 "PRIVATE_INTERACTION_PROMPT",
18898 "PRIVATE_INTERACTION_AGENT",
18899 "PRIVATE_SUBAGENT_ID",
18900 "PRIVATE_SUBAGENT_TYPE",
18901 "PRIVATE_SUBAGENT_DESCRIPTION",
18902 "PRIVATE_PROVIDER_ERROR",
18903 "input_json",
18904 "current_prompt",
18905 "session_id",
18906 ] {
18907 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
18908 }
18909
18910 let snapshot = NodeSnapshot {
18911 node_id: NodeId::new("node-progress-test").unwrap(),
18912 enabled_providers: vec![agent("claude")],
18913 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
18914 workspaces: Vec::new(),
18915 session_records: Vec::new(),
18916 managed_worktrees: Vec::new(),
18917 launch_inventory: None,
18918 agent_progress: vec![entry],
18919 };
18920 let mut legacy_reply = ResponseEnvelope {
18921 request_id: 1,
18922 result: Ok(NodeResponse::Snapshot {
18923 event_sequence: 0,
18924 controller: None,
18925 snapshot: snapshot.clone(),
18926 }),
18927 };
18928 project_response_without_agent_progress(&mut legacy_reply);
18929 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
18930 panic!("agent progress projection changed response kind");
18931 };
18932 assert!(legacy.agent_progress.is_empty());
18933 assert_eq!(snapshot.agent_progress.len(), 1);
18934 }
18935
18936 fn terminal_address(generation: u64) -> SessionAddress {
18937 SessionAddress {
18938 workspace_id: WorkspaceId::new("primary").unwrap(),
18939 session: SessionKey {
18940 instance_id: AgentInstanceId(41),
18941 generation: SessionGeneration(generation),
18942 },
18943 }
18944 }
18945
18946 fn spawn_spec_fixture() -> SpawnSpec {
18947 SpawnSpec {
18948 target: crate::protocol::SpawnTarget {
18949 node_id: NodeId::new("node-terminal-test").unwrap(),
18950 workspace_id: WorkspaceId::new("primary").unwrap(),
18951 worktree_id: None,
18952 },
18953 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18954 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
18955 .unwrap(),
18956 overrides: crate::protocol::SpawnOverrides::default(),
18957 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18958 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
18959 required_capabilities: SpawnRequiredCapabilities::default(),
18960 }
18961 }
18962
18963 fn environment_profile_test_shared(
18964 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
18965 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18966 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
18967 "local-claude-r1",
18968 )
18969 .unwrap();
18970 let spawn_profiles = SpawnProfileRegistry::new([
18971 crate::protocol::SpawnProfileDefaults {
18972 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18973 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18974 provider: agent("claude"),
18975 mode: SessionMode::Pty,
18976 terminal_size: gate4agent_types::TerminalSize {
18977 rows: 24,
18978 columns: 80,
18979 },
18980 prompt: None,
18981 bundle_id: None,
18982 context_id: None,
18983 environment_profile_id: Some(profile_id.clone()),
18984 },
18985 ])
18986 .unwrap();
18987 let catalog = active_registry().unwrap();
18988 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18989 let control = runtime.native_launch_profile_control();
18990 let node_profile = NodeEnvironmentProfile::new(
18991 profile_id.clone(),
18992 profile_revision.clone(),
18993 agent("claude"),
18994 [NativeLaunchProfile::new(
18995 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18996 agent("claude"),
18997 TransportKind::Pty,
18998 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18999 Arc::new(EmptyEnvironmentResolver),
19000 )
19001 .unwrap()],
19002 )
19003 .unwrap();
19004 let (binding, native_profiles, _materialization) = node_profile.into_parts();
19005 for native_profile in native_profiles {
19006 runtime.upsert_native_launch_profile(native_profile).unwrap();
19007 }
19008 let workspace = WorkspaceConfig::new(
19009 WorkspaceId::new("primary").unwrap(),
19010 std::env::current_dir().unwrap(),
19011 )
19012 .unwrap();
19013 let shared = NodeShared::new_with_incarnation(
19014 handle,
19015 "fixture-token".to_owned(),
19016 NodeId::new("node-terminal-test").unwrap(),
19017 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19018 vec![workspace],
19019 vec![agent("claude")],
19020 ProviderRuntimeStatuses::default(),
19021 None,
19022 Vec::new(),
19023 Vec::new(),
19024 spawn_profiles,
19025 Some(control),
19026 None,
19027 None,
19028 None,
19029 Vec::new(),
19030 Vec::new(),
19031 Vec::new(),
19032 Vec::new(),
19033 None,
19034 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19035 );
19036 shared
19037 .environment_profiles
19038 .write()
19039 .unwrap_or_else(|poisoned| poisoned.into_inner())
19040 .insert(binding.id.clone(), binding);
19041 (
19042 shared,
19043 runtime,
19044 ResolvedEnvironmentProfileReceipt {
19045 profile_id,
19046 profile_revision,
19047 network_allowlist: None,
19048 browser_profile_id: None,
19049 },
19050 )
19051 }
19052
19053 fn materialization_test_shared(
19054 deny_secret: bool,
19055 ) -> (
19056 NodeShared,
19057 NativeRuntime,
19058 ResolvedEnvironmentProfileReceipt,
19059 PathBuf,
19060 Arc<AtomicBool>,
19061 ) {
19062 let workspace = WorkspaceConfig::new(
19063 WorkspaceId::new("primary").unwrap(),
19064 std::env::current_dir().unwrap(),
19065 )
19066 .unwrap();
19067 materialization_test_shared_with_workspace(deny_secret, workspace)
19068 }
19069
19070 fn materialization_test_shared_with_workspace(
19071 deny_secret: bool,
19072 workspace: WorkspaceConfig,
19073 ) -> (
19074 NodeShared,
19075 NativeRuntime,
19076 ResolvedEnvironmentProfileReceipt,
19077 PathBuf,
19078 Arc<AtomicBool>,
19079 ) {
19080 let root = temporary_workspace_root(if deny_secret {
19081 "materialization-denied"
19082 } else {
19083 "materialization-ready"
19084 });
19085 std::fs::create_dir_all(&root).unwrap();
19086 let materialization_root = root.join("session-environments");
19087 let deny = Arc::new(AtomicBool::new(deny_secret));
19088 let resolver = Arc::new(FixtureSecretResolver {
19089 deny: Arc::clone(&deny),
19090 });
19091 let materializer = SessionEnvironmentMaterializer::new(
19092 materialization_root,
19093 resolver,
19094 )
19095 .unwrap();
19096 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19097 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19098 "materialized-claude-r1",
19099 )
19100 .unwrap();
19101 let materialization = NodeSessionMaterializationProfile::new(
19102 vec![NodeSessionEnvironmentMutation::SetSecret {
19103 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19104 reference: NodeSecretReference::new("fixture-token").unwrap(),
19105 }],
19106 vec![NodeSessionPathBinding::new(
19107 "GATE4AGENT_PROVIDER_HOME",
19108 NodeSessionPathClass::ProviderHome,
19109 )
19110 .unwrap()],
19111 vec![NodeSessionFile::secret(
19112 NodeSessionPathClass::Config,
19113 "auth/token",
19114 NodeSecretReference::new("fixture-token").unwrap(),
19115 )
19116 .unwrap()],
19117 )
19118 .unwrap();
19119 let catalog = active_registry().unwrap();
19120 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19121 let control = runtime.native_launch_profile_control();
19122 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19123 profile_id.clone(),
19124 profile_revision.clone(),
19125 agent("claude"),
19126 [NativeLaunchProfile::new(
19127 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19128 agent("claude"),
19129 TransportKind::Pty,
19130 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19131 Arc::new(EmptyEnvironmentResolver),
19132 )
19133 .unwrap()],
19134 Some(materialization),
19135 )
19136 .unwrap();
19137 let (binding, native_profiles, materialization) = node_profile.into_parts();
19138 for native_profile in native_profiles {
19139 runtime.upsert_native_launch_profile(native_profile).unwrap();
19140 }
19141 let shared = NodeShared::new_with_incarnation(
19142 handle,
19143 "fixture-token".to_owned(),
19144 NodeId::new("node-materialization-test").unwrap(),
19145 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19146 vec![workspace],
19147 vec![agent("claude")],
19148 ProviderRuntimeStatuses::default(),
19149 None,
19150 Vec::new(),
19151 Vec::new(),
19152 SpawnProfileRegistry::default(),
19153 Some(control),
19154 None,
19155 Some(materializer),
19156 None,
19157 Vec::new(),
19158 Vec::new(),
19159 Vec::new(),
19160 Vec::new(),
19161 None,
19162 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19163 );
19164 shared
19165 .environment_profiles
19166 .write()
19167 .unwrap_or_else(|poisoned| poisoned.into_inner())
19168 .insert(binding.id.clone(), binding);
19169 shared
19170 .environment_materialization_profiles
19171 .write()
19172 .unwrap_or_else(|poisoned| poisoned.into_inner())
19173 .insert(profile_id.clone(), materialization.unwrap());
19174 (
19175 shared,
19176 runtime,
19177 ResolvedEnvironmentProfileReceipt {
19178 profile_id,
19179 profile_revision,
19180 network_allowlist: None,
19181 browser_profile_id: None,
19182 },
19183 root,
19184 deny,
19185 )
19186 }
19187
19188 #[cfg(feature = "fixture")]
19189 #[derive(Clone, Copy)]
19190 enum CodexHomeProfile {
19191 Missing,
19192 WrongClass,
19193 Exact,
19194 }
19195
19196 #[cfg(feature = "fixture")]
19197 fn codex_bundle_test_shared(
19198 home_profile: CodexHomeProfile,
19199 ) -> (
19200 NodeShared,
19201 NativeRuntime,
19202 ResolvedEnvironmentProfileReceipt,
19203 NodeBundle,
19204 PathBuf,
19205 ) {
19206 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
19207 const CLAUDE_MANIFEST: &[u8] =
19208 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
19209 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
19210 const DIGEST: &str =
19211 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
19212
19213 let (shared, mut runtime, _claude_receipt, root, _deny) =
19214 materialization_test_shared(false);
19215 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
19216 CodexHomeProfile::Missing => "codex-home-missing",
19217 CodexHomeProfile::WrongClass => "codex-home-wrong",
19218 CodexHomeProfile::Exact => "codex-home-exact",
19219 })
19220 .unwrap();
19221 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19222 "codex-home-r1",
19223 )
19224 .unwrap();
19225 let materialization = match home_profile {
19226 CodexHomeProfile::Missing => None,
19227 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
19228 Some(NodeSessionMaterializationProfile::new(
19229 Vec::new(),
19230 vec![NodeSessionPathBinding::new(
19231 "CODEX_HOME",
19232 if matches!(home_profile, CodexHomeProfile::Exact) {
19233 NodeSessionPathClass::ProviderHome
19234 } else {
19235 NodeSessionPathClass::Config
19236 },
19237 )
19238 .unwrap()],
19239 Vec::new(),
19240 )
19241 .unwrap())
19242 }
19243 };
19244 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19245 profile_id.clone(),
19246 profile_revision.clone(),
19247 agent("codex"),
19248 [NativeLaunchProfile::new(
19249 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
19250 agent("codex"),
19251 TransportKind::Pty,
19252 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19253 Arc::new(EmptyEnvironmentResolver),
19254 )
19255 .unwrap()],
19256 materialization,
19257 )
19258 .unwrap();
19259 let (binding, native_profiles, materialization) = node_profile.into_parts();
19260 for native_profile in native_profiles {
19261 runtime.upsert_native_launch_profile(native_profile).unwrap();
19262 }
19263 shared
19264 .environment_profiles
19265 .write()
19266 .unwrap_or_else(|poisoned| poisoned.into_inner())
19267 .insert(binding.id.clone(), binding);
19268 if let Some(materialization) = materialization {
19269 shared
19270 .environment_materialization_profiles
19271 .write()
19272 .unwrap_or_else(|poisoned| poisoned.into_inner())
19273 .insert(profile_id.clone(), materialization);
19274 }
19275
19276 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
19277 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
19278 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
19279 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
19280 std::fs::write(
19281 bundle_source.join(".claude-plugin/plugin.json"),
19282 CLAUDE_MANIFEST,
19283 )
19284 .unwrap();
19285 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
19286 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
19287 let bundle = NodeBundle::new(
19288 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
19289 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
19290 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
19291 &bundle_source,
19292 )
19293 .unwrap();
19294 *shared
19295 .bundle_catalog
19296 .write()
19297 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
19298 BundleCatalog::new([bundle.clone()]).unwrap();
19299 (
19300 shared,
19301 runtime,
19302 ResolvedEnvironmentProfileReceipt {
19303 profile_id,
19304 profile_revision,
19305 network_allowlist: None,
19306 browser_profile_id: None,
19307 },
19308 bundle,
19309 root,
19310 )
19311 }
19312
19313 #[cfg(feature = "fixture")]
19314 #[test]
19315 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
19316 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
19317 let (shared, runtime, receipt, bundle, root) =
19318 codex_bundle_test_shared(home_profile);
19319 let error = shared
19320 .bundle_layout(
19321 &agent("codex"),
19322 SessionMode::Pty,
19323 Some(&receipt),
19324 &bundle,
19325 )
19326 .unwrap_err();
19327 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
19328 assert!(shared
19329 .materializations
19330 .lock()
19331 .unwrap_or_else(|poisoned| poisoned.into_inner())
19332 .is_empty());
19333 let materialization_root = root.join("session-environments");
19334 assert!(!std::fs::read_dir(&materialization_root)
19335 .unwrap()
19336 .filter_map(Result::ok)
19337 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
19338 drop(shared);
19339 drop(runtime);
19340 let _ = std::fs::remove_dir_all(root);
19341 }
19342 }
19343
19344 #[cfg(feature = "fixture")]
19345 #[test]
19346 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
19347 let (shared, runtime, receipt, bundle, root) =
19348 codex_bundle_test_shared(CodexHomeProfile::Exact);
19349 let address = SessionAddress {
19350 workspace_id: WorkspaceId::new("primary").unwrap(),
19351 session: SessionKey {
19352 instance_id: AgentInstanceId(310),
19353 generation: SessionGeneration(1),
19354 },
19355 };
19356 let (overlay, guard) = shared
19357 .prepare_session_materialization(
19358 &address,
19359 &agent("codex"),
19360 SessionMode::Pty,
19361 Some(&receipt),
19362 Some(&bundle.receipt()),
19363 None,
19364 None,
19365 )
19366 .unwrap()
19367 .unwrap();
19368 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
19369 let ownership = shared
19370 .materializations
19371 .lock()
19372 .unwrap_or_else(|poisoned| poisoned.into_inner())
19373 .get(guard.id().unwrap())
19374 .unwrap()
19375 .clone();
19376 let (profile, _, _) = shared
19377 .materialization_profile(
19378 &agent("codex"),
19379 SessionMode::Pty,
19380 Some(&receipt),
19381 Some(&bundle.receipt()),
19382 None,
19383 )
19384 .unwrap()
19385 .unwrap();
19386 let environment = shared
19387 .session_environment_materializer
19388 .as_ref()
19389 .unwrap()
19390 .resolve_environment(&ownership, &profile)
19391 .unwrap();
19392 assert!(environment.iter().any(|mutation| {
19393 mutation.key == OsString::from("CODEX_HOME")
19394 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
19395 }));
19396 assert_eq!(
19397 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
19398 .unwrap(),
19399 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
19400 );
19401 assert!(std::fs::read_dir(ownership.bundle_root())
19402 .unwrap()
19403 .next()
19404 .is_none());
19405 drop(guard);
19406 drop(shared);
19407 drop(runtime);
19408 let _ = std::fs::remove_dir_all(root);
19409 }
19410
19411 #[cfg(feature = "fixture")]
19412 #[test]
19413 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
19414 let (shared, runtime, receipt, bundle, root) =
19415 codex_bundle_test_shared(CodexHomeProfile::Exact);
19416 let bundle_receipt = bundle.receipt();
19417 let address = SessionAddress {
19418 workspace_id: WorkspaceId::new("primary").unwrap(),
19419 session: SessionKey {
19420 instance_id: AgentInstanceId(311),
19421 generation: SessionGeneration(1),
19422 },
19423 };
19424 let (_overlay, guard) = shared
19425 .prepare_session_materialization(
19426 &address,
19427 &agent("codex"),
19428 SessionMode::Pty,
19429 Some(&receipt),
19430 Some(&bundle_receipt),
19431 None,
19432 None,
19433 )
19434 .unwrap()
19435 .unwrap();
19436 let materialization_id = guard.id().unwrap().clone();
19437 let skill_path = shared
19438 .materializations
19439 .lock()
19440 .unwrap_or_else(|poisoned| poisoned.into_inner())
19441 .get(&materialization_id)
19442 .unwrap()
19443 .provider_home()
19444 .join("skills/review-code/SKILL.md");
19445 let record_id = shared
19446 .bind_spawn_session_with_materialization(
19447 &address,
19448 agent("codex"),
19449 SessionMode::Pty,
19450 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
19451 SpawnRecordPolicy::ProviderIdentityOnly,
19452 Some(receipt.clone()),
19453 Some(bundle_receipt.clone()),
19454 None,
19455 Some(materialization_id.clone()),
19456 )
19457 .unwrap()
19458 .unwrap();
19459 guard.retain();
19460 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
19461
19462 let error = match shared.resolve_record_materialization(
19463 &record_id,
19464 &agent("codex"),
19465 SessionMode::Pty,
19466 Some(&receipt),
19467 Some(&bundle_receipt),
19468 None,
19469 ) {
19470 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
19471 Err(error) => error,
19472 };
19473 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
19474 assert_eq!(
19475 shared
19476 .materializations
19477 .lock()
19478 .unwrap_or_else(|poisoned| poisoned.into_inner())
19479 .get(&materialization_id)
19480 .unwrap()
19481 .state(),
19482 MaterializationState::RecoveryRequired,
19483 );
19484 drop(shared);
19485 drop(runtime);
19486 let _ = std::fs::remove_dir_all(root);
19487 }
19488
19489 fn bind_record_owned_materialization_fixture(
19490 shared: &NodeShared,
19491 receipt: &ResolvedEnvironmentProfileReceipt,
19492 instance_id: u64,
19493 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
19494 let address = SessionAddress {
19495 workspace_id: WorkspaceId::new("primary").unwrap(),
19496 session: SessionKey {
19497 instance_id: AgentInstanceId(instance_id),
19498 generation: SessionGeneration(1),
19499 },
19500 };
19501 let (overlay, materialization_guard) = shared
19502 .prepare_session_materialization(
19503 &address,
19504 &agent("claude"),
19505 SessionMode::Pty,
19506 Some(receipt),
19507 None,
19508 None,
19509 None,
19510 )
19511 .unwrap()
19512 .unwrap();
19513 let materialization_id = materialization_guard.id().unwrap().clone();
19514 let materialization_root = shared
19515 .materializations
19516 .lock()
19517 .unwrap_or_else(|poisoned| poisoned.into_inner())
19518 .get(&materialization_id)
19519 .unwrap()
19520 .root()
19521 .to_path_buf();
19522 let selection = shared
19523 .select_environment_profile(
19524 address.session.instance_id,
19525 &agent("claude"),
19526 SessionMode::Pty,
19527 Some(receipt),
19528 )
19529 .unwrap()
19530 .unwrap();
19531 assert!(shared
19532 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
19533 .unwrap()
19534 .is_none());
19535 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
19536 let record_id = shared
19537 .bind_spawn_session_with_materialization(
19538 &address,
19539 agent("claude"),
19540 SessionMode::Pty,
19541 policy,
19542 SpawnRecordPolicy::ProviderIdentityOnly,
19543 Some(receipt.clone()),
19544 None,
19545 None,
19546 Some(materialization_id.clone()),
19547 )
19548 .unwrap()
19549 .unwrap();
19550 selection.retain();
19551 materialization_guard.retain();
19552 (address, record_id, materialization_id, materialization_root)
19553 }
19554
19555 #[test]
19556 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
19557 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
19558 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19559 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
19560 }));
19561 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
19562 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
19563 let mut spec = spawn_spec_fixture();
19564 spec.expected_profile_revision =
19565 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
19566 assert!(matches!(
19567 &spec.overrides.environment_profile_id,
19568 crate::protocol::SpawnOverride::Inherit,
19569 ));
19570 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
19571 .requires_child_environment_profile_capability());
19572 assert!(request_requires_child_environment_profile(
19573 &shared,
19574 &NodeRequest::SpawnSpec { spec: spec.clone() },
19575 ));
19576
19577 let address = terminal_address(1);
19578 shared.bind_session_with_policy(
19579 &address,
19580 ProviderRuntimePolicy::raw_pty(),
19581 Some(environment_profile.clone()),
19582 );
19583 assert!(request_requires_child_environment_profile(
19584 &shared,
19585 &NodeRequest::Input {
19586 session: address.clone(),
19587 text: "x".to_owned(),
19588 },
19589 ));
19590
19591 let mut record = record("claude", "profile-record");
19592 record.active_session = Some(address.clone());
19593 record.environment_profile = Some(environment_profile.clone());
19594 let record_id = record.record_id.clone();
19595 shared.insert_record(record.clone()).unwrap();
19596 assert!(request_requires_child_environment_profile(
19597 &shared,
19598 &NodeRequest::ResumeSessionRecord {
19599 record_id,
19600 terminal_size: gate4agent_types::TerminalSize {
19601 rows: 24,
19602 columns: 80,
19603 },
19604 initial_prompt: None,
19605 },
19606 ));
19607
19608 let snapshot = snapshot_for_wire(
19609 &shared, false, true, true, true, false, false, false, false, true,
19610 );
19611 assert_eq!(snapshot.session_records.len(), 1);
19612 assert!(snapshot.session_records[0].environment_profile.is_none());
19613 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
19614 sequence: 1,
19615 event: NodeEvent::SessionRecordUpserted { record },
19616 });
19617 let NodeEvent::SessionRecordUpserted { record } = event.event else {
19618 panic!("record projection changed the event kind");
19619 };
19620 assert!(record.environment_profile.is_none());
19621
19622 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
19623 let mut reply = ResponseEnvelope {
19624 request_id: 1,
19625 result: Ok(NodeResponse::SpawnSpecAccepted {
19626 receipt: resolved.receipt_with_environment(
19627 shared.incarnation_id,
19628 address,
19629 Some(environment_profile),
19630 ),
19631 }),
19632 };
19633 project_response_without_child_environment_profile(&mut reply);
19634 assert_eq!(
19635 reply.result.unwrap_err().code,
19636 NodeFailureCode::UnsupportedCapability,
19637 );
19638 }
19639
19640 #[test]
19641 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
19642 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
19643 let snapshot = shared.snapshot();
19644 let profiles = snapshot
19645 .launch_inventory
19646 .as_ref()
19647 .and_then(|inventory| inventory.spawn_profiles.as_ref())
19648 .unwrap();
19649 assert_eq!(profiles.len(), 1);
19650 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
19651 assert!(snapshot.requires_child_environment_profile_capability());
19652
19653 let mut legacy = snapshot.clone();
19654 clear_snapshot_child_environment_profiles(&mut legacy);
19655 assert!(legacy
19656 .launch_inventory
19657 .as_ref()
19658 .unwrap()
19659 .spawn_profiles
19660 .as_ref()
19661 .unwrap()[0]
19662 .environment_profile
19663 .is_none());
19664 assert!(!legacy.requires_child_environment_profile_capability());
19665
19666 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
19667 let bindings = shared
19668 .environment_profiles
19669 .read()
19670 .unwrap_or_else(|poisoned| poisoned.into_inner())
19671 .clone();
19672 let mut provider_mismatch = bindings.clone();
19673 provider_mismatch
19674 .values_mut()
19675 .next()
19676 .unwrap()
19677 .provider = agent("codex");
19678 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
19679 let mut mode_mismatch = profile;
19680 mode_mismatch.mode = SessionMode::Inline;
19681 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
19682
19683 shared
19684 .environment_profiles
19685 .write()
19686 .unwrap_or_else(|poisoned| poisoned.into_inner())
19687 .clear();
19688 assert!(shared
19689 .snapshot()
19690 .launch_inventory
19691 .unwrap()
19692 .spawn_profiles
19693 .unwrap()
19694 .is_empty());
19695 }
19696
19697 #[test]
19698 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
19699 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19700 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
19701 }));
19702 let shared = terminal_test_shared();
19703 let bundle = ResolvedBundleReceipt {
19704 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
19705 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
19706 digest: crate::protocol::SpawnBundleDigest::new(format!(
19707 "sha256:{}",
19708 "0".repeat(64),
19709 ))
19710 .unwrap(),
19711 };
19712 let mut bundled = record("claude", "bundle-record");
19713 bundled.bundle = Some(bundle.clone());
19714 let record_id = bundled.record_id.clone();
19715 shared.insert_record(bundled.clone()).unwrap();
19716 assert!(request_requires_session_bundle(
19717 &shared,
19718 &NodeRequest::ForgetSessionRecord {
19719 record_id: record_id.clone(),
19720 },
19721 ));
19722
19723 let snapshot = snapshot_for_wire(
19724 &shared, false, true, true, true, true, false, false, false, true,
19725 );
19726 assert!(snapshot.session_records[0].bundle.is_none());
19727 let launch = snapshot.launch_inventory.as_ref().unwrap();
19728 assert!(launch.spawn_profiles.is_some());
19729 assert!(launch.bundles.is_none());
19730 let event = project_event_without_session_bundle(NodeEventEnvelope {
19731 sequence: 1,
19732 event: NodeEvent::SessionRecordUpserted { record: bundled },
19733 });
19734 let NodeEvent::SessionRecordUpserted { record } = event.event else {
19735 panic!("record projection changed the event kind");
19736 };
19737 assert!(record.bundle.is_none());
19738
19739 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
19740 let mut reply = ResponseEnvelope {
19741 request_id: 1,
19742 result: Ok(NodeResponse::SpawnSpecAccepted {
19743 receipt: resolved.receipt_with_materialization(
19744 shared.incarnation_id,
19745 terminal_address(17),
19746 None,
19747 Some(bundle),
19748 None,
19749 ),
19750 }),
19751 };
19752 project_response_without_session_bundle(&mut reply);
19753 assert_eq!(
19754 reply.result.unwrap_err().code,
19755 NodeFailureCode::UnsupportedCapability,
19756 );
19757 }
19758
19759 #[test]
19760 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
19761 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19762 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
19763 }));
19764 let context_id = SpawnContextId::new("context-review").unwrap();
19765 let context = ResolvedContextPackReceipt {
19766 id: context_id.clone(),
19767 digest: crate::protocol::SpawnContextDigest::new(format!(
19768 "sha256:{}",
19769 "c".repeat(64),
19770 ))
19771 .unwrap(),
19772 lineage: ContextPackLineageReceipt {
19773 source_node_id: NodeId::new("node-source").unwrap(),
19774 source_session: terminal_address(1),
19775 source_provider: agent("kimi"),
19776 },
19777 source_message_count: 2,
19778 retained_message_count: 2,
19779 byte_len: 256,
19780 truncated: false,
19781 };
19782 assert!(context.is_valid());
19783 let mut bound_record = record("codex", "context-record");
19784 bound_record.context_id = Some(context_id);
19785 bound_record.context = Some(context.clone());
19786 let session = gate4agent_types::SessionSnapshot {
19787 instance_id: AgentInstanceId(91),
19788 agent_id: agent("kimi"),
19789 transport: TransportKind::Pty,
19790 generation: SessionGeneration(3),
19791 status: SessionStatus::Running,
19792 pending_operation: None,
19793 pending_input: None,
19794 process_id: Some(9001),
19795 terminal_size: None,
19796 terminal_frame: None,
19797 terminal_stale: None,
19798 session_options: None,
19799 capabilities: gate4agent_types::CapabilitySnapshot::default(),
19800 history: gate4agent_types::HistorySnapshot {
19801 pending: None,
19802 candidates: vec![HistoryCandidateSummary {
19803 id: "candidate-opaque".to_owned(),
19804 session_id_hint: "session-hint".to_owned(),
19805 modified_at_unix_ms: Some(1),
19806 }],
19807 loaded_candidate_id: Some("candidate-opaque".to_owned()),
19808 loaded: Some(HistorySessionRecord {
19809 session_id: "private-vendor-session".to_owned(),
19810 title: Some("private title".to_owned()),
19811 cwd: Some(r"C:\private\history-root".to_owned()),
19812 model: Some("private-model".to_owned()),
19813 message_count: 2,
19814 completed_turn_count: None,
19815 total_tokens: 19,
19816 messages: vec![
19817 gate4agent_types::HistoryMessageRecord {
19818 role: gate4agent_types::HistoryMessageRole::User,
19819 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
19820 },
19821 gate4agent_types::HistoryMessageRecord {
19822 role: gate4agent_types::HistoryMessageRole::Assistant,
19823 text: "private answer".to_owned(),
19824 },
19825 ],
19826 }),
19827 last_error: None,
19828 },
19829 resume: gate4agent_types::ResumeSnapshot::default(),
19830 foreground: gate4agent_types::ForegroundSnapshot::default(),
19831 provider: gate4agent_types::ProviderSnapshot::default(),
19832 screen_state: gate4agent_types::PtyScreenState::default(),
19833 };
19834 let original = NodeSnapshot {
19835 node_id: NodeId::new("node-terminal-test").unwrap(),
19836 enabled_providers: vec![agent("kimi"), agent("codex")],
19837 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19838 workspaces: vec![WorkspaceSnapshot {
19839 workspace_id: WorkspaceId::new("primary").unwrap(),
19840 canonical_root: opaque_windows_path(
19841 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
19842 ),
19843 sessions: vec![session],
19844 worktree_service_mode: None,
19845 managed_worktree_profiles: None,
19846 }],
19847 session_records: vec![bound_record],
19848 managed_worktrees: Vec::new(),
19849 launch_inventory: None,
19850 agent_progress: Vec::new(),
19851 };
19852
19853 let mut negotiated = original.clone();
19854 project_snapshot_history_for_wire(&mut negotiated, true);
19855 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
19856 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
19857 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
19858 assert!(!serde_json::to_string(&negotiated)
19859 .unwrap()
19860 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
19861
19862 let mut legacy = original;
19863 project_snapshot_history_for_wire(&mut legacy, false);
19864 clear_snapshot_context_packs(&mut legacy);
19865 assert_eq!(
19866 legacy.workspaces[0].sessions[0].history,
19867 gate4agent_types::HistorySnapshot::default(),
19868 );
19869 assert!(legacy.session_records[0].context_id.is_none());
19870 assert!(legacy.session_records[0].context.is_none());
19871 let encoded = serde_json::to_string(&legacy).unwrap();
19872 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
19873 assert!(!encoded.contains("context-review"));
19874
19875 let mut reply = ResponseEnvelope {
19876 request_id: 7,
19877 result: Ok(NodeResponse::ContextPackExported { context }),
19878 };
19879 project_response_without_context_pack(&mut reply);
19880 assert_eq!(
19881 reply.result.unwrap_err().code,
19882 NodeFailureCode::UnsupportedCapability,
19883 );
19884 }
19885
19886 #[test]
19887 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
19888 let shared = terminal_test_shared();
19889 let history = HistorySessionRecord {
19890 session_id: "fixture-history".to_owned(),
19891 title: None,
19892 cwd: None,
19893 model: None,
19894 message_count: 1,
19895 completed_turn_count: None,
19896 total_tokens: 0,
19897 messages: vec![gate4agent_types::HistoryMessageRecord {
19898 role: gate4agent_types::HistoryMessageRole::User,
19899 text: "bounded handoff".to_owned(),
19900 }],
19901 };
19902 let pack = NodeContextPack::export(
19903 ContextPackLineageReceipt {
19904 source_node_id: shared.node_id.clone(),
19905 source_session: terminal_address(1),
19906 source_provider: agent("claude"),
19907 },
19908 &history,
19909 )
19910 .unwrap();
19911 let context = shared
19912 .context_catalog
19913 .write()
19914 .unwrap_or_else(|poisoned| poisoned.into_inner())
19915 .insert(pack)
19916 .unwrap();
19917 let mut retained = record("codex", "context-retained-record");
19918 retained.context_id = Some(context.id.clone());
19919 retained.context = Some(context.clone());
19920 let record_id = retained.record_id.clone();
19921 shared.insert_record(retained).unwrap();
19922 assert_eq!(
19923 shared.forget_context_pack(&context.id).unwrap_err().code,
19924 NodeFailureCode::ContextPackBusy,
19925 );
19926 assert!(shared
19927 .context_catalog
19928 .read()
19929 .unwrap_or_else(|poisoned| poisoned.into_inner())
19930 .get(&context.id)
19931 .is_some());
19932 shared
19933 .session_records
19934 .lock()
19935 .unwrap_or_else(|poisoned| poisoned.into_inner())
19936 .records
19937 .remove(&record_id);
19938
19939 let mut spec = spawn_spec_fixture();
19940 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
19941 value: context.id.clone(),
19942 };
19943 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
19944 let receipt = resolved.receipt_with_materialization(
19945 shared.incarnation_id,
19946 terminal_address(2),
19947 None,
19948 None,
19949 Some(context.clone()),
19950 );
19951 assert!(receipt.context_binding_is_valid());
19952 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
19953
19954 shared.forget_context_pack(&context.id).unwrap();
19955 assert!(shared
19956 .context_catalog
19957 .read()
19958 .unwrap_or_else(|poisoned| poisoned.into_inner())
19959 .get(&context.id)
19960 .is_none());
19961 assert!(shared
19962 .replay_spawn_spec(&spec, Instant::now())
19963 .unwrap()
19964 .is_none());
19965 }
19966
19967 #[test]
19968 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
19969 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
19970 let address = terminal_address(1);
19971 let selection = shared
19972 .select_environment_profile(
19973 address.session.instance_id,
19974 &agent("claude"),
19975 SessionMode::Pty,
19976 Some(&environment_profile),
19977 )
19978 .unwrap()
19979 .unwrap();
19980 shared.bind_session_with_policy(
19981 &address,
19982 ProviderRuntimePolicy::raw_pty(),
19983 Some(environment_profile.clone()),
19984 );
19985 selection.retain();
19986 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
19987 assert!(matches!(
19988 runtime.remove_native_launch_profile(&native_id),
19989 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
19990 ));
19991 assert!(shared.remove_binding(&address).is_some());
19992 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
19993
19994 let mismatched = ResolvedEnvironmentProfileReceipt {
19995 profile_id: environment_profile.profile_id,
19996 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
19997 "local-claude-r2",
19998 )
19999 .unwrap(),
20000 network_allowlist: None,
20001 browser_profile_id: None,
20002 };
20003 let error = match shared.select_environment_profile(
20004 AgentInstanceId(42),
20005 &agent("claude"),
20006 SessionMode::Pty,
20007 Some(&mismatched),
20008 ) {
20009 Ok(_) => panic!("mismatched environment profile revision was selected"),
20010 Err(error) => error,
20011 };
20012 assert_eq!(
20013 error.code,
20014 NodeFailureCode::EnvironmentProfileBindingMismatch,
20015 );
20016 assert!(!shared
20017 .session_bindings
20018 .lock()
20019 .unwrap_or_else(|poisoned| poisoned.into_inner())
20020 .contains_key(&AgentInstanceId(42)));
20021 }
20022
20023 #[test]
20024 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
20025 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20026 let address = terminal_address(1);
20027 let selection = shared
20028 .select_environment_profile(
20029 address.session.instance_id,
20030 &agent("claude"),
20031 SessionMode::Pty,
20032 Some(&environment_profile),
20033 )
20034 .unwrap()
20035 .unwrap();
20036 let overlay = NativeInstanceLaunchOverlay::new(
20037 agent("claude"),
20038 TransportKind::Pty,
20039 vec![EnvMutation {
20040 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
20041 value: Some(OsString::from("fixture-secret")),
20042 }],
20043 vec![
20044 OsString::from("--plugin-dir"),
20045 std::env::current_dir().unwrap().into_os_string(),
20046 ],
20047 )
20048 .unwrap();
20049 let instance_overlay = shared
20050 .install_prepared_launch_overlay(
20051 address.session.instance_id,
20052 PreparedNativeLaunchOverlay::Instance(overlay),
20053 )
20054 .unwrap()
20055 .unwrap();
20056 let bundle = ResolvedBundleReceipt {
20057 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
20058 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
20059 digest: crate::protocol::SpawnBundleDigest::new(format!(
20060 "sha256:{}",
20061 "0".repeat(64),
20062 ))
20063 .unwrap(),
20064 };
20065 shared.bind_session_with_materialization(
20066 &address,
20067 ProviderRuntimePolicy::raw_pty(),
20068 Some(environment_profile),
20069 Some(bundle),
20070 None,
20071 None,
20072 );
20073 selection.retain();
20074 instance_overlay.retain();
20075
20076 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20077 assert!(matches!(
20078 runtime.remove_native_launch_profile(&native_id),
20079 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20080 ));
20081 assert!(shared.remove_binding(&address).is_some());
20082 assert!(!shared
20083 .native_launch_profile_control
20084 .as_ref()
20085 .unwrap()
20086 .clear_native_instance_launch_overlay(address.session.instance_id));
20087 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20088 }
20089
20090 #[test]
20091 fn session_environment_materialization_failure_is_pre_child_and_durable() {
20092 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20093 let address = SessionAddress {
20094 workspace_id: WorkspaceId::new("primary").unwrap(),
20095 session: SessionKey {
20096 instance_id: AgentInstanceId(201),
20097 generation: SessionGeneration(1),
20098 },
20099 };
20100 let error = match shared.prepare_session_materialization(
20101 &address,
20102 &agent("claude"),
20103 SessionMode::Pty,
20104 Some(&receipt),
20105 None,
20106 None,
20107 None,
20108 ) {
20109 Ok(_) => panic!("denied local reference unexpectedly materialized"),
20110 Err(error) => error,
20111 };
20112 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20113 assert!(shared.handle.snapshot().sessions.is_empty());
20114 assert!(shared
20115 .session_bindings
20116 .lock()
20117 .unwrap_or_else(|poisoned| poisoned.into_inner())
20118 .is_empty());
20119 assert!(shared
20120 .materializations
20121 .lock()
20122 .unwrap_or_else(|poisoned| poisoned.into_inner())
20123 .is_empty());
20124 drop(shared);
20125 drop(runtime);
20126 std::fs::remove_dir_all(root).unwrap();
20127 }
20128
20129 #[test]
20130 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
20131 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20132 let address = SessionAddress {
20133 workspace_id: WorkspaceId::new("primary").unwrap(),
20134 session: SessionKey {
20135 instance_id: AgentInstanceId(202),
20136 generation: SessionGeneration(1),
20137 },
20138 };
20139 let (overlay, materialization_guard) = shared
20140 .prepare_session_materialization(
20141 &address,
20142 &agent("claude"),
20143 SessionMode::Pty,
20144 Some(&receipt),
20145 None,
20146 None,
20147 None,
20148 )
20149 .unwrap()
20150 .unwrap();
20151 let materialization_id = materialization_guard.id().unwrap().clone();
20152 let materialization_root = shared
20153 .materializations
20154 .lock()
20155 .unwrap_or_else(|poisoned| poisoned.into_inner())
20156 .get(&materialization_id)
20157 .unwrap()
20158 .root()
20159 .to_path_buf();
20160 let selection = shared
20161 .select_environment_profile(
20162 address.session.instance_id,
20163 &agent("claude"),
20164 SessionMode::Pty,
20165 Some(&receipt),
20166 )
20167 .unwrap()
20168 .unwrap();
20169 assert!(shared
20170 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20171 .unwrap()
20172 .is_none());
20173 shared.bind_session_with_materialization(
20174 &address,
20175 ProviderRuntimePolicy::raw_pty(),
20176 Some(receipt),
20177 None,
20178 None,
20179 Some(materialization_id),
20180 );
20181 selection.retain();
20182 materialization_guard.retain();
20183
20184 assert!(materialization_root.is_dir());
20185 assert!(shared
20186 .session_bindings
20187 .lock()
20188 .unwrap_or_else(|poisoned| poisoned.into_inner())
20189 .contains_key(&address.session.instance_id));
20190
20191 let removed = shared.remove_binding(&address).unwrap();
20192 shared
20193 .cleanup_session_owned_materialization(&address, &removed)
20194 .unwrap();
20195 assert!(!materialization_root.exists());
20196 assert!(shared
20197 .materializations
20198 .lock()
20199 .unwrap_or_else(|poisoned| poisoned.into_inner())
20200 .is_empty());
20201 drop(shared);
20202 drop(runtime);
20203 std::fs::remove_dir_all(root).unwrap();
20204 }
20205
20206 #[tokio::test]
20207 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
20208 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20209 let address = SessionAddress {
20210 workspace_id: WorkspaceId::new("primary").unwrap(),
20211 session: SessionKey {
20212 instance_id: AgentInstanceId(203),
20213 generation: SessionGeneration(1),
20214 },
20215 };
20216 let (overlay, materialization_guard) = shared
20217 .prepare_session_materialization(
20218 &address,
20219 &agent("claude"),
20220 SessionMode::Pty,
20221 Some(&receipt),
20222 None,
20223 None,
20224 None,
20225 )
20226 .unwrap()
20227 .unwrap();
20228 let materialization_id = materialization_guard.id().unwrap().clone();
20229 let materialization_root = shared
20230 .materializations
20231 .lock()
20232 .unwrap_or_else(|poisoned| poisoned.into_inner())
20233 .get(&materialization_id)
20234 .unwrap()
20235 .root()
20236 .to_path_buf();
20237 let selection = shared
20238 .select_environment_profile(
20239 address.session.instance_id,
20240 &agent("claude"),
20241 SessionMode::Pty,
20242 Some(&receipt),
20243 )
20244 .unwrap()
20245 .unwrap();
20246 assert!(shared
20247 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20248 .unwrap()
20249 .is_none());
20250 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20251 let record_id = shared
20252 .bind_spawn_session_with_materialization(
20253 &address,
20254 agent("claude"),
20255 SessionMode::Pty,
20256 policy,
20257 SpawnRecordPolicy::ProviderIdentityOnly,
20258 Some(receipt.clone()),
20259 None,
20260 None,
20261 Some(materialization_id.clone()),
20262 )
20263 .unwrap()
20264 .unwrap();
20265 selection.retain();
20266 materialization_guard.retain();
20267 assert_eq!(
20268 shared
20269 .materializations
20270 .lock()
20271 .unwrap_or_else(|poisoned| poisoned.into_inner())
20272 .get(&materialization_id)
20273 .unwrap()
20274 .owner(),
20275 &MaterializationOwner::Record {
20276 record_id: record_id.clone(),
20277 },
20278 );
20279
20280 let removed = shared.remove_binding(&address).unwrap();
20281 shared
20282 .cleanup_session_owned_materialization(&address, &removed)
20283 .unwrap();
20284 assert!(materialization_root.is_dir());
20285 assert!(shared
20286 .resolve_record_materialization(
20287 &record_id,
20288 &agent("claude"),
20289 SessionMode::Pty,
20290 Some(&receipt),
20291 None,
20292 None,
20293 )
20294 .unwrap()
20295 .is_some());
20296 {
20297 let mut records = shared
20298 .session_records
20299 .lock()
20300 .unwrap_or_else(|poisoned| poisoned.into_inner());
20301 let record = records.records.get_mut(&record_id).unwrap();
20302 record.active_session = None;
20303 record.state = ManagedSessionState::Unavailable;
20304 }
20305 shared.forget_session_record(&record_id).await.unwrap();
20306 assert!(!materialization_root.exists());
20307 assert!(shared
20308 .materializations
20309 .lock()
20310 .unwrap_or_else(|poisoned| poisoned.into_inner())
20311 .is_empty());
20312 drop(shared);
20313 drop(runtime);
20314 std::fs::remove_dir_all(root).unwrap();
20315 }
20316
20317 #[test]
20318 fn record_materialization_revalidation_failure_persists_recovery_required() {
20319 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20320 let state_path = root.join("node-state-v6.json");
20321 shared.state_path = Some(state_path.clone());
20322 let (_address, _record_id, materialization_id, materialization_root) =
20323 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
20324 std::fs::write(
20325 materialization_root.join(".gate4agent-materialization-owner"),
20326 b"tampered",
20327 )
20328 .unwrap();
20329
20330 let error = match shared.resolve_record_materialization(
20331 &_record_id,
20332 &agent("claude"),
20333 SessionMode::Pty,
20334 Some(&receipt),
20335 None,
20336 None,
20337 ) {
20338 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
20339 Err(error) => error,
20340 };
20341 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20342 assert_eq!(
20343 shared
20344 .materializations
20345 .lock()
20346 .unwrap_or_else(|poisoned| poisoned.into_inner())
20347 .get(&materialization_id)
20348 .unwrap()
20349 .state(),
20350 MaterializationState::RecoveryRequired,
20351 );
20352 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
20353 assert_eq!(loaded.materializations.len(), 1);
20354 assert_eq!(
20355 loaded.materializations[0].state(),
20356 MaterializationState::RecoveryRequired,
20357 );
20358 drop(shared);
20359 drop(runtime);
20360 std::fs::remove_dir_all(root).unwrap();
20361 }
20362
20363 #[test]
20364 fn transient_record_materialization_resolution_failure_remains_ready() {
20365 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
20366 let (_address, record_id, materialization_id, _materialization_root) =
20367 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
20368 deny.store(true, Ordering::Release);
20369
20370 let error = match shared.resolve_record_materialization(
20371 &record_id,
20372 &agent("claude"),
20373 SessionMode::Pty,
20374 Some(&receipt),
20375 None,
20376 None,
20377 ) {
20378 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
20379 Err(error) => error,
20380 };
20381 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20382 assert_eq!(
20383 shared
20384 .materializations
20385 .lock()
20386 .unwrap_or_else(|poisoned| poisoned.into_inner())
20387 .get(&materialization_id)
20388 .unwrap()
20389 .state(),
20390 MaterializationState::Ready,
20391 );
20392 drop(shared);
20393 drop(runtime);
20394 std::fs::remove_dir_all(root).unwrap();
20395 }
20396
20397 #[test]
20398 fn startup_missing_record_materialization_is_not_resumable() {
20399 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20400 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
20401 shared
20402 .insert_record(ManagedSessionRecord {
20403 record_id: record_id.clone(),
20404 display_name: "missing materialization".to_owned(),
20405 provider: agent("claude"),
20406 mode: SessionMode::Pty,
20407 state: ManagedSessionState::Dormant,
20408 workspace_id: WorkspaceId::new("primary").unwrap(),
20409 canonical_root: opaque_windows_path(
20410 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20411 ),
20412 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
20413 key: gate4agent_types::ProviderSessionKey::SessionId,
20414 id: "provider-session-missing-materialization".to_owned(),
20415 transcript_path: None,
20416 }),
20417 active_session: None,
20418 environment_profile: Some(receipt),
20419 bundle: None,
20420 context_id: None,
20421 context: None,
20422 exported_context: None,
20423 task_binding: None,
20424 created_at_unix_ms: 1,
20425 updated_at_unix_ms: 1,
20426 last_error: None,
20427 })
20428 .unwrap();
20429
20430 shared.reconcile_materializations();
20431 let record = shared.record(&record_id).unwrap();
20432 assert_eq!(record.state, ManagedSessionState::Unavailable);
20433 assert!(record.provider_session.is_none());
20434 assert_eq!(
20435 record.last_error.as_deref(),
20436 Some("environment-profile-unavailable"),
20437 );
20438 drop(shared);
20439 drop(runtime);
20440 std::fs::remove_dir_all(root).unwrap();
20441 }
20442
20443 #[tokio::test]
20444 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
20445 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20446 let lease = install_managed_lease(&shared);
20447 let address = SessionAddress {
20448 workspace_id: WorkspaceId::new("primary").unwrap(),
20449 session: SessionKey {
20450 instance_id: AgentInstanceId(204),
20451 generation: SessionGeneration(1),
20452 },
20453 };
20454 let (overlay, materialization_guard) = shared
20455 .prepare_session_materialization(
20456 &address,
20457 &agent("claude"),
20458 SessionMode::Pty,
20459 Some(&receipt),
20460 None,
20461 None,
20462 Some(lease.lease_id.clone()),
20463 )
20464 .unwrap()
20465 .unwrap();
20466 let materialization_id = materialization_guard.id().unwrap().clone();
20467 let materialization_root = shared
20468 .materializations
20469 .lock()
20470 .unwrap_or_else(|poisoned| poisoned.into_inner())
20471 .get(&materialization_id)
20472 .unwrap()
20473 .root()
20474 .to_path_buf();
20475 let selection = shared
20476 .select_environment_profile(
20477 address.session.instance_id,
20478 &agent("claude"),
20479 SessionMode::Pty,
20480 Some(&receipt),
20481 )
20482 .unwrap()
20483 .unwrap();
20484 assert!(shared
20485 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20486 .unwrap()
20487 .is_none());
20488 shared.bind_session_with_materialization(
20489 &address,
20490 ProviderRuntimePolicy::raw_pty(),
20491 Some(receipt),
20492 None,
20493 None,
20494 Some(materialization_id),
20495 );
20496 selection.retain();
20497 materialization_guard.retain();
20498
20499 let blocked = shared
20500 .cleanup_managed_worktree(&lease.lease_id, false)
20501 .await
20502 .unwrap_err();
20503 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
20504 assert!(materialization_root.is_dir());
20505 let removed = shared.remove_binding(&address).unwrap();
20506 shared
20507 .cleanup_session_owned_materialization(&address, &removed)
20508 .unwrap();
20509 assert!(!materialization_root.exists());
20510 drop(shared);
20511 drop(runtime);
20512 std::fs::remove_dir_all(root).unwrap();
20513 }
20514
20515 #[tokio::test]
20516 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
20517 let git_root = temporary_workspace_root("raw-managed-inventory");
20518 let source = git_root.join("source");
20519 let allocation = git_root.join("allocation");
20520 std::fs::create_dir_all(&source).unwrap();
20521 std::fs::create_dir_all(&allocation).unwrap();
20522 run_test_git(&source, &["init"]);
20523 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
20524 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
20525 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
20526 run_test_git(&source, &["add", "seed.txt"]);
20527 run_test_git(&source, &["commit", "-m", "seed"]);
20528 let profile = ManagedWorktreeProfile::new(
20529 WorktreeProfileId::new("default").unwrap(),
20530 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20531 &allocation,
20532 "gate4agent",
20533 "HEAD",
20534 ManagedWorktreeRetention::RemoveWhenReleased,
20535 )
20536 .unwrap();
20537 let workspace = WorkspaceConfig::new(
20538 WorkspaceId::new("primary").unwrap(),
20539 &source,
20540 )
20541 .unwrap()
20542 .with_worktree_service_mode(WorktreeServiceMode::Managed)
20543 .with_managed_worktree_profile(profile.clone())
20544 .unwrap();
20545 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
20546 materialization_test_shared_with_workspace(false, workspace);
20547 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
20548 let source_text = source.to_string_lossy().into_owned();
20549 let target_text = target.to_string_lossy().into_owned();
20550 let created = create_git_worktree(
20551 &source_text,
20552 &target_text,
20553 "gate4agent/mw-raw-inventory",
20554 Some("HEAD"),
20555 )
20556 .await
20557 .unwrap();
20558 let lease = ManagedWorktreeLeaseRecord {
20559 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
20560 source_workspace_id: WorkspaceId::new("primary").unwrap(),
20561 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
20562 profile_id: profile.profile_id().clone(),
20563 profile_revision: profile.revision().clone(),
20564 target_root: created.path.clone(),
20565 branch: "gate4agent/mw-raw-inventory".to_owned(),
20566 base_commit: created.head.clone(),
20567 expected_head: Some(created.head.clone()),
20568 retention: ManagedWorktreeRetention::RemoveWhenReleased,
20569 state: ManagedWorktreeLeaseState::Ready,
20570 session_holders: Vec::new(),
20571 record_holders: Vec::new(),
20572 cleanup_failure: None,
20573 created_at_unix_ms: 1,
20574 updated_at_unix_ms: 1,
20575 };
20576 shared
20577 .workspaces
20578 .write()
20579 .unwrap_or_else(|poisoned| poisoned.into_inner())
20580 .insert(lease.workspace_id.clone(), created.path.clone());
20581 *shared
20582 .managed_worktrees
20583 .lock()
20584 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20585 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
20586 let address = SessionAddress {
20587 workspace_id: lease.workspace_id.clone(),
20588 session: SessionKey {
20589 instance_id: AgentInstanceId(205),
20590 generation: SessionGeneration(1),
20591 },
20592 };
20593 let (overlay, materialization_guard) = shared
20594 .prepare_session_materialization(
20595 &address,
20596 &agent("claude"),
20597 SessionMode::Pty,
20598 Some(&receipt),
20599 None,
20600 None,
20601 Some(lease.lease_id.clone()),
20602 )
20603 .unwrap()
20604 .unwrap();
20605 let materialization_id = materialization_guard.id().unwrap().clone();
20606 let materialization_root = shared
20607 .materializations
20608 .lock()
20609 .unwrap_or_else(|poisoned| poisoned.into_inner())
20610 .get(&materialization_id)
20611 .unwrap()
20612 .root()
20613 .to_path_buf();
20614 let selection = shared
20615 .select_environment_profile(
20616 address.session.instance_id,
20617 &agent("claude"),
20618 SessionMode::Pty,
20619 Some(&receipt),
20620 )
20621 .unwrap()
20622 .unwrap();
20623 assert!(shared
20624 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20625 .unwrap()
20626 .is_none());
20627 let record_id = shared
20628 .bind_spawn_session_with_materialization(
20629 &address,
20630 agent("claude"),
20631 SessionMode::Pty,
20632 ProviderRuntimePolicy::raw_pty(),
20633 SpawnRecordPolicy::Always,
20634 Some(receipt.clone()),
20635 None,
20636 None,
20637 Some(materialization_id.clone()),
20638 )
20639 .unwrap()
20640 .unwrap();
20641 selection.retain();
20642 materialization_guard.retain();
20643 let record = shared.record(&record_id).unwrap();
20644 assert_eq!(record.state, ManagedSessionState::Live);
20645 assert!(record.provider_session.is_none());
20646 assert!(record.environment_profile.is_none());
20647 assert!(record.bundle.is_none());
20648 assert!(record.context_id.is_none());
20649 assert!(record.context.is_none());
20650 assert_eq!(record.active_session.as_ref(), Some(&address));
20651 assert_eq!(
20652 shared
20653 .materializations
20654 .lock()
20655 .unwrap_or_else(|poisoned| poisoned.into_inner())
20656 .get(&materialization_id)
20657 .unwrap()
20658 .owner(),
20659 &MaterializationOwner::Session {
20660 incarnation_id: shared.incarnation_id,
20661 instance_id: address.session.instance_id,
20662 generation: address.session.generation,
20663 },
20664 );
20665 {
20666 let mut bindings = shared
20667 .session_bindings
20668 .lock()
20669 .unwrap_or_else(|poisoned| poisoned.into_inner());
20670 bindings
20671 .get_mut(&address.session.instance_id)
20672 .unwrap()
20673 .managed_worktree_lease_id = Some(lease.lease_id.clone());
20674 }
20675 let lease_snapshot = shared
20676 .managed_worktrees
20677 .lock()
20678 .unwrap_or_else(|poisoned| poisoned.into_inner())
20679 .bind_session(
20680 &lease.lease_id,
20681 ManagedWorktreeSessionHolder {
20682 incarnation_id: shared.incarnation_id,
20683 instance_id: address.session.instance_id,
20684 generation: address.session.generation,
20685 },
20686 None,
20687 unix_time_ms(),
20688 )
20689 .unwrap();
20690 assert_eq!(lease_snapshot.active_session_count, 1);
20691 assert_eq!(lease_snapshot.managed_record_count, 0);
20692 shared.persist_state().unwrap();
20693 assert!(shared
20694 .managed_worktrees
20695 .lock()
20696 .unwrap_or_else(|poisoned| poisoned.into_inner())
20697 .get(&lease.lease_id)
20698 .unwrap()
20699 .record_holders
20700 .is_empty());
20701 shared
20702 .handle
20703 .dispatch(shared.prepare_command(ControlCommand::Register {
20704 instance_id: address.session.instance_id,
20705 agent_id: agent("claude"),
20706 transport: TransportKind::Pty,
20707 }))
20708 .unwrap();
20709 runtime.tick().await;
20710 shared.reconcile_managed_record(
20711 &record_id,
20712 &address,
20713 &ControlEvent {
20714 sequence: 1,
20715 command_id: None,
20716 instance_id: address.session.instance_id,
20717 generation: address.session.generation,
20718 event: ControlEventKind::Removed,
20719 },
20720 false,
20721 false,
20722 );
20723 let remove = shared.remove_session(&address);
20724 let drive_runtime = async {
20725 runtime.tick().await;
20726 };
20727 let (removed, ()) = tokio::join!(remove, drive_runtime);
20728 removed.unwrap();
20729
20730 assert!(!materialization_root.exists());
20731 assert!(!Path::new(&created.path).exists());
20732 assert_eq!(
20733 shared
20734 .managed_worktrees
20735 .lock()
20736 .unwrap_or_else(|poisoned| poisoned.into_inner())
20737 .get(&lease.lease_id)
20738 .unwrap()
20739 .state,
20740 ManagedWorktreeLeaseState::Removed,
20741 );
20742 let renamed = shared
20743 .rename_session_record(&record_id, "raw managed inventory".to_owned())
20744 .unwrap();
20745 assert_eq!(renamed.record_id, record_id);
20746 assert_eq!(renamed.display_name, "raw managed inventory");
20747 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
20748 assert!(renamed.active_session.is_none());
20749
20750 drop(shared);
20751 drop(runtime);
20752 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
20753 std::fs::remove_dir_all(git_root).unwrap();
20754 }
20755
20756 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
20757 let root = std::env::current_dir().unwrap()
20758 .join("managed-test-target")
20759 .to_string_lossy()
20760 .into_owned();
20761 let lease = ManagedWorktreeLeaseRecord {
20762 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
20763 source_workspace_id: WorkspaceId::new("primary").unwrap(),
20764 workspace_id: WorkspaceId::new("managed-test").unwrap(),
20765 profile_id: WorktreeProfileId::new("default").unwrap(),
20766 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20767 target_root: root.clone(),
20768 branch: "gate4agent/mw-test".to_owned(),
20769 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
20770 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
20771 retention: ManagedWorktreeRetention::RemoveWhenReleased,
20772 state: ManagedWorktreeLeaseState::Ready,
20773 session_holders: Vec::new(),
20774 record_holders: Vec::new(),
20775 cleanup_failure: None,
20776 created_at_unix_ms: 1,
20777 updated_at_unix_ms: 1,
20778 };
20779 shared.workspaces.write()
20780 .unwrap_or_else(|poisoned| poisoned.into_inner())
20781 .insert(lease.workspace_id.clone(), root);
20782 *shared.managed_worktrees.lock()
20783 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20784 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
20785 lease
20786 }
20787
20788 #[cfg(feature = "fixture")]
20789 #[test]
20790 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
20791 let mut shared = terminal_test_shared();
20792 shared.fixture_semantic_hook_policy = true;
20793 let lease = install_managed_lease(&shared);
20794 let address = SessionAddress {
20795 workspace_id: lease.workspace_id.clone(),
20796 session: SessionKey {
20797 instance_id: AgentInstanceId(206),
20798 generation: SessionGeneration(1),
20799 },
20800 };
20801 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
20802 let effective_runtime_policy =
20803 shared.effective_spawn_runtime_policy(pre_admission_policy);
20804 assert_ne!(effective_runtime_policy, pre_admission_policy);
20805 assert!(effective_runtime_policy.provider_session_identity);
20806
20807 let record_id = shared
20808 .bind_spawn_session(
20809 &address,
20810 agent("claude"),
20811 SessionMode::Pty,
20812 effective_runtime_policy,
20813 SpawnRecordPolicy::Always,
20814 None,
20815 )
20816 .unwrap()
20817 .unwrap();
20818 assert_eq!(
20819 shared.record(&record_id).unwrap().state,
20820 ManagedSessionState::IdentityPending,
20821 );
20822 let (record_holder, raw_inventory_record_id) = {
20823 let mut bindings = shared
20824 .session_bindings
20825 .lock()
20826 .unwrap_or_else(|poisoned| poisoned.into_inner());
20827 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
20828 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
20829 managed_spawn_record_ownership(binding, effective_runtime_policy)
20830 };
20831 assert_eq!(record_holder.as_ref(), Some(&record_id));
20832 assert!(raw_inventory_record_id.is_none());
20833 let snapshot = shared
20834 .managed_worktrees
20835 .lock()
20836 .unwrap_or_else(|poisoned| poisoned.into_inner())
20837 .bind_session(
20838 &lease.lease_id,
20839 ManagedWorktreeSessionHolder {
20840 incarnation_id: shared.incarnation_id,
20841 instance_id: address.session.instance_id,
20842 generation: address.session.generation,
20843 },
20844 record_holder,
20845 unix_time_ms(),
20846 )
20847 .unwrap();
20848 assert_eq!(snapshot.managed_record_count, 1);
20849 assert_eq!(
20850 shared
20851 .managed_worktrees
20852 .lock()
20853 .unwrap_or_else(|poisoned| poisoned.into_inner())
20854 .get(&lease.lease_id)
20855 .unwrap()
20856 .record_holders,
20857 vec![record_id.clone()],
20858 );
20859
20860 let identity = ProviderSessionIdentity {
20861 key: ProviderSessionKey::SessionId,
20862 id: "monitoring-fixture-managed-session".to_owned(),
20863 transcript_path: None,
20864 };
20865 shared.publish_control(ControlEvent {
20866 sequence: 1,
20867 command_id: None,
20868 instance_id: address.session.instance_id,
20869 generation: address.session.generation,
20870 event: ControlEventKind::ProviderEvent {
20871 sequence: 1,
20872 source: gate4agent_types::ProviderSource {
20873 family: AdapterFamily::Hook,
20874 binding: AdapterBinding::new(
20875 gate4agent_types::AdapterId::new("claude-code").unwrap(),
20876 "fixture/v1",
20877 gate4agent_types::AdapterVerification::SyntheticFixture,
20878 )
20879 .unwrap(),
20880 },
20881 source_sequence: 1,
20882 event: ProviderEvent::SessionIdentityObserved {
20883 identity: identity.clone(),
20884 },
20885 },
20886 });
20887
20888 let live = shared.record(&record_id).unwrap();
20889 assert_eq!(live.state, ManagedSessionState::Live);
20890 assert_eq!(live.provider_session.as_ref(), Some(&identity));
20891 assert_eq!(live.active_session.as_ref(), Some(&address));
20892 assert_eq!(
20893 shared
20894 .managed_worktrees
20895 .lock()
20896 .unwrap_or_else(|poisoned| poisoned.into_inner())
20897 .get(&lease.lease_id)
20898 .unwrap()
20899 .record_holders,
20900 vec![record_id],
20901 );
20902 }
20903
20904 fn managed_v2_test_shared(
20905 allocation_root: &Path,
20906 state_path: Option<PathBuf>,
20907 profile_revision: &str,
20908 ) -> NodeShared {
20909 let profile = ManagedWorktreeProfile::new(
20910 WorktreeProfileId::new("default").unwrap(),
20911 WorktreeProfileRevision::new(profile_revision).unwrap(),
20912 allocation_root,
20913 "gate4agent",
20914 "HEAD",
20915 ManagedWorktreeRetention::RemoveWhenReleased,
20916 )
20917 .unwrap();
20918 let workspace = WorkspaceConfig::new(
20919 WorkspaceId::new("primary").unwrap(),
20920 std::env::current_dir().unwrap(),
20921 )
20922 .unwrap()
20923 .with_worktree_service_mode(WorktreeServiceMode::Managed)
20924 .with_managed_worktree_profile(profile)
20925 .unwrap();
20926 let catalog = active_registry().unwrap();
20927 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20928 let mut shared = NodeShared::new(
20929 handle,
20930 "fixture-token".to_owned(),
20931 NodeId::new("node-terminal-test").unwrap(),
20932 vec![workspace],
20933 vec![agent("claude")],
20934 );
20935 shared.state_path = state_path;
20936 shared
20937 }
20938
20939 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
20940 let mut spawn_spec = spawn_spec_fixture();
20941 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
20942 ManagedWorktreeSpawnRequestV2 {
20943 spawn_spec,
20944 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
20945 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
20946 }
20947 }
20948
20949 #[test]
20950 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
20951 let shared = terminal_test_shared();
20952 let mut lease = install_managed_lease(&shared);
20953 let session = SessionAddress {
20954 workspace_id: lease.workspace_id.clone(),
20955 session: SessionKey {
20956 instance_id: AgentInstanceId(73),
20957 generation: SessionGeneration(4),
20958 },
20959 };
20960 lease.state = ManagedWorktreeLeaseState::InUse;
20961 lease.session_holders.push(ManagedWorktreeSessionHolder {
20962 incarnation_id: shared.incarnation_id,
20963 instance_id: session.session.instance_id,
20964 generation: session.session.generation,
20965 });
20966 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20967 assert!(resolved.target.worktree_id.is_none());
20968
20969 let receipt = managed_spawn_receipt(
20970 &resolved,
20971 shared.incarnation_id,
20972 session.clone(),
20973 lease.snapshot(),
20974 None,
20975 None,
20976 None,
20977 );
20978 assert!(resolved.target.worktree_id.is_none());
20979 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
20980 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
20981 assert_eq!(receipt.spawn.session, session);
20982 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
20983 let encoded = serde_json::to_vec(&receipt).unwrap();
20984 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
20985 assert_eq!(decoded, receipt);
20986 }
20987
20988 #[tokio::test]
20989 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
20990 let root = temporary_workspace_root("managed-v2-revision");
20991 let allocation = root.join("allocation");
20992 std::fs::create_dir_all(&allocation).unwrap();
20993 let shared = managed_v2_test_shared(
20994 &allocation,
20995 Some(root.join("node-state.json")),
20996 "v1",
20997 );
20998 let mut request = managed_v2_request("managed-v2-revision");
20999 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
21000
21001 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
21002
21003 assert_eq!(
21004 failure.code,
21005 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21006 );
21007 assert!(shared
21008 .managed_worktrees
21009 .lock()
21010 .unwrap_or_else(|poisoned| poisoned.into_inner())
21011 .records()
21012 .is_empty());
21013 assert!(shared
21014 .managed_spawn_replays
21015 .lock()
21016 .unwrap_or_else(|poisoned| poisoned.into_inner())
21017 .is_empty());
21018 std::fs::remove_dir_all(root).unwrap();
21019 }
21020
21021 #[test]
21022 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
21023 let root = temporary_workspace_root("managed-v2-concurrent");
21024 let allocation = root.join("allocation");
21025 std::fs::create_dir_all(&allocation).unwrap();
21026 let shared = Arc::new(managed_v2_test_shared(
21027 &allocation,
21028 Some(root.join("node-state.json")),
21029 "v1",
21030 ));
21031 let request = managed_v2_request("managed-v2-concurrent");
21032 let barrier = Arc::new(std::sync::Barrier::new(3));
21033 let mut joins = Vec::new();
21034 for _ in 0..2 {
21035 let shared = Arc::clone(&shared);
21036 let request = request.clone();
21037 let barrier = Arc::clone(&barrier);
21038 joins.push(std::thread::spawn(move || {
21039 let digest = managed_spawn_request_digest_v2(&request).unwrap();
21040 barrier.wait();
21041 shared.acquire_durable_managed_spawn_v2(&request, digest)
21042 }));
21043 }
21044 barrier.wait();
21045 let results = joins
21046 .into_iter()
21047 .map(|join| join.join().unwrap())
21048 .collect::<Vec<_>>();
21049 assert_eq!(
21050 results
21051 .iter()
21052 .filter(|result| {
21053 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
21054 })
21055 .count(),
21056 1,
21057 );
21058 assert_eq!(
21059 results
21060 .iter()
21061 .filter(|result| {
21062 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
21063 })
21064 .count(),
21065 1,
21066 );
21067 assert_eq!(
21068 shared
21069 .managed_spawn_replays
21070 .lock()
21071 .unwrap_or_else(|poisoned| poisoned.into_inner())
21072 .len(),
21073 1,
21074 );
21075 assert!(shared
21076 .managed_worktrees
21077 .lock()
21078 .unwrap_or_else(|poisoned| poisoned.into_inner())
21079 .records()
21080 .is_empty());
21081 drop(shared);
21082 std::fs::remove_dir_all(root).unwrap();
21083 }
21084
21085 #[tokio::test]
21086 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
21087 let root = temporary_workspace_root("managed-v2-linked-pending");
21088 let allocation = root.join("allocation");
21089 std::fs::create_dir_all(&allocation).unwrap();
21090 let state_path = root.join("node-state.json");
21091 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21092 let lease = install_managed_lease(&shared);
21093 let request = managed_v2_request("managed-v2-linked-pending");
21094 assert!(matches!(
21095 shared
21096 .acquire_durable_managed_spawn_v2(
21097 &request,
21098 managed_spawn_request_digest_v2(&request).unwrap(),
21099 )
21100 .unwrap(),
21101 DurableManagedSpawnReplayDecision::Reserved,
21102 ));
21103 {
21104 let _transaction = shared
21105 .state_transaction
21106 .lock()
21107 .unwrap_or_else(|poisoned| poisoned.into_inner());
21108 shared
21109 .link_durable_managed_spawn_v2_lease_locked(
21110 &request.spawn_spec.idempotency_key,
21111 &lease.lease_id,
21112 )
21113 .unwrap();
21114 shared.persist_state_locked().unwrap();
21115 }
21116 drop(shared);
21117
21118 let mut loaded = session_registry::load(
21119 Some(&state_path),
21120 &NodeId::new("node-terminal-test").unwrap(),
21121 )
21122 .unwrap();
21123 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21124 *reopened
21125 .managed_worktrees
21126 .lock()
21127 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21128 ManagedWorktreeRegistry::from_records(
21129 std::mem::take(&mut loaded.managed_worktrees),
21130 std::mem::take(&mut loaded.managed_worktree_tombstones),
21131 )
21132 .unwrap();
21133 *reopened
21134 .managed_spawn_replays
21135 .lock()
21136 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21137 .managed_spawn_replays
21138 .into_iter()
21139 .map(|record| (record.idempotency_key.clone(), record))
21140 .collect();
21141 let lease_count = reopened
21142 .managed_worktrees
21143 .lock()
21144 .unwrap_or_else(|poisoned| poisoned.into_inner())
21145 .records()
21146 .len();
21147
21148 let failure = reopened
21149 .spawn_managed_worktree_v2(request)
21150 .await
21151 .unwrap_err();
21152
21153 assert_eq!(
21154 failure.code,
21155 NodeFailureCode::ManagedWorktreeRecoveryRequired,
21156 );
21157 assert_eq!(
21158 reopened
21159 .managed_worktrees
21160 .lock()
21161 .unwrap_or_else(|poisoned| poisoned.into_inner())
21162 .records()
21163 .len(),
21164 lease_count,
21165 );
21166 std::fs::remove_dir_all(root).unwrap();
21167 }
21168
21169 #[tokio::test]
21170 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
21171 let root = temporary_workspace_root("managed-v2-reopen");
21172 let allocation = root.join("allocation");
21173 std::fs::create_dir_all(&allocation).unwrap();
21174 let state_path = root.join("node-state.json");
21175 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21176 let lease = install_managed_lease(&shared);
21177 let request = managed_v2_request("managed-v2-reopen");
21178 let session = SessionAddress {
21179 workspace_id: lease.workspace_id.clone(),
21180 session: SessionKey {
21181 instance_id: AgentInstanceId(91),
21182 generation: SessionGeneration(1),
21183 },
21184 };
21185 let snapshot = {
21186 let mut registry = shared
21187 .managed_worktrees
21188 .lock()
21189 .unwrap_or_else(|poisoned| poisoned.into_inner());
21190 let current = registry.get_mut(&lease.lease_id).unwrap();
21191 current.state = ManagedWorktreeLeaseState::InUse;
21192 current.session_holders.push(ManagedWorktreeSessionHolder {
21193 incarnation_id: shared.incarnation_id,
21194 instance_id: session.session.instance_id,
21195 generation: session.session.generation,
21196 });
21197 current.snapshot()
21198 };
21199 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
21200 let receipt = managed_spawn_receipt(
21201 &resolved,
21202 shared.incarnation_id,
21203 session,
21204 snapshot,
21205 None,
21206 None,
21207 None,
21208 );
21209 assert!(matches!(
21210 shared
21211 .acquire_durable_managed_spawn_v2(
21212 &request,
21213 managed_spawn_request_digest_v2(&request).unwrap(),
21214 )
21215 .unwrap(),
21216 DurableManagedSpawnReplayDecision::Reserved,
21217 ));
21218 {
21219 let _transaction = shared
21220 .state_transaction
21221 .lock()
21222 .unwrap_or_else(|poisoned| poisoned.into_inner());
21223 shared
21224 .link_durable_managed_spawn_v2_lease_locked(
21225 &request.spawn_spec.idempotency_key,
21226 &lease.lease_id,
21227 )
21228 .unwrap();
21229 shared.persist_state_locked().unwrap();
21230 }
21231 shared
21232 .commit_durable_managed_spawn_v2(&request, receipt.clone())
21233 .unwrap();
21234 let legacy_same_key = ManagedWorktreeSpawnRequest {
21235 spawn_spec: request.spawn_spec.clone(),
21236 worktree_profile_id: request.worktree_profile_id.clone(),
21237 };
21238 shared.remember_managed_spawn_attempt(
21239 Some(&request.spawn_spec.idempotency_key),
21240 legacy_same_key.clone(),
21241 Ok(receipt.clone()),
21242 Instant::now(),
21243 );
21244 assert!(shared
21245 .replay_managed_spawn(&legacy_same_key, Instant::now())
21246 .unwrap()
21247 .is_none());
21248 drop(shared);
21249
21250 let mut loaded = session_registry::load(
21251 Some(&state_path),
21252 &NodeId::new("node-terminal-test").unwrap(),
21253 )
21254 .unwrap();
21255 assert_eq!(loaded.managed_spawn_replays.len(), 1);
21256 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21257 *reopened
21258 .managed_worktrees
21259 .lock()
21260 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21261 ManagedWorktreeRegistry::from_records(
21262 std::mem::take(&mut loaded.managed_worktrees),
21263 std::mem::take(&mut loaded.managed_worktree_tombstones),
21264 )
21265 .unwrap();
21266 *reopened
21267 .managed_spawn_replays
21268 .lock()
21269 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21270 .managed_spawn_replays
21271 .into_iter()
21272 .map(|record| (record.idempotency_key.clone(), record))
21273 .collect();
21274 let lease_count_before = reopened
21275 .managed_worktrees
21276 .lock()
21277 .unwrap_or_else(|poisoned| poisoned.into_inner())
21278 .records()
21279 .len();
21280
21281 assert_eq!(
21282 reopened
21283 .spawn_managed_worktree_v2(request.clone())
21284 .await
21285 .unwrap(),
21286 receipt,
21287 );
21288 assert_eq!(
21289 reopened
21290 .managed_worktrees
21291 .lock()
21292 .unwrap_or_else(|poisoned| poisoned.into_inner())
21293 .records()
21294 .len(),
21295 lease_count_before,
21296 );
21297 let mut changed = request;
21298 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
21299 let failure = reopened
21300 .spawn_managed_worktree_v2(changed)
21301 .await
21302 .unwrap_err();
21303 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
21304 assert_eq!(
21305 reopened
21306 .managed_worktrees
21307 .lock()
21308 .unwrap_or_else(|poisoned| poisoned.into_inner())
21309 .records()
21310 .len(),
21311 lease_count_before,
21312 );
21313 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
21314 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
21315 let failure = reopened
21316 .spawn_managed_worktree_v2(new_key)
21317 .await
21318 .unwrap_err();
21319 assert_eq!(
21320 failure.code,
21321 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21322 );
21323 assert_eq!(
21324 reopened
21325 .managed_worktrees
21326 .lock()
21327 .unwrap_or_else(|poisoned| poisoned.into_inner())
21328 .records()
21329 .len(),
21330 lease_count_before,
21331 );
21332 assert_eq!(
21333 reopened
21334 .managed_spawn_replays
21335 .lock()
21336 .unwrap_or_else(|poisoned| poisoned.into_inner())
21337 .len(),
21338 1,
21339 );
21340 std::fs::remove_dir_all(root).unwrap();
21341 }
21342
21343 #[test]
21344 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
21345 let shared = terminal_test_shared();
21346 let request = ManagedWorktreeSpawnRequest {
21347 spawn_spec: spawn_spec_fixture(),
21348 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21349 };
21350 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
21351 shared.remember_managed_spawn(
21352 request.clone(),
21353 Err(expected.clone()),
21354 Instant::now(),
21355 );
21356
21357 assert_eq!(
21358 shared
21359 .replay_managed_spawn(&request, Instant::now())
21360 .unwrap()
21361 .unwrap(),
21362 Err(expected),
21363 );
21364 assert!(shared
21365 .managed_spawn_replays
21366 .lock()
21367 .unwrap_or_else(|poisoned| poisoned.into_inner())
21368 .is_empty());
21369 }
21370
21371 #[tokio::test]
21372 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
21373 let shared = terminal_test_shared();
21374 let lease = install_managed_lease(&shared);
21375 let raw = shared.spawn_session(
21376 lease.workspace_id.clone(),
21377 agent("claude"),
21378 SessionMode::Pty,
21379 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
21380 None,
21381 ).await.unwrap_err();
21382 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21383 let mut spec = spawn_spec_fixture();
21384 spec.target.workspace_id = lease.workspace_id.clone();
21385 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
21386 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21387 let mut selected = spawn_spec_fixture();
21388 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
21389 selected.target.worktree_id = Some(lease.workspace_id);
21390 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
21391 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21392 assert!(shared.handle.snapshot().sessions.is_empty());
21393 }
21394
21395 #[test]
21396 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
21397 let shared = terminal_test_shared();
21398 let lease = install_managed_lease(&shared);
21399 let snapshot = snapshot_for_wire(
21400 &shared, true, true, true, false, true, true, false, false, true,
21401 );
21402 assert!(snapshot.managed_worktrees.is_empty());
21403 assert!(snapshot.workspaces.iter().all(|workspace| {
21404 workspace.managed_worktree_profiles.is_none()
21405 }));
21406 let mut reply = ResponseEnvelope {
21407 request_id: 1,
21408 result: Ok(NodeResponse::Resync {
21409 event_sequence: 1,
21410 oldest_available_sequence: 1,
21411 snapshot: shared.snapshot(),
21412 events: vec![NodeEventEnvelope {
21413 sequence: 1,
21414 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
21415 }],
21416 }),
21417 };
21418 project_response_without_managed_worktrees(&mut reply);
21419 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
21420 panic!("projection changed the response kind");
21421 };
21422 assert!(snapshot.managed_worktrees.is_empty());
21423 assert!(events.is_empty());
21424 let mut git = git_snapshot_for_parser();
21425 git.branch = Some(lease.branch.clone());
21426 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
21427 lease_id: lease.lease_id.clone(),
21428 source_workspace_id: lease.source_workspace_id.clone(),
21429 branch: lease.branch.clone(),
21430 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
21431 active_session_count: 1,
21432 managed_record_count: 0,
21433 });
21434 let mut inspection_reply = ResponseEnvelope {
21435 request_id: 2,
21436 result: Ok(NodeResponse::WorkspaceInspected {
21437 inspection: WorkspaceInspection {
21438 workspace_id: lease.workspace_id.clone(),
21439 entries: Vec::new(),
21440 tree_truncated: false,
21441 git,
21442 truncation: None,
21443 },
21444 }),
21445 };
21446 project_response_without_managed_worktrees(&mut inspection_reply);
21447 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
21448 panic!("projection changed the inspection response kind");
21449 };
21450 assert!(inspection.git.managed_worktree.is_none());
21451 let failure = managed_git_worktree_failure(GitWorktreeError {
21452 kind: GitWorktreeErrorKind::Failed,
21453 message: r"git failed at C:\private\managed-target".to_owned(),
21454 });
21455 assert!(!failure.message.contains("private"));
21456 assert!(!failure.message.contains("managed-target"));
21457 }
21458
21459 #[tokio::test]
21460 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
21461 let root = temporary_workspace_root("managed-restart");
21462 let source = root.join("source");
21463 let allocation = root.join("allocation");
21464 std::fs::create_dir_all(&source).unwrap();
21465 std::fs::create_dir_all(&allocation).unwrap();
21466 run_test_git(&source, &["init"]);
21467 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21468 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21469 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21470 run_test_git(&source, &["add", "seed.txt"]);
21471 run_test_git(&source, &["commit", "-m", "seed"]);
21472 let profile = ManagedWorktreeProfile::new(
21473 WorktreeProfileId::new("default").unwrap(),
21474 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21475 &allocation,
21476 "gate4agent",
21477 "HEAD",
21478 ManagedWorktreeRetention::Retain,
21479 ).unwrap();
21480 let workspace = WorkspaceConfig::new(
21481 WorkspaceId::new("primary").unwrap(),
21482 &source,
21483 ).unwrap()
21484 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21485 .with_managed_worktree_profile(profile.clone()).unwrap();
21486 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
21487 let source_text = workspace.canonical_root().to_owned();
21488 let target_text = target.to_string_lossy().into_owned();
21489 let created = create_git_worktree(
21490 &source_text,
21491 &target_text,
21492 "gate4agent/mw-restart",
21493 Some("HEAD"),
21494 ).await.unwrap();
21495 let lease = ManagedWorktreeLeaseRecord {
21496 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
21497 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21498 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
21499 profile_id: profile.profile_id().clone(),
21500 profile_revision: profile.revision().clone(),
21501 target_root: created.path.clone(),
21502 branch: "gate4agent/mw-restart".to_owned(),
21503 base_commit: created.head.clone(),
21504 expected_head: None,
21505 retention: ManagedWorktreeRetention::Retain,
21506 state: ManagedWorktreeLeaseState::Allocating,
21507 session_holders: Vec::new(),
21508 record_holders: Vec::new(),
21509 cleanup_failure: None,
21510 created_at_unix_ms: 1,
21511 updated_at_unix_ms: 1,
21512 };
21513 let catalog = active_registry().unwrap();
21514 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21515 let shared = NodeShared::new_with_incarnation(
21516 handle,
21517 "fixture-token".to_owned(),
21518 NodeId::new("node-managed-restart").unwrap(),
21519 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
21520 vec![workspace],
21521 vec![agent("claude")],
21522 ProviderRuntimeStatuses::default(),
21523 None,
21524 Vec::new(),
21525 Vec::new(),
21526 SpawnProfileRegistry::default(),
21527 None,
21528 None,
21529 None,
21530 None,
21531 Vec::new(),
21532 vec![lease.clone()],
21533 Vec::new(),
21534 Vec::new(),
21535 None,
21536 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
21537 );
21538 shared.reconcile_managed_worktrees().await;
21539 let retained = shared.managed_worktrees.lock()
21540 .unwrap_or_else(|poisoned| poisoned.into_inner())
21541 .get(&lease.lease_id).unwrap().clone();
21542 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
21543 assert!(shared.workspaces.read()
21544 .unwrap_or_else(|poisoned| poisoned.into_inner())
21545 .contains_key(&lease.workspace_id));
21546 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
21547 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
21548 assert!(!Path::new(&created.path).exists());
21549 std::fs::remove_dir_all(root).unwrap();
21550 }
21551
21552 fn run_test_git(root: &Path, arguments: &[&str]) {
21553 let output = std::process::Command::new("git")
21554 .arg("-C").arg(root).args(arguments).output().unwrap();
21555 assert!(
21556 output.status.success(),
21557 "git {:?} failed: {}",
21558 arguments,
21559 String::from_utf8_lossy(&output.stderr),
21560 );
21561 }
21562
21563 #[tokio::test]
21564 async fn spawn_spec_validation_fails_before_session_mutation() {
21565 let shared = terminal_test_shared();
21566 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21567 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
21568 }));
21569 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21570 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
21571 }));
21572 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21573 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
21574 }));
21575
21576 let mut target_mismatch = spawn_spec_fixture();
21577 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
21578 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
21579 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
21580
21581 let mut unknown_profile = spawn_spec_fixture();
21582 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
21583 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
21584 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
21585 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
21586
21587 let mut revision_mismatch = spawn_spec_fixture();
21588 revision_mismatch.idempotency_key =
21589 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
21590 revision_mismatch.expected_profile_revision =
21591 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
21592 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
21593 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
21594
21595 let mut unsupported_materializer = spawn_spec_fixture();
21596 unsupported_materializer.idempotency_key =
21597 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
21598 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
21599 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
21600 };
21601 let failure = shared
21602 .spawn_from_spec(unsupported_materializer)
21603 .await
21604 .unwrap_err();
21605 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
21606
21607 let failure = shared
21608 .spawn_session_with_deadline(
21609 WorkspaceId::new("primary").unwrap(),
21610 agent("claude"),
21611 SessionMode::Pty,
21612 gate4agent_types::TerminalSize {
21613 rows: 24,
21614 columns: 80,
21615 },
21616 None,
21617 None,
21618 None,
21619 None,
21620 None,
21621 None,
21622 SpawnRecordPolicy::ProviderIdentityOnly,
21623 Some(Instant::now()),
21624 &[],
21625 None,
21626 ApprovalLevel::default(),
21627 None,
21628 None,
21629 )
21630 .await
21631 .unwrap_err();
21632 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
21633 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
21634 assert!(shared.handle.snapshot().sessions.is_empty());
21635 }
21636
21637 #[test]
21638 fn worktree_selection_requires_its_separate_negotiated_capability() {
21639 let mut spec = spawn_spec_fixture();
21640 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
21641 let request = NodeRequest::SpawnSpec { spec };
21642 let spawn_spec_capability =
21643 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
21644 assert!(request_uses_unnegotiated_capability(
21645 &request,
21646 &[spawn_spec_capability.clone()],
21647 ));
21648 let worktree_capability =
21649 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
21650 assert!(request_uses_unnegotiated_capability(
21651 &request,
21652 &[spawn_spec_capability.clone(), worktree_capability.clone()],
21653 ));
21654 let profile_revision_capability =
21655 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
21656 assert!(request_uses_unnegotiated_capability(
21657 &request,
21658 &[
21659 spawn_spec_capability.clone(),
21660 worktree_capability.clone(),
21661 profile_revision_capability.clone(),
21662 ],
21663 ));
21664 let bundle_materialization_capability =
21671 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
21672 assert!(request_uses_unnegotiated_capability(
21673 &request,
21674 &[
21675 spawn_spec_capability.clone(),
21676 worktree_capability.clone(),
21677 profile_revision_capability.clone(),
21678 bundle_materialization_capability.clone(),
21679 ],
21680 ));
21681 let history_context_pack_capability =
21682 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
21683 assert!(!request_uses_unnegotiated_capability(
21684 &request,
21685 &[
21686 spawn_spec_capability,
21687 worktree_capability,
21688 profile_revision_capability,
21689 bundle_materialization_capability,
21690 history_context_pack_capability,
21691 ],
21692 ));
21693 }
21694
21695 #[tokio::test]
21696 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
21697 let mut shared = terminal_test_shared();
21698 let spec = spawn_spec_fixture();
21699 let receipt = shared
21700 .resolve_spawn_spec(&spec)
21701 .unwrap()
21702 .receipt(shared.incarnation_id, terminal_address(1));
21703 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
21704
21705 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
21706 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
21707 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
21708
21709 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
21710
21711 let mut revision_conflicting = spec.clone();
21712 revision_conflicting.expected_profile_revision =
21713 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
21714 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
21715 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
21716
21717 let mut stale_cache_miss = spec.clone();
21718 stale_cache_miss.idempotency_key =
21719 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
21720 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
21721 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
21722
21723 let mut conflicting = spec;
21724 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
21725 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
21726 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
21727 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
21728 assert!(shared.handle.snapshot().sessions.is_empty());
21729 }
21730
21731 #[tokio::test]
21732 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
21733 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
21734 let mut spec = spawn_spec_fixture();
21735 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
21736 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
21737 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
21738
21739 let failure = shared
21740 .create_worktree(
21741 WorkspaceId::new("primary").unwrap(),
21742 WorkspaceId::new("new-worktree").unwrap(),
21743 "not-an-absolute-path".to_owned(),
21744 "invalid branch".to_owned(),
21745 None,
21746 )
21747 .await
21748 .unwrap_err();
21749 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
21750
21751 let failure = shared
21752 .remove_worktree(
21753 WorkspaceId::new("primary").unwrap(),
21754 "not-an-absolute-path".to_owned(),
21755 )
21756 .await
21757 .unwrap_err();
21758 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
21759 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
21760 assert!(shared.handle.snapshot().sessions.is_empty());
21761 }
21762
21763 #[tokio::test]
21764 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
21765 let root = temporary_workspace_root("manual-worktree-compensation");
21766 let source = root.join("source");
21767 let target = root.join("target");
21768 let invalid_state_parent = root.join("state-parent-is-a-file");
21769 let invalid_state_path = invalid_state_parent.join("state.json");
21770 std::fs::create_dir_all(&source).unwrap();
21771 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
21772 run_test_git(&source, &["init"]);
21773 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21774 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21775 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21776 run_test_git(&source, &["add", "seed.txt"]);
21777 run_test_git(&source, &["commit", "-m", "seed"]);
21778
21779 let catalog = active_registry().unwrap();
21780 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21781 let workspace = WorkspaceConfig::new(
21782 WorkspaceId::new("primary").unwrap(),
21783 &source,
21784 ).unwrap();
21785 let mut shared = NodeShared::new(
21786 handle,
21787 "fixture-token".to_owned(),
21788 NodeId::new("node-worktree-compensation").unwrap(),
21789 vec![workspace],
21790 vec![agent("claude")],
21791 );
21792 shared.state_path = Some(invalid_state_path);
21793
21794 let error = shared.create_worktree(
21795 WorkspaceId::new("primary").unwrap(),
21796 WorkspaceId::new("compensated").unwrap(),
21797 target.to_string_lossy().into_owned(),
21798 "gate4agent/compensated".to_owned(),
21799 Some("HEAD".to_owned()),
21800 ).await.unwrap_err();
21801
21802 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21803 assert!(!target.exists());
21804 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
21805 .iter()
21806 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
21807 std::fs::remove_dir_all(root).unwrap();
21808 }
21809
21810 #[test]
21811 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
21812 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
21813 NativeGitWorktreeSnapshot {
21814 path: path.to_owned(),
21815 head: "0123456789abcdef".to_owned(),
21816 branch: Some("refs/heads/test".to_owned()),
21817 is_bare,
21818 is_main,
21819 locked: false,
21820 lock_reason: None,
21821 prunable: false,
21822 prunable_reason: None,
21823 workspace_id: None,
21824 }
21825 }
21826
21827 let source = r"C:\repo";
21828 let selected = r"C:\repo-worktree";
21829 let listed = vec![
21830 worktree(source, true, false),
21831 worktree(selected, false, false),
21832 ];
21833 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
21834
21835 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
21836 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
21837
21838 let bare = vec![
21839 worktree(source, true, false),
21840 worktree(selected, false, true),
21841 ];
21842 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
21843 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
21844
21845 let foreign_failure =
21846 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
21847 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
21848 }
21849
21850 fn terminal_frame(sequence: u64) -> TerminalFrame {
21851 TerminalFrame {
21852 sequence,
21853 size: gate4agent_types::TerminalSize {
21854 rows: 24,
21855 columns: 80,
21856 },
21857 cursor_row: 2,
21858 cursor_column: 3,
21859 contents: format!("frame-{sequence}"),
21860 formatted: vec![1, 2, 3],
21861 scrollback_formatted: Vec::new(),
21862 alternate_screen: false,
21863 mouse_protocol_enabled: false,
21864 mouse_protocol_encoding: Default::default(),
21865 produced_at_unix_ms: 0,
21866 screen_state: gate4agent_types::PtyScreenState::default(),
21867 bracketed_paste: None,
21868 }
21869 }
21870
21871 #[test]
21872 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
21873 let shared = terminal_test_shared();
21874 let mut receiver = shared.terminal_event_tx.subscribe();
21875 let original = terminal_address(1);
21876
21877 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
21878 let first = receiver.try_recv().unwrap();
21879 assert_eq!(first.len(), 1);
21880 assert!(matches!(
21881 &first[0].event,
21882 NodeEvent::TerminalFrame { address, frame }
21883 if address == &original && frame.sequence == 7
21884 ));
21885
21886 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
21887 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
21888 assert!(matches!(
21889 receiver.try_recv(),
21890 Err(broadcast::error::TryRecvError::Empty)
21891 ));
21892
21893 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
21894 let advanced = receiver.try_recv().unwrap();
21895 assert_eq!(advanced[0].sequence, 2);
21896
21897 let resumed = terminal_address(2);
21898 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
21899 let reset = receiver.try_recv().unwrap();
21900 assert_eq!(reset[0].sequence, 3);
21901 assert!(matches!(
21902 &reset[0].event,
21903 NodeEvent::TerminalFrame { address, frame }
21904 if address == &resumed && frame.sequence == 1
21905 ));
21906 shared.clear_terminal_frame_watermark(&resumed);
21907 assert!(!shared
21908 .terminal_frame_watermarks
21909 .lock()
21910 .unwrap_or_else(|poisoned| poisoned.into_inner())
21911 .contains_key(&resumed.session.instance_id));
21912 }
21913
21914 #[test]
21915 fn terminal_frame_batches_replace_without_entering_node_event_history() {
21916 let shared = terminal_test_shared();
21917 let mut receiver = shared.terminal_event_tx.subscribe();
21918 let address = terminal_address(1);
21919 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
21920
21921 for sequence in 1..=count {
21922 shared.publish_terminal_frame_candidates(vec![(
21923 address.clone(),
21924 terminal_frame(sequence),
21925 )]);
21926 }
21927
21928 let history = shared
21929 .history
21930 .lock()
21931 .unwrap_or_else(|poisoned| poisoned.into_inner());
21932 assert!(history.events.is_empty());
21933 assert_eq!(history.last_sequence, count);
21934 assert_eq!(history.replay_floor_sequence, 1);
21935 drop(history);
21936 let NodeResponse::Resync {
21937 event_sequence,
21938 oldest_available_sequence,
21939 events,
21940 ..
21941 } = shared.resync(0) else {
21942 unreachable!("resync helper returned another response")
21943 };
21944 assert_eq!(event_sequence, count);
21945 assert_eq!(oldest_available_sequence, 1);
21946 assert!(events.is_empty());
21947 assert!(matches!(
21948 resync_required_event(&shared).event,
21949 NodeEvent::ResyncRequired {
21950 oldest_available_sequence: 1,
21951 }
21952 ));
21953 assert!(matches!(
21954 receiver.try_recv(),
21955 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
21956 ));
21957 let latest = receiver.try_recv().unwrap();
21958 assert_eq!(latest.len(), 1);
21959 assert_eq!(latest[0].sequence, count);
21960 }
21961
21962 #[test]
21963 fn real_durable_eviction_advances_authoritative_replay_floor() {
21964 let shared = terminal_test_shared();
21965 for _ in 0..=NODE_EVENT_HISTORY_MAX {
21966 shared.publish(NodeEvent::ControllerChanged { controller: None });
21967 }
21968
21969 let NodeResponse::Resync {
21970 event_sequence,
21971 oldest_available_sequence,
21972 events,
21973 ..
21974 } = shared.resync(0) else {
21975 unreachable!("resync helper returned another response")
21976 };
21977 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
21978 assert_eq!(oldest_available_sequence, 2);
21979 assert_eq!(events.first().map(|event| event.sequence), Some(2));
21980 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
21981 assert!(matches!(
21982 resync_required_event(&shared).event,
21983 NodeEvent::ResyncRequired {
21984 oldest_available_sequence: 2,
21985 }
21986 ));
21987 }
21988
21989 #[test]
21990 fn legacy_terminal_frame_connection_has_no_live_subscription() {
21991 let shared = terminal_test_shared();
21992 let without_capability = terminal_event_subscription(&shared, false);
21993 let with_capability = terminal_event_subscription(&shared, true);
21994 assert!(without_capability.is_none());
21995 assert!(with_capability.is_some());
21996 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21997 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
21998 }));
21999 }
22000
22001 #[test]
22002 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
22003 let mut pending = Vec::new();
22004 let mut event_sequence = 0_u64;
22005 for frame_sequence in 1..=4 {
22006 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
22007 event_sequence += 1;
22008 let mut address = terminal_address(1);
22009 address.session.instance_id = AgentInstanceId(instance);
22010 queue_connection_event(
22011 &mut pending,
22012 NodeEventEnvelope {
22013 sequence: event_sequence,
22014 event: NodeEvent::TerminalFrame {
22015 address,
22016 frame: terminal_frame(frame_sequence),
22017 },
22018 },
22019 0,
22020 );
22021 }
22022 }
22023 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
22024 pending.sort_unstable_by_key(|event| event.sequence);
22025
22026 let mut uninterrupted = pending.clone();
22027 let first_burst = connection_event_burst_len(uninterrupted.len());
22028 uninterrupted.drain(..first_burst);
22029 let second_burst = connection_event_burst_len(uninterrupted.len());
22030 uninterrupted.drain(..second_burst);
22031 assert!(uninterrupted.is_empty());
22032
22033 let (frame_tx, mut frame_rx) =
22034 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
22035 frame_tx
22036 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
22037 request_id: 77,
22038 request: NodeRequest::Snapshot,
22039 })))
22040 .unwrap();
22041 let burst_len = connection_event_burst_len(pending.len());
22042 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
22043 pending.drain(..burst_len);
22044 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
22045 assert!(matches!(
22046 frame_rx.try_recv(),
22047 Ok(Ok(ClientFrame::Request(RequestEnvelope {
22048 request_id: 77,
22049 request: NodeRequest::Snapshot,
22050 })))
22051 ));
22052 }
22053
22054 #[test]
22055 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
22056 let mut pending = Vec::new();
22057 for sequence in [7_u64, 8] {
22058 let mut address = terminal_address(1);
22059 address.session.instance_id = AgentInstanceId(sequence);
22060 queue_connection_event(
22061 &mut pending,
22062 NodeEventEnvelope {
22063 sequence,
22064 event: NodeEvent::TerminalFrame {
22065 address,
22066 frame: terminal_frame(sequence),
22067 },
22068 },
22069 0,
22070 );
22071 }
22072 let mut discard_through = 0;
22073 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
22074 assert!(pending.is_empty());
22075
22076 let replayed = NodeEventEnvelope {
22077 sequence: 8,
22078 event: NodeEvent::TerminalFrame {
22079 address: terminal_address(1),
22080 frame: terminal_frame(8),
22081 },
22082 };
22083 let advanced = NodeEventEnvelope {
22084 sequence: 9,
22085 event: NodeEvent::TerminalFrame {
22086 address: terminal_address(2),
22087 frame: terminal_frame(1),
22088 },
22089 };
22090 queue_connection_event_batch(
22091 &mut pending,
22092 &[replayed, advanced.clone()],
22093 discard_through,
22094 );
22095 assert_eq!(pending, vec![advanced]);
22096 }
22097
22098 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
22099 ManagedSessionRecord {
22100 record_id: SessionRecordId::new(record_id).unwrap(),
22101 display_name: format!("{provider} record"),
22102 provider: agent(provider),
22103 mode: SessionMode::Pty,
22104 state: ManagedSessionState::Unavailable,
22105 workspace_id: WorkspaceId::new("primary").unwrap(),
22106 canonical_root: opaque_windows_path(
22107 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22108 ),
22109 provider_session: None,
22110 active_session: None,
22111 environment_profile: None,
22112 bundle: None,
22113 context_id: None,
22114 context: None,
22115 exported_context: None,
22116 task_binding: None,
22117 created_at_unix_ms: 1,
22118 updated_at_unix_ms: 1,
22119 last_error: None,
22120 }
22121 }
22122
22123 fn retention_test_record(
22124 workspace_id: &str,
22125 record_id: &str,
22126 state: ManagedSessionState,
22127 updated_at_unix_ms: u64,
22128 ) -> ManagedSessionRecord {
22129 ManagedSessionRecord {
22130 record_id: SessionRecordId::new(record_id).unwrap(),
22131 display_name: format!("{record_id} display"),
22132 provider: agent("claude"),
22133 mode: SessionMode::Pty,
22134 state,
22135 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
22136 canonical_root: opaque_windows_path(
22137 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22138 ),
22139 provider_session: None,
22140 active_session: None,
22141 environment_profile: None,
22142 bundle: None,
22143 context_id: None,
22144 context: None,
22145 exported_context: None,
22146 task_binding: None,
22147 created_at_unix_ms: updated_at_unix_ms,
22148 updated_at_unix_ms,
22149 last_error: None,
22150 }
22151 }
22152
22153 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
22161 vec![
22162 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
22163 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
22164 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
22165 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
22166 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
22167 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
22168 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
22169 retention_test_record(
22170 "workspace-a",
22171 "decoy-pending",
22172 ManagedSessionState::IdentityPending,
22173 now - 500_000,
22174 ),
22175 retention_test_record(
22176 "workspace-a",
22177 "decoy-dormant",
22178 ManagedSessionState::Dormant,
22179 now - 500_000,
22180 ),
22181 ]
22182 }
22183
22184 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
22185 let root = temporary_workspace_root("session-record-retention");
22186 std::fs::create_dir_all(&root).unwrap();
22187 let catalog = active_registry().unwrap();
22188 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22189 let workspace_a =
22190 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
22191 let workspace_b =
22192 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
22193 let mut shared = NodeShared::new(
22194 handle,
22195 "fixture-token".to_owned(),
22196 NodeId::new("node-retention-test").unwrap(),
22197 vec![workspace_a, workspace_b],
22198 vec![agent("claude")],
22199 );
22200 shared.state_path = Some(root.join("node-state-v10.json"));
22201 let now = unix_time_ms();
22202 for record in retention_fixture_records(now) {
22203 shared.insert_record(record).unwrap();
22204 }
22205 shared
22206 .persist_state_locked()
22207 .expect("fixture-only, empty-history persist against a fresh temp state path");
22208 (shared, runtime, root, now)
22209 }
22210
22211 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22212 shared
22213 .history
22214 .lock()
22215 .unwrap_or_else(|poisoned| poisoned.into_inner())
22216 .events
22217 .iter()
22218 .filter_map(|envelope| match &envelope.event {
22219 NodeEvent::SessionRecordRemoved { record_id } => {
22220 Some(record_id.as_str().to_owned())
22221 }
22222 _ => None,
22223 })
22224 .collect()
22225 }
22226
22227 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22228 shared
22229 .session_records
22230 .lock()
22231 .unwrap_or_else(|poisoned| poisoned.into_inner())
22232 .records
22233 .keys()
22234 .map(|record_id| record_id.as_str().to_owned())
22235 .collect()
22236 }
22237
22238 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
22239 values.iter().map(|value| (*value).to_owned()).collect()
22240 }
22241
22242 #[tokio::test]
22243 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
22244 let (mut shared, runtime, root, _now) = retention_fixture_shared();
22245 shared.session_record_retention = SessionRecordRetentionConfig {
22246 age_ms: 0,
22247 keep_per_workspace: 1,
22248 };
22249 shared.retire_unavailable_session_records().await;
22250
22251 assert_eq!(
22252 retention_remaining_record_ids(&shared),
22253 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22254 );
22255 assert_eq!(
22256 retention_removed_record_ids(&shared),
22257 owned(&["a1", "a2", "b1", "b2"]),
22258 );
22259 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
22260
22261 drop(shared);
22262 drop(runtime);
22263 std::fs::remove_dir_all(root).unwrap();
22264 }
22265
22266 #[tokio::test]
22272 async fn session_record_retention_both_set_retires_only_the_intersection() {
22273 let (mut shared, runtime, root, _now) = retention_fixture_shared();
22274 shared.session_record_retention = SessionRecordRetentionConfig {
22275 age_ms: 250_000,
22276 keep_per_workspace: 1,
22277 };
22278 shared.retire_unavailable_session_records().await;
22279
22280 assert_eq!(
22281 retention_remaining_record_ids(&shared),
22282 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22283 );
22284 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
22285 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
22286
22287 drop(shared);
22288 drop(runtime);
22289 std::fs::remove_dir_all(root).unwrap();
22290 }
22291
22292 #[tokio::test]
22293 async fn session_record_retention_both_disabled_is_a_no_op() {
22294 let (mut shared, runtime, root, _now) = retention_fixture_shared();
22295 shared.session_record_retention = SessionRecordRetentionConfig::default();
22296 shared.retire_unavailable_session_records().await;
22297
22298 assert_eq!(
22299 retention_remaining_record_ids(&shared),
22300 owned(&[
22301 "a1", "a2", "a3", "b1", "b2", "b3",
22302 "decoy-live", "decoy-pending", "decoy-dormant",
22303 ]),
22304 );
22305 assert!(retention_removed_record_ids(&shared).is_empty());
22306 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
22307
22308 drop(shared);
22309 drop(runtime);
22310 std::fs::remove_dir_all(root).unwrap();
22311 }
22312
22313 #[test]
22314 fn session_record_context_export_selects_one_exact_history_identity() {
22315 let candidates = vec![
22316 HistoryCandidateSummary {
22317 id: "candidate-other".to_owned(),
22318 session_id_hint: "provider-other".to_owned(),
22319 modified_at_unix_ms: Some(1),
22320 },
22321 HistoryCandidateSummary {
22322 id: "candidate-exact".to_owned(),
22323 session_id_hint: "provider-exact".to_owned(),
22324 modified_at_unix_ms: Some(2),
22325 },
22326 ];
22327 assert_eq!(
22328 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
22329 "candidate-exact",
22330 );
22331 }
22332
22333 #[test]
22334 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
22335 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
22336 id: id.to_owned(),
22337 session_id_hint: hint.to_owned(),
22338 modified_at_unix_ms: None,
22339 };
22340 let missing = select_history_candidate(
22341 &[candidate("candidate-other", "provider-other")],
22342 Some("provider-exact"),
22343 )
22344 .unwrap_err();
22345 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
22346 let ambiguous = select_history_candidate(
22347 &[
22348 candidate("candidate-a", "provider-exact"),
22349 candidate("candidate-b", "provider-exact"),
22350 ],
22351 Some("provider-exact"),
22352 )
22353 .unwrap_err();
22354 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
22355 }
22356
22357 #[test]
22365 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
22366 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
22367 id: id.to_owned(),
22368 session_id_hint: hint.to_owned(),
22369 modified_at_unix_ms: None,
22370 };
22371 assert_eq!(
22372 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
22373 .unwrap(),
22374 "candidate-sole",
22375 );
22376 let missing = select_history_candidate(&[], None).unwrap_err();
22377 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
22378 let ambiguous = select_history_candidate(
22379 &[
22380 candidate("candidate-a", "sidecar-session-a"),
22381 candidate("candidate-b", "sidecar-session-b"),
22382 ],
22383 None,
22384 )
22385 .unwrap_err();
22386 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
22387 }
22388
22389 #[test]
22390 fn session_record_context_export_revalidation_rejects_record_change() {
22391 let session = terminal_address(1);
22392 let identity = ProviderSessionIdentity {
22393 key: ProviderSessionKey::SessionId,
22394 id: "provider-exact".to_owned(),
22395 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
22396 };
22397 let mut expected = record("claude", "record-context-export");
22398 expected.state = ManagedSessionState::Live;
22399 expected.provider_session = Some(identity.clone());
22400 expected.active_session = Some(session.clone());
22401 let binding = SessionBinding {
22402 workspace_id: session.workspace_id.clone(),
22403 generation: session.session.generation,
22404 runtime_policy: ProviderRuntimePolicy::raw_pty(),
22405 pending_resume: None,
22406 record_id: Some(expected.record_id.clone()),
22407 managed_worktree_lease_id: None,
22408 environment_profile: None,
22409 bundle: None,
22410 context: None,
22411 materialization_id: None,
22412 };
22413 let current_root = windows_path_text(&expected.canonical_root).to_owned();
22414 assert!(session_record_context_export_binding_is_exact(
22415 &expected,
22416 &expected,
22417 Some(&identity),
22418 &session,
22419 &binding,
22420 &expected.provider,
22421 ¤t_root,
22422 false,
22423 ));
22424 let mut changed = expected.clone();
22425 changed.updated_at_unix_ms += 1;
22426 assert!(!session_record_context_export_binding_is_exact(
22427 &expected,
22428 &changed,
22429 Some(&identity),
22430 &session,
22431 &binding,
22432 &expected.provider,
22433 ¤t_root,
22434 false,
22435 ));
22436 assert!(!session_record_context_export_binding_is_exact(
22441 &expected,
22442 &changed,
22443 Some(&identity),
22444 &session,
22445 &binding,
22446 &expected.provider,
22447 ¤t_root,
22448 true,
22449 ));
22450 let mut detached = expected.clone();
22451 detached.state = ManagedSessionState::Dormant;
22452 detached.active_session = None;
22453 detached.updated_at_unix_ms += 1;
22454 assert!(!session_record_context_export_binding_is_exact(
22455 &expected,
22456 &detached,
22457 Some(&identity),
22458 &session,
22459 &binding,
22460 &expected.provider,
22461 ¤t_root,
22462 false,
22463 ));
22464 assert!(session_record_context_export_binding_is_exact(
22465 &expected,
22466 &detached,
22467 Some(&identity),
22468 &session,
22469 &binding,
22470 &expected.provider,
22471 ¤t_root,
22472 true,
22473 ));
22474 }
22475
22476 #[test]
22483 fn session_record_context_export_binding_is_exact_without_identity() {
22484 let session = terminal_address(1);
22485 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
22486 expected.state = ManagedSessionState::Live;
22487 expected.active_session = Some(session.clone());
22488 let binding = SessionBinding {
22489 workspace_id: session.workspace_id.clone(),
22490 generation: session.session.generation,
22491 runtime_policy: ProviderRuntimePolicy::raw_pty(),
22492 pending_resume: None,
22493 record_id: Some(expected.record_id.clone()),
22494 managed_worktree_lease_id: None,
22495 environment_profile: None,
22496 bundle: None,
22497 context: None,
22498 materialization_id: None,
22499 };
22500 let current_root = windows_path_text(&expected.canonical_root).to_owned();
22501 assert!(session_record_context_export_binding_is_exact(
22502 &expected,
22503 &expected,
22504 None,
22505 &session,
22506 &binding,
22507 &expected.provider,
22508 ¤t_root,
22509 false,
22510 ));
22511 let mut drifted = expected.clone();
22515 drifted.provider_session = Some(ProviderSessionIdentity {
22516 key: ProviderSessionKey::SessionId,
22517 id: "unexpected-identity".to_owned(),
22518 transcript_path: None,
22519 });
22520 assert!(!session_record_context_export_binding_is_exact(
22521 &expected,
22522 &drifted,
22523 None,
22524 &session,
22525 &binding,
22526 &expected.provider,
22527 ¤t_root,
22528 false,
22529 ));
22530 }
22531
22532 #[test]
22533 fn session_record_context_export_preflight_requires_live_usable_source() {
22534 let mut source = record("claude", "record-context-export-preflight");
22535 source.state = ManagedSessionState::Live;
22536 assert!(session_record_context_export_source_is_usable(
22537 &source,
22538 &SessionStatus::Running,
22539 false,
22540 ));
22541 assert!(session_record_context_export_source_is_usable(
22542 &source,
22543 &SessionStatus::Exited { exit_code: Some(0) },
22544 false,
22545 ));
22546
22547 source.state = ManagedSessionState::Dormant;
22548 assert!(!session_record_context_export_source_is_usable(
22549 &source,
22550 &SessionStatus::Running,
22551 false,
22552 ));
22553 assert!(session_record_context_export_source_is_usable(
22557 &source,
22558 &SessionStatus::Exited { exit_code: Some(0) },
22559 true,
22560 ));
22561 assert!(!session_record_context_export_source_is_usable(
22562 &source,
22563 &SessionStatus::Exited { exit_code: Some(0) },
22564 false,
22565 ));
22566 source.state = ManagedSessionState::Live;
22567 assert!(!session_record_context_export_source_is_usable(
22568 &source,
22569 &SessionStatus::Starting,
22570 false,
22571 ));
22572 }
22573
22574 #[test]
22575 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
22576 let catalog = active_registry().unwrap();
22577 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22578 let workspace = WorkspaceConfig::new(
22579 WorkspaceId::new("primary").unwrap(),
22580 std::env::current_dir().unwrap(),
22581 )
22582 .unwrap();
22583 let shared = NodeShared::new(
22584 handle,
22585 "fixture-token".to_owned(),
22586 NodeId::new("node-context-export-conflict").unwrap(),
22587 vec![workspace],
22588 vec![agent("claude")],
22589 );
22590 let history = HistorySessionRecord {
22591 session_id: "provider-exact".to_owned(),
22592 title: None,
22593 cwd: None,
22594 model: None,
22595 message_count: 1,
22596 completed_turn_count: None,
22597 total_tokens: 0,
22598 messages: vec![gate4agent_types::HistoryMessageRecord {
22599 role: gate4agent_types::HistoryMessageRole::User,
22600 text: "bounded context".to_owned(),
22601 }],
22602 };
22603 let pack = NodeContextPack::export(
22604 ContextPackLineageReceipt {
22605 source_node_id: shared.node_id.clone(),
22606 source_session: terminal_address(1),
22607 source_provider: agent("claude"),
22608 },
22609 &history,
22610 )
22611 .unwrap();
22612 let context_id = pack.receipt().id.clone();
22613 let session = terminal_address(1);
22614 let identity = ProviderSessionIdentity {
22615 key: ProviderSessionKey::SessionId,
22616 id: history.session_id.clone(),
22617 transcript_path: None,
22618 };
22619 let mut expected = record("claude", "record-context-export-final-conflict");
22620 expected.state = ManagedSessionState::Live;
22621 expected.provider_session = Some(identity.clone());
22622 expected.active_session = Some(session.clone());
22623 let mut changed = expected.clone();
22624 changed.updated_at_unix_ms += 1;
22625 shared.insert_record(changed).unwrap();
22626
22627 let error = shared
22628 .commit_context_pack_for_session_record(
22629 &expected,
22630 Some(&identity),
22631 &session,
22632 "candidate-exact",
22633 &history,
22634 pack,
22635 false,
22636 )
22637 .unwrap_err();
22638
22639 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
22640 assert!(shared
22641 .context_catalog
22642 .read()
22643 .unwrap_or_else(|poisoned| poisoned.into_inner())
22644 .get(&context_id)
22645 .is_none());
22646 }
22647
22648 #[tokio::test]
22649 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
22650 let root = temporary_workspace_root("session-task-binding");
22651 std::fs::create_dir_all(&root).unwrap();
22652 let state_path = root.join("node-state.json");
22653 let catalog = active_registry().unwrap();
22654 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22655 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
22656 let node_id = NodeId::new("node-1").unwrap();
22657 let mut shared = NodeShared::new(
22658 handle,
22659 "fixture-token".to_owned(),
22660 node_id.clone(),
22661 vec![workspace],
22662 vec![agent("claude")],
22663 );
22664 shared.state_path = Some(state_path.clone());
22665 let record = record("claude", "sr-task");
22666 let record_id = record.record_id.clone();
22667 shared.insert_record(record).unwrap();
22668 shared.persist_state().unwrap();
22669 shared
22670 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
22671 .unwrap();
22672
22673 let minted = process_request_inner(
22674 &shared,
22675 77,
22676 ClientRole::Operator,
22677 NodeRequest::SetSessionTask {
22678 record_id: record_id.clone(),
22679 expected_revision: 0,
22680 target: SessionTaskTargetV1::New,
22681 },
22682 )
22683 .await
22684 .unwrap();
22685 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
22686 panic!("session task mutation returned another response");
22687 };
22688 let minted_binding = minted.task_binding.clone().unwrap();
22689 let minted_task_id = minted_binding.task_id.clone().unwrap();
22690 assert_eq!(minted_binding.revision, 1);
22691 assert!(minted_task_id.as_str().starts_with("task-"));
22692 assert_eq!(minted_task_id.as_str().len(), 29);
22693
22694 let event_count = shared.current_sequence();
22695 let repeated = process_request_inner(
22696 &shared,
22697 77,
22698 ClientRole::Operator,
22699 NodeRequest::SetSessionTask {
22700 record_id: record_id.clone(),
22701 expected_revision: 1,
22702 target: SessionTaskTargetV1::Existing {
22703 task_id: minted_task_id.clone(),
22704 },
22705 },
22706 )
22707 .await
22708 .unwrap();
22709 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
22710 assert_eq!(shared.current_sequence(), event_count);
22711
22712 let stale = process_request_inner(
22713 &shared,
22714 77,
22715 ClientRole::Operator,
22716 NodeRequest::SetSessionTask {
22717 record_id: record_id.clone(),
22718 expected_revision: 0,
22719 target: SessionTaskTargetV1::Existing {
22720 task_id: minted_task_id,
22721 },
22722 },
22723 )
22724 .await
22725 .unwrap_err();
22726 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
22727
22728 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
22729 let assigned = process_request_inner(
22730 &shared,
22731 77,
22732 ClientRole::Operator,
22733 NodeRequest::SetSessionTask {
22734 record_id: record_id.clone(),
22735 expected_revision: 1,
22736 target: SessionTaskTargetV1::Existing {
22737 task_id: assigned_id.clone(),
22738 },
22739 },
22740 )
22741 .await
22742 .unwrap();
22743 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
22744 panic!("session task assignment returned another response");
22745 };
22746 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
22747 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
22748
22749 let cleared = process_request_inner(
22750 &shared,
22751 77,
22752 ClientRole::Operator,
22753 NodeRequest::SetSessionTask {
22754 record_id: record_id.clone(),
22755 expected_revision: 2,
22756 target: SessionTaskTargetV1::Clear,
22757 },
22758 )
22759 .await
22760 .unwrap();
22761 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
22762 panic!("session task clear returned another response");
22763 };
22764 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
22765 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
22766 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
22767 assert_eq!(
22768 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
22769 cleared.updated_at_unix_ms,
22770 );
22771
22772 let persisted = std::fs::read(&state_path).unwrap();
22773 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
22774 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
22775 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
22776 std::fs::remove_dir_all(root).unwrap();
22777 }
22778
22779 #[test]
22780 fn session_task_capability_strips_snapshots_events_and_replies() {
22781 let mut bound = record("claude", "sr-task-projection");
22782 bound.updated_at_unix_ms = 2;
22783 bound.task_binding = Some(SessionTaskBindingV1 {
22784 revision: 1,
22785 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
22786 changed_at_unix_ms: 2,
22787 });
22788 let request = NodeRequest::SetSessionTask {
22789 record_id: bound.record_id.clone(),
22790 expected_revision: 1,
22791 target: SessionTaskTargetV1::Clear,
22792 };
22793 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22794 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
22795 }));
22796 assert!(!request_uses_unnegotiated_capability(
22797 &request,
22798 &baseline_capabilities().unwrap(),
22799 ));
22800 assert!(request_uses_unnegotiated_capability(&request, &[]));
22801
22802 let mut snapshot = terminal_test_shared().snapshot();
22803 snapshot.session_records.push(bound.clone());
22804 let mut reply = ResponseEnvelope {
22805 request_id: 1,
22806 result: Ok(NodeResponse::Resync {
22807 event_sequence: 1,
22808 oldest_available_sequence: 1,
22809 snapshot,
22810 events: vec![NodeEventEnvelope {
22811 sequence: 1,
22812 event: NodeEvent::SessionRecordUpserted {
22813 record: bound.clone(),
22814 },
22815 }],
22816 }),
22817 };
22818 project_response_without_session_task_binding(&mut reply);
22819 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22820 panic!("task projection changed the response kind");
22821 };
22822 assert!(snapshot.session_records[0].task_binding.is_none());
22823 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
22824 panic!("task projection changed the event kind");
22825 };
22826 assert!(record.task_binding.is_none());
22827
22828 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
22829 sequence: 2,
22830 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
22831 });
22832 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
22833 panic!("task event projection changed the event kind");
22834 };
22835 assert!(record.task_binding.is_none());
22836
22837 let mut direct = ResponseEnvelope {
22838 request_id: 2,
22839 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
22840 };
22841 project_response_without_session_task_binding(&mut direct);
22842 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
22843 panic!("task reply projection changed the response kind");
22844 };
22845 assert!(record.task_binding.is_none());
22846 }
22847
22848 #[test]
22849 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
22850 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
22851 let workspace = WorkspaceConfig::new(
22852 WorkspaceId::new("primary").unwrap(),
22853 std::env::current_dir().unwrap(),
22854 )
22855 .unwrap();
22856 let config = NodeServerConfig::new(
22857 DEFAULT_NODE_ENDPOINT,
22858 "fixture-token",
22859 NodeId::new("node-1").unwrap(),
22860 [workspace],
22861 )
22862 .unwrap();
22863 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
22864 }
22865
22866 #[cfg(all(feature = "fixture", windows))]
22867 #[test]
22868 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
22869 let root = temporary_workspace_root("provider-bundle-argv-hold");
22870 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
22871 std::fs::create_dir_all(&root).unwrap();
22872 std::fs::create_dir_all(&outside).unwrap();
22873 let proof_path = root.join("provider.proof");
22874 let release_signal = root.join("release.signal");
22875 let config = NodeServerConfig::new(
22876 format!(
22877 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
22878 std::process::id(),
22879 ),
22880 "fixture-token",
22881 NodeId::new("provider-bundle-hold-node").unwrap(),
22882 [WorkspaceConfig::new(
22883 WorkspaceId::new("primary").unwrap(),
22884 std::env::current_dir().unwrap(),
22885 )
22886 .unwrap()],
22887 )
22888 .unwrap()
22889 .with_spawn_profiles(SpawnProfileRegistry::new([
22890 crate::protocol::SpawnProfileDefaults {
22891 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
22892 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
22893 .unwrap(),
22894 provider: agent("kimi"),
22895 mode: SessionMode::Pty,
22896 terminal_size: gate4agent_types::TerminalSize {
22897 rows: 24,
22898 columns: 80,
22899 },
22900 prompt: None,
22901 bundle_id: None,
22902 context_id: None,
22903 environment_profile_id: None,
22904 },
22905 ])
22906 .unwrap());
22907 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
22908 config,
22909 agent("kimi"),
22910 proof_path.clone(),
22911 release_signal.clone(),
22912 )
22913 .unwrap();
22914 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
22915
22916 let spec = NodeServer::provider_bundle_argv_fixture_spec(
22917 agent("kimi"),
22918 proof_path.clone(),
22919 Some(release_signal.clone()),
22920 )
22921 .unwrap();
22922 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
22923 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
22924 let release_wait = wrapper
22925 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
22926 .unwrap();
22927 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
22928 assert!(proof_write < release_wait);
22929 assert!(release_wait < fixture_ready);
22930 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
22931 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
22932 assert_eq!(
22933 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
22934 &[
22935 proof_path.to_string_lossy().into_owned(),
22936 release_signal.to_string_lossy().into_owned(),
22937 ],
22938 );
22939
22940 let error = NodeServer::provider_bundle_argv_fixture_spec(
22941 agent("kimi"),
22942 proof_path,
22943 Some(outside.join("release.signal")),
22944 )
22945 .unwrap_err();
22946 assert!(matches!(
22947 error,
22948 NodeServerError::Registry(message)
22949 if message == "provider bundle argv release path must share the proof parent"
22950 ));
22951 drop(server);
22952 std::fs::remove_dir_all(root).unwrap();
22953 std::fs::remove_dir_all(outside).unwrap();
22954 }
22955
22956 #[cfg(feature = "fixture")]
22957 #[test]
22958 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
22959 let root = temporary_workspace_root("context-fixture-registry");
22960 std::fs::create_dir_all(&root).unwrap();
22961 let proof_path = root.join("context.proof");
22962 let config = NodeServerConfig::new(
22963 format!(
22964 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
22965 std::process::id(),
22966 ),
22967 "fixture-token",
22968 NodeId::new("context-fixture-node").unwrap(),
22969 [WorkspaceConfig::new(
22970 WorkspaceId::new("primary").unwrap(),
22971 std::env::current_dir().unwrap(),
22972 )
22973 .unwrap()],
22974 )
22975 .unwrap();
22976 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
22977 let mut enabled = server
22978 .shared
22979 .enabled_providers
22980 .iter()
22981 .map(AgentId::as_str)
22982 .collect::<Vec<_>>();
22983 enabled.sort_unstable();
22984 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
22985 for provider in &server.shared.enabled_providers {
22986 assert!(server
22987 .shared
22988 .provider_adapter_contracts
22989 .iter()
22990 .any(|contract| {
22991 &contract.provider == provider
22992 && contract.family == AdapterFamily::History
22993 }));
22994 }
22995 drop(server);
22996 std::fs::remove_dir_all(root).unwrap();
22997 }
22998
22999 #[cfg(feature = "fixture")]
23000 #[test]
23001 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
23002 let root = temporary_workspace_root("monitoring-context-fixture");
23003 std::fs::create_dir_all(&root).unwrap();
23004 let proof_path = root.join("context.proof");
23005 let standard = NodeServer::context_pack_fixture_catalog(
23006 proof_path.clone(),
23007 None,
23008 false,
23009 ).unwrap();
23010 let monitoring = NodeServer::context_pack_fixture_catalog(
23011 proof_path.clone(),
23012 None,
23013 true,
23014 ).unwrap();
23015 let claude = AgentId::new("claude").unwrap();
23016 let standard_claude = standard.get(&claude).unwrap();
23017 let monitoring_claude = monitoring.get(&claude).unwrap();
23018 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
23022 assert_eq!(
23023 monitoring_claude.capabilities.adapters.history,
23024 standard_claude.capabilities.adapters.history,
23025 );
23026 assert_ne!(monitoring_claude.launch, standard_claude.launch);
23027 let codex = AgentId::new("codex").unwrap();
23028 let standard_codex = standard.get(&codex).unwrap();
23029 let monitoring_codex = monitoring.get(&codex).unwrap();
23030 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
23031 assert_eq!(
23032 monitoring_codex.capabilities.adapters.history,
23033 standard_codex.capabilities.adapters.history,
23034 );
23035 assert_ne!(monitoring_codex.launch, standard_codex.launch);
23036 let codex_validation = monitoring_codex.launch.fixed_args.iter()
23037 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
23038 .unwrap();
23039 for required in [
23040 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
23041 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
23042 "@('role', 'text')",
23043 "g4a-private-provider-session-canary",
23044 "private-provider-login-identity",
23045 "$rawProof",
23046 "$env:GATE4AGENT_HOOK_URL",
23047 "g4a-private-provider-session-canary",
23048 ] {
23049 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
23050 }
23051 for provider in ["grok", "kimi"] {
23052 let provider = AgentId::new(provider).unwrap();
23053 assert_eq!(monitoring.get(&provider), standard.get(&provider));
23054 }
23055
23056 let config = NodeServerConfig::new(
23057 format!(
23058 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
23059 std::process::id(),
23060 ),
23061 "fixture-token",
23062 NodeId::new("monitoring-context-fixture-node").unwrap(),
23063 [WorkspaceConfig::new(
23064 WorkspaceId::new("primary").unwrap(),
23065 std::env::current_dir().unwrap(),
23066 )
23067 .unwrap()],
23068 )
23069 .unwrap();
23070 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
23071 assert!(server.shared.fixture_semantic_hook_policy);
23072 drop(server);
23073 std::fs::remove_dir_all(root).unwrap();
23074 }
23075
23076 #[cfg(feature = "fixture")]
23077 #[test]
23078 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
23079 let root = temporary_workspace_root("kimi-context-only-fixture");
23080 std::fs::create_dir_all(&root).unwrap();
23081 let proof_path = root.join("context.proof");
23082 let standard = NodeServer::context_pack_fixture_catalog(
23083 proof_path.clone(),
23084 None,
23085 false,
23086 ).unwrap();
23087 let kimi = AgentId::new("kimi").unwrap();
23088 let context_only = NodeServer::context_pack_fixture_catalog(
23089 proof_path,
23090 Some(&kimi),
23091 false,
23092 )
23093 .unwrap();
23094
23095 let codex = AgentId::new("codex").unwrap();
23096 assert_eq!(
23097 standard.get(&codex).unwrap().launch.fixed_args,
23098 context_only.get(&codex).unwrap().launch.fixed_args,
23099 );
23100 let script = context_only
23101 .get(&kimi)
23102 .unwrap()
23103 .launch
23104 .fixed_args
23105 .iter()
23106 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
23107 .expect("Kimi context-only validation script is missing");
23108 for required in [
23109 "$bundleArgs.Count -ne 0",
23110 "GATE4AGENT_CONTEXT_ROOT",
23111 "context-pack.json",
23112 "$entries.Count -ne 1",
23113 "g4a-context-pack-v1",
23114 "$document.PSObject.Properties['cwd']",
23115 "'claude', 'codex', 'grok', 'kimi'",
23116 "$hasUser",
23117 "$hasAssistant",
23118 "SHA256",
23119 "context-only",
23120 ] {
23121 assert!(script.contains(required), "missing validation: {required}");
23122 }
23123 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
23124 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
23125 }
23126 assert!(!standard
23127 .get(&kimi)
23128 .unwrap()
23129 .launch
23130 .fixed_args
23131 .iter()
23132 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
23133
23134 std::fs::remove_dir_all(root).unwrap();
23135 }
23136
23137 #[cfg(all(feature = "fixture", windows))]
23138 #[tokio::test]
23139 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
23140 let root = temporary_workspace_root("kimi-context-only-spawn");
23141 let workspace_root = root.join("workspace");
23142 std::fs::create_dir_all(&workspace_root).unwrap();
23143 let materialization_root = root.join("private-materializations");
23144 let proof_path = root.join("context.proof");
23145 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
23146 let workspace_id = WorkspaceId::new("primary").unwrap();
23147 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
23148 let endpoint = format!(
23149 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
23150 std::process::id(),
23151 unix_time_ms(),
23152 );
23153 let spawn_profiles = SpawnProfileRegistry::new([
23154 crate::protocol::SpawnProfileDefaults {
23155 profile_id: profile_id.clone(),
23156 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
23157 provider: agent("kimi"),
23158 mode: SessionMode::Pty,
23159 terminal_size: gate4agent_types::TerminalSize {
23160 rows: 30,
23161 columns: 120,
23162 },
23163 prompt: None,
23164 bundle_id: None,
23165 context_id: None,
23166 environment_profile_id: None,
23167 },
23168 ])
23169 .unwrap();
23170 let mut config = NodeServerConfig::new(
23171 &endpoint,
23172 "fixture-token",
23173 node_id.clone(),
23174 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
23175 )
23176 .unwrap()
23177 .with_spawn_profiles(spawn_profiles)
23178 .with_session_environment_materialization(
23179 materialization_root,
23180 Arc::new(FixtureSecretResolver {
23181 deny: Arc::new(AtomicBool::new(false)),
23182 }),
23183 )
23184 .unwrap();
23185 config.fixture_raw_pty_runtime = true;
23186 let server = NodeServer::new_context_only_proof_fixture(
23187 config,
23188 agent("kimi"),
23189 proof_path.clone(),
23190 )
23191 .unwrap();
23192 let shared = Arc::clone(&server.shared);
23193 let shutdown = server.shutdown_handle();
23194
23195 let history = HistorySessionRecord {
23196 session_id: "context-only-source-history".to_owned(),
23197 title: None,
23198 cwd: None,
23199 model: None,
23200 message_count: 2,
23201 completed_turn_count: None,
23202 total_tokens: 0,
23203 messages: vec![
23204 gate4agent_types::HistoryMessageRecord {
23205 role: gate4agent_types::HistoryMessageRole::User,
23206 text: "continue the bounded parent run through native C2".to_owned(),
23207 },
23208 gate4agent_types::HistoryMessageRecord {
23209 role: gate4agent_types::HistoryMessageRole::Assistant,
23210 text: "the exact parent context is ready for the child run".to_owned(),
23211 },
23212 ],
23213 };
23214 let pack = NodeContextPack::export(
23215 ContextPackLineageReceipt {
23216 source_node_id: node_id.clone(),
23217 source_session: SessionAddress {
23218 workspace_id: workspace_id.clone(),
23219 session: SessionKey {
23220 instance_id: AgentInstanceId(71),
23221 generation: SessionGeneration(1),
23222 },
23223 },
23224 source_provider: agent("codex"),
23225 },
23226 &history,
23227 )
23228 .unwrap();
23229 let expected_context_hash = digest(&SHA256, pack.bytes())
23230 .as_ref()
23231 .iter()
23232 .map(|byte| format!("{byte:02x}"))
23233 .collect::<String>();
23234 let context = shared
23235 .context_catalog
23236 .write()
23237 .unwrap_or_else(|poisoned| poisoned.into_inner())
23238 .insert(pack)
23239 .unwrap();
23240 let spec = SpawnSpec {
23241 target: crate::protocol::SpawnTarget {
23242 node_id,
23243 workspace_id: workspace_id.clone(),
23244 worktree_id: None,
23245 },
23246 profile_id,
23247 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
23248 "target-kimi-r1",
23249 ).unwrap(),
23250 overrides: crate::protocol::SpawnOverrides {
23251 context_id: crate::protocol::SpawnOverride::Set {
23252 value: context.id.clone(),
23253 },
23254 ..crate::protocol::SpawnOverrides::default()
23255 },
23256 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
23257 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
23258 required_capabilities: SpawnRequiredCapabilities::default(),
23259 };
23260 let resolved = match shared.resolve_spawn_spec(&spec) {
23261 Ok(resolved) => resolved,
23262 Err(error) => panic!(
23263 "Kimi context-only resolve rejected: exact_code={:?} message={}",
23264 error.code,
23265 error.message,
23266 ),
23267 };
23268 let resolved_context = match shared.resolve_context(&resolved) {
23269 Ok(Some(resolved_context)) => resolved_context,
23270 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
23271 Err(error) => panic!(
23272 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
23273 error.code,
23274 error.message,
23275 ),
23276 };
23277 assert_eq!(resolved_context, context);
23278 let runtime_policy = match shared
23279 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
23280 .await
23281 {
23282 Ok(runtime_policy) => runtime_policy,
23283 Err(error) => panic!(
23284 "Kimi RawPty admission rejected: exact_code={:?} message={}",
23285 error.code,
23286 error.message,
23287 ),
23288 };
23289 assert!(runtime_policy.raw_pty_lifecycle);
23290
23291 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
23292 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
23293 agent("kimi"),
23294 TransportKind::Pty,
23295 vec![EnvMutation {
23296 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
23297 value: Some(OsString::from("must-remain-rejected")),
23298 }],
23299 )
23300 .unwrap();
23301 assert_eq!(
23302 launch_control
23303 .install_native_launch_environment_overlay(
23304 AgentInstanceId(72),
23305 generic_overlay,
23306 )
23307 .unwrap_err(),
23308 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
23309 );
23310
23311 let diagnostic_address = SessionAddress {
23312 workspace_id: workspace_id.clone(),
23313 session: SessionKey {
23314 instance_id: AgentInstanceId(73),
23315 generation: SessionGeneration(1),
23316 },
23317 };
23318 let (diagnostic_overlay, diagnostic_materialization) = match shared
23319 .prepare_session_materialization(
23320 &diagnostic_address,
23321 &agent("kimi"),
23322 SessionMode::Pty,
23323 None,
23324 None,
23325 Some(&resolved_context),
23326 None,
23327 )
23328 {
23329 Ok(Some(prepared)) => prepared,
23330 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
23331 Err(error) => panic!(
23332 "Kimi context-only materialization rejected: exact_code={:?} message={}",
23333 error.code,
23334 error.message,
23335 ),
23336 };
23337 let diagnostic_overlay = diagnostic_overlay
23338 .expect("Kimi context-only materialization omitted its environment overlay");
23339 assert!(matches!(
23340 &diagnostic_overlay,
23341 PreparedNativeLaunchOverlay::Environment(_),
23342 ));
23343 match shared.install_prepared_launch_overlay(
23344 diagnostic_address.session.instance_id,
23345 diagnostic_overlay,
23346 ) {
23347 Ok(None) => {}
23348 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
23349 Err(error) => panic!(
23350 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
23351 error.code,
23352 error.message,
23353 ),
23354 }
23355 assert!(launch_control.clear_native_instance_launch_overlay(
23356 diagnostic_address.session.instance_id,
23357 ));
23358 drop(diagnostic_materialization);
23359 assert!(shared
23360 .materializations
23361 .lock()
23362 .unwrap_or_else(|poisoned| poisoned.into_inner())
23363 .is_empty());
23364
23365 let node_task = tokio::spawn(server.run());
23366 let ready_client = timeout(Duration::from_secs(5), async {
23367 loop {
23368 match gate4agent_node_wire::LocalNodeClient::connect(
23369 &endpoint,
23370 &shared.node_id,
23371 ClientRole::Observer,
23372 "fixture-token",
23373 )
23374 .await
23375 {
23376 Ok(client) => break client,
23377 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
23378 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
23379 }
23380 }
23381 })
23382 .await
23383 .expect("Kimi context-only Node did not become ready");
23384 assert_eq!(
23385 ready_client
23386 .hello()
23387 .snapshot
23388 .provider_runtime_statuses
23389 .iter()
23390 .find(|status| status.provider().as_str() == "kimi")
23391 .expect("Kimi runtime status is missing")
23392 .mode(),
23393 crate::protocol::ProviderRuntimeMode::RawPassthrough,
23394 );
23395 drop(ready_client);
23396
23397 let receipt = match shared.spawn_from_spec(spec).await {
23398 Ok(receipt) => receipt,
23399 Err(error) => panic!(
23400 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
23401 error.code,
23402 error.message,
23403 ),
23404 };
23405 assert_eq!(receipt.provider, agent("kimi"));
23406 assert_eq!(receipt.context.as_ref(), Some(&context));
23407 assert!(receipt.context_binding_is_valid());
23408 timeout(Duration::from_secs(5), async {
23409 while !proof_path.is_file() {
23410 sleep(Duration::from_millis(10)).await;
23411 }
23412 })
23413 .await
23414 .expect("Kimi context-only fixture did not write its proof");
23415
23416 let proof = std::fs::read_to_string(&proof_path).unwrap();
23417 let lines = proof.lines().collect::<Vec<_>>();
23418 assert_eq!(lines.len(), 7);
23419 assert_eq!(lines[0], "context-only");
23420 assert!(Path::new(lines[1]).is_absolute());
23421 assert_ne!(
23422 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
23423 std::fs::canonicalize(&workspace_root).unwrap(),
23424 );
23425 assert_eq!(
23426 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
23427 std::fs::canonicalize(&workspace_root).unwrap(),
23428 );
23429 assert_eq!(lines[3], expected_context_hash);
23430 assert_eq!(lines[4], "g4a-context-pack-v1");
23431 assert_eq!(lines[5], "codex");
23432 assert_eq!(lines[6], "2");
23433 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
23434 session.instance_id == receipt.session.session.instance_id
23435 && session.agent_id.as_str() == "kimi"
23436 }));
23437
23438 shutdown.request_shutdown().await.unwrap();
23439 timeout(Duration::from_secs(5), node_task)
23440 .await
23441 .unwrap()
23442 .unwrap()
23443 .unwrap();
23444 drop(shutdown);
23445 drop(shared);
23446 std::fs::remove_dir_all(root).unwrap();
23447 }
23448
23449 #[cfg(feature = "fixture")]
23450 #[test]
23451 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
23452 let root = temporary_workspace_root("clean-exit-constructor");
23453 std::fs::create_dir_all(&root).unwrap();
23454 let config = construction_test_config("clean-exit-constructor")
23455 .with_state_path(root.join("node-state.json"))
23456 .unwrap();
23457 let server = NodeServer::new_clean_exit_fixture(
23458 config,
23459 root.clone(),
23460 root.join("started.marker"),
23461 root.join("release.signal"),
23462 )
23463 .unwrap();
23464 assert_eq!(
23465 server
23466 .shared
23467 .enabled_providers
23468 .iter()
23469 .map(AgentId::as_str)
23470 .collect::<Vec<_>>(),
23471 ["claude"],
23472 );
23473 drop(server);
23474 std::fs::remove_dir_all(root).unwrap();
23475 }
23476
23477 #[test]
23478 fn production_registry_provider_contract_manifest_is_exact() {
23479 let registry = active_registry().unwrap();
23480 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
23481 assert_eq!(
23482 providers
23483 .iter()
23484 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
23485 .collect::<Vec<_>>(),
23486 vec![
23487 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23488 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23489 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23490 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23491 ]
23492 );
23493 assert_eq!(
23494 adapters
23495 .iter()
23496 .map(|contract| (
23497 contract.provider.as_str(),
23498 contract.family,
23499 contract.adapter_id.as_str(),
23500 contract.revision.as_str(),
23501 ))
23502 .collect::<Vec<_>>(),
23503 vec![
23504 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
23505 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
23506 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
23507 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
23508 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
23509 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
23510 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
23511 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
23512 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
23513 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
23514 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
23515 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
23516 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
23517 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
23518 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
23519 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
23520 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
23521 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
23522 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
23523 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
23524 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
23525 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
23526 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
23527 ]
23528 );
23529 }
23530
23531 fn construction_test_config(name: &str) -> NodeServerConfig {
23532 let workspace = WorkspaceConfig::new(
23533 WorkspaceId::new("primary").unwrap(),
23534 std::env::current_dir().unwrap(),
23535 )
23536 .unwrap();
23537 NodeServerConfig::new(
23538 format!(r"\\.\pipe\gate4agent-{name}"),
23539 "fixture-token",
23540 NodeId::new(name).unwrap(),
23541 [workspace],
23542 )
23543 .unwrap()
23544 }
23545
23546 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
23547 let adapter = AdapterBinding::new(
23548 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
23549 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
23550 gate4agent_types::AdapterVerification::SyntheticFixture,
23551 )
23552 .unwrap();
23553 let agent_ids = ["claude", "codex", "kimi"]
23554 .into_iter()
23555 .map(|agent_id| AgentId::new(agent_id).unwrap())
23556 .collect::<Vec<_>>();
23557 let adapters = gate4agent_catalog::AdapterRegistry::new(
23558 [
23559 AdapterFamily::PtySemantic,
23560 AdapterFamily::Pipe,
23561 AdapterFamily::OneShot,
23562 AdapterFamily::Acp,
23563 AdapterFamily::Hook,
23564 AdapterFamily::ManagedHook,
23565 AdapterFamily::History,
23566 AdapterFamily::Resume,
23567 AdapterFamily::SessionOptions,
23568 AdapterFamily::CapabilityProbe,
23569 ]
23570 .into_iter()
23571 .map(|family| gate4agent_catalog::AdapterDescriptor {
23572 family,
23573 binding: adapter.clone(),
23574 agents: agent_ids.clone(),
23575 }),
23576 )
23577 .unwrap();
23578 let specs = agent_ids
23579 .iter()
23580 .map(|agent_id| {
23581 let agent_id = agent_id.as_str();
23582 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
23583 spec.revision =
23584 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
23585 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
23586 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
23587 adapter: adapter.clone(),
23588 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
23589 launch_override: None,
23590 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
23591 });
23592 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
23593 adapter: adapter.clone(),
23594 launch_override: None,
23595 });
23596 spec.capabilities.adapters.hook = Some(adapter.clone());
23597 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
23598 spec.capabilities.adapters.one_shot = Some(adapter.clone());
23599 spec.capabilities.adapters.history = Some(adapter.clone());
23600 spec.capabilities.adapters.resume = Some(adapter.clone());
23601 spec.capabilities.adapters.session_options = Some(adapter.clone());
23602 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
23603 spec
23604 })
23605 .collect::<Vec<_>>();
23606 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
23607 }
23608
23609 #[test]
23617 fn node_server_construction_accepts_the_production_manifest() {
23618 let registry = active_registry().unwrap();
23619 let (providers, _) = provider_contract_manifest(®istry).unwrap();
23620 assert_eq!(providers.len(), registry.iter().count());
23621 assert!(!providers.is_empty());
23622 let server = NodeServer::new_with_registry(
23623 construction_test_config("manifest-production-bound"),
23624 registry,
23625 )
23626 .unwrap();
23627 drop(server);
23628 }
23629
23630 #[test]
23631 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
23632 let registry = maximum_size_provider_manifest_registry();
23633 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
23634 assert_eq!(providers.len(), 3);
23635 assert_eq!(adapters.len(), 30);
23636 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
23637 let error = match NodeServer::new_with_registry(
23638 construction_test_config("manifest-handshake-overflow"),
23639 registry,
23640 ) {
23641 Ok(_) => panic!("oversized negotiated manifest was accepted"),
23642 Err(error) => error,
23643 };
23644 assert!(matches!(
23645 error,
23646 NodeServerError::ProviderContractManifest(message)
23647 if message.contains("authentication binding length")
23648 && message.contains("8192-byte limit")
23649 ));
23650 }
23651
23652 #[test]
23653 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
23654 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
23655 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
23656 let registry = AgentRegistry::new([spec]).unwrap();
23657 assert!(matches!(
23658 provider_contract_manifest(®istry),
23659 Err(NodeServerError::ProviderContractManifest(message))
23660 if message.contains("exceeds the 128-byte limit")
23661 ));
23662 }
23663
23664 #[test]
23665 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
23666 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
23667 spec.revision = "invalid revision".to_owned();
23668 let registry = AgentRegistry::new([spec]).unwrap();
23669 assert!(matches!(
23670 provider_contract_manifest(®istry),
23671 Err(NodeServerError::ProviderContractManifest(message))
23672 if message.contains("bounded lowercase ASCII")
23673 ));
23674 }
23675
23676 #[test]
23677 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
23678 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
23679 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
23680 let conflicting_binding = builtin_registry()
23681 .get_by_id("codex")
23682 .unwrap()
23683 .capabilities
23684 .adapters
23685 .one_shot
23686 .clone()
23687 .unwrap();
23688 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
23689 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
23690 pipe.adapter = transport_binding;
23691 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
23692 let registry = AgentRegistry::new([spec]).unwrap();
23693 assert!(matches!(
23694 provider_contract_manifest(®istry),
23695 Err(NodeServerError::ProviderContractManifest(message))
23696 if message.contains("conflicting OneShot adapter bindings")
23697 ));
23698 }
23699
23700 #[test]
23701 fn public_node_failures_expose_only_fixed_categories() {
23702 let provider = failure(
23703 NodeFailureCode::BackendOperationFailed,
23704 r"provider bearer-secret failed at C:\private\session.jsonl",
23705 );
23706 assert_eq!(provider.message, "backend-operation-failed");
23707 assert!(!provider.message.contains("bearer-secret"));
23708 assert!(!provider.message.contains(r"C:\private"));
23709 let persistence = persistence_failure(io::Error::new(
23710 io::ErrorKind::Other,
23711 r"state write failed at C:\private\state-v1.json",
23712 ));
23713 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
23714 assert!(!persistence.message.contains(r"C:\private"));
23715
23716 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
23717 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
23718 assert_eq!(
23719 generic.to_string(),
23720 "node durable state failed: durable-state-load-failed",
23721 );
23722 }
23723 }
23724
23725 #[test]
23726 fn terminal_byte_sequences_are_bounded_before_dispatch() {
23727 let sequence = b"\x1b[1;5D".to_vec();
23728 assert_eq!(
23729 terminal_bytes_action(sequence.clone()).unwrap(),
23730 InputAction::TerminalBytes(sequence),
23731 );
23732 assert_eq!(
23733 terminal_bytes_action(Vec::new()).unwrap_err().code,
23734 NodeFailureCode::InvalidRequest,
23735 );
23736 assert_eq!(
23737 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
23738 .unwrap_err()
23739 .code,
23740 NodeFailureCode::InvalidRequest,
23741 );
23742 }
23743
23744 #[test]
23745 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
23746 let root = temporary_workspace_root("exclusive-state-owner");
23747 std::fs::create_dir_all(&root).unwrap();
23748 let workspace = WorkspaceConfig::new(
23749 WorkspaceId::new("primary").unwrap(),
23750 &root,
23751 )
23752 .unwrap();
23753 let state_path = root.join("state-v1.json");
23754 let endpoint = format!(
23755 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
23756 std::process::id(),
23757 std::time::SystemTime::now()
23758 .duration_since(std::time::UNIX_EPOCH)
23759 .unwrap()
23760 .as_nanos(),
23761 );
23762 let config = NodeServerConfig::new(
23763 endpoint,
23764 "fixture-token",
23765 NodeId::new("node-1").unwrap(),
23766 [workspace],
23767 )
23768 .unwrap()
23769 .with_state_path(&state_path)
23770 .unwrap();
23771
23772 let first = NodeServer::new(config.clone()).unwrap();
23773 let error = match NodeServer::new(config.clone()) {
23774 Ok(_) => panic!("a second node server acquired the same durable state path"),
23775 Err(error) => error,
23776 };
23777 assert!(matches!(
23778 &error,
23779 NodeServerError::DurableState(error)
23780 if error.kind() == io::ErrorKind::WouldBlock
23781 ));
23782 assert_eq!(
23783 error.to_string(),
23784 "node durable state failed: durable-state-lock-failed",
23785 );
23786 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
23787 drop(first);
23788 let second = NodeServer::new(config).unwrap();
23789 drop(second);
23790 std::fs::remove_dir_all(root).unwrap();
23791 }
23792
23793 #[test]
23794 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
23795 let catalog = active_registry().unwrap();
23796 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23797 let workspace_id = WorkspaceId::new("primary").unwrap();
23798 let workspace = WorkspaceConfig::new(
23799 workspace_id.clone(),
23800 std::env::current_dir().unwrap(),
23801 )
23802 .unwrap();
23803 let shared = NodeShared::new(
23804 handle,
23805 "fixture-token".to_owned(),
23806 NodeId::new("node-1").unwrap(),
23807 vec![workspace.clone()],
23808 vec![agent("claude"), agent("codex"), agent("kimi")],
23809 );
23810 let instance_id = AgentInstanceId(17);
23811 let generation = SessionGeneration(3);
23812 assert!(shared.address_for(instance_id, generation).is_none());
23813 shared.bind_session(instance_id, workspace_id.clone(), generation);
23814 let address = SessionAddress {
23815 workspace_id,
23816 session: SessionKey { instance_id, generation },
23817 };
23818 assert_eq!(
23819 shared.workspace_root(&address.workspace_id).unwrap(),
23820 workspace.canonical_root,
23821 );
23822 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
23823 shared.remove_binding(&address);
23824 assert!(shared.address_for(instance_id, generation).is_none());
23825 }
23826
23827 #[test]
23828 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
23829 let address = SessionAddress {
23830 workspace_id: WorkspaceId::new("primary").unwrap(),
23831 session: SessionKey {
23832 instance_id: AgentInstanceId(7),
23833 generation: SessionGeneration(2),
23834 },
23835 };
23836 let record_id = SessionRecordId::new("record-a").unwrap();
23837 let size = gate4agent_types::TerminalSize {
23838 rows: 24,
23839 columns: 80,
23840 };
23841 let address_requests = [
23842 NodeRequest::Resume {
23843 session: address.clone(),
23844 terminal_size: size,
23845 initial_prompt: None,
23846 },
23847 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
23848 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
23849 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
23850 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
23851 NodeRequest::TerminalControl {
23852 session: address.clone(),
23853 control: TerminalControl::Enter,
23854 },
23855 NodeRequest::Resize { session: address.clone(), size },
23856 NodeRequest::Interrupt { session: address.clone() },
23857 NodeRequest::Stop { session: address.clone(), force: false },
23858 NodeRequest::Remove { session: address },
23859 ];
23860 for request in &address_requests {
23861 assert!(request_requires_open_provider_ids_with(
23862 request,
23863 |_| Some(agent("third-party-agent")),
23864 |_| Some(agent("claude")),
23865 ));
23866 assert!(!request_requires_open_provider_ids_with(
23867 request,
23868 |_| Some(agent("claude")),
23869 |_| Some(agent("claude")),
23870 ));
23871 assert!(request_requires_open_provider_ids_with(
23872 request,
23873 |_| None,
23874 |_| Some(agent("claude")),
23875 ));
23876 }
23877
23878 let record_requests = [
23879 NodeRequest::RenameSessionRecord {
23880 record_id: record_id.clone(),
23881 display_name: "renamed".to_owned(),
23882 },
23883 NodeRequest::ResumeSessionRecord {
23884 record_id: record_id.clone(),
23885 terminal_size: size,
23886 initial_prompt: None,
23887 },
23888 NodeRequest::ForgetSessionRecord { record_id },
23889 ];
23890 for request in &record_requests {
23891 assert!(request_requires_open_provider_ids_with(
23892 request,
23893 |_| Some(agent("claude")),
23894 |_| Some(agent("grok")),
23895 ));
23896 assert!(!request_requires_open_provider_ids_with(
23897 request,
23898 |_| Some(agent("claude")),
23899 |_| Some(agent("kimi")),
23900 ));
23901 assert!(request_requires_open_provider_ids_with(
23902 request,
23903 |_| Some(agent("claude")),
23904 |_| None,
23905 ));
23906 }
23907
23908 assert!(request_requires_open_provider_ids_with(
23909 &NodeRequest::Spawn {
23910 workspace_id: WorkspaceId::new("primary").unwrap(),
23911 provider: agent("third-party-agent"),
23912 mode: SessionMode::Pty,
23913 terminal_size: size,
23914 initial_prompt: None,
23915 },
23916 |_| None,
23917 |_| None,
23918 ));
23919 assert!(!request_requires_open_provider_ids_with(
23920 &NodeRequest::Snapshot,
23921 |_| None,
23922 |_| None,
23923 ));
23924 assert!(request_requires_open_provider_ids_with(
23925 &NodeRequest::ForgetContextPack {
23926 context_id: SpawnContextId::new("ctx-forget").unwrap(),
23927 },
23928 |_| None,
23929 |_| None,
23930 ));
23931 }
23932
23933 #[test]
23934 fn legacy_projection_filters_open_record_replies_and_event_history() {
23935 let catalog = active_registry().unwrap();
23936 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23937 let workspace = WorkspaceConfig::new(
23938 WorkspaceId::new("primary").unwrap(),
23939 std::env::current_dir().unwrap(),
23940 )
23941 .unwrap();
23942 let shared = NodeShared::new(
23943 handle,
23944 "fixture-token".to_owned(),
23945 NodeId::new("node-1").unwrap(),
23946 vec![workspace],
23947 vec![agent("claude"), agent("grok")],
23948 );
23949 let legacy = record("claude", "legacy-record");
23950 let open = record("grok", "open-record");
23951 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
23952 record: legacy.clone(),
23953 });
23954 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
23955 record: open.clone(),
23956 });
23957 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
23958 record_id: legacy.record_id.clone(),
23959 });
23960 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
23961 record_id: open.record_id.clone(),
23962 });
23963 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
23964 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
23965 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
23966 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
23967
23968 let mut record_reply = ResponseEnvelope {
23969 request_id: 1,
23970 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
23971 };
23972 project_response_legacy_provider_ids(&shared, &mut record_reply);
23973 assert!(matches!(
23974 record_reply.result,
23975 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
23976 ));
23977
23978 let mut resync = ResponseEnvelope {
23979 request_id: 2,
23980 result: Ok(NodeResponse::Resync {
23981 event_sequence: open_removed.sequence,
23982 oldest_available_sequence: 1,
23983 snapshot: shared.snapshot(),
23984 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
23985 }),
23986 };
23987 project_response_legacy_provider_ids(&shared, &mut resync);
23988 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
23989 panic!("legacy projection changed the resync response kind");
23990 };
23991 assert!(snapshot.session_records.iter().all(|item| {
23992 provider_id_is_legacy(&item.provider)
23993 }));
23994 assert_eq!(events.len(), 2);
23995 assert!(events.iter().all(|event| matches!(
23996 &event.event,
23997 NodeEvent::SessionRecordUpserted { record }
23998 if provider_id_is_legacy(&record.provider)
23999 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
24000 }
24001
24002 #[test]
24003 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
24004 let catalog = active_registry().unwrap();
24005 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24006 let workspace_id = WorkspaceId::new("primary").unwrap();
24007 let workspace = WorkspaceConfig::new(
24008 workspace_id.clone(),
24009 std::env::current_dir().unwrap(),
24010 )
24011 .unwrap();
24012 let shared = NodeShared::new(
24013 handle,
24014 "fixture-token".to_owned(),
24015 NodeId::new("node-1").unwrap(),
24016 vec![workspace],
24017 vec![agent("claude")],
24018 );
24019 for index in 0..CONTROL_SESSIONS_MAX {
24020 shared.bind_session(
24021 AgentInstanceId((index + 1) as u64),
24022 workspace_id.clone(),
24023 SessionGeneration(1),
24024 );
24025 }
24026 let error = shared.ensure_binding_capacity().unwrap_err();
24027 assert_eq!(error.code, NodeFailureCode::BackendBusy);
24028 }
24029
24030 #[tokio::test]
24031 async fn provider_spawn_admission_rejects_before_session_mutation() {
24032 let catalog = active_registry().unwrap();
24033 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24034 let workspace = WorkspaceConfig::new(
24035 WorkspaceId::new("primary").unwrap(),
24036 std::env::current_dir().unwrap(),
24037 )
24038 .unwrap();
24039 let mut shared = NodeShared::new(
24040 handle,
24041 "fixture-token".to_owned(),
24042 NodeId::new("node-provider-admission").unwrap(),
24043 vec![workspace],
24044 vec![agent("claude")],
24045 );
24046 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24047 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
24048 ])
24049 .unwrap();
24050 let terminal_size = gate4agent_types::TerminalSize {
24051 rows: 24,
24052 columns: 80,
24053 };
24054
24055 let unavailable = shared
24056 .spawn_session(
24057 WorkspaceId::new("primary").unwrap(),
24058 agent("claude"),
24059 SessionMode::Pty,
24060 terminal_size,
24061 None,
24062 )
24063 .await
24064 .unwrap_err();
24065 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
24066 assert_eq!(unavailable.message, "backend-operation-failed");
24067
24068 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24069 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
24070 agent("claude"),
24071 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
24072 ),
24073 ])
24074 .unwrap();
24075 let semantic = shared
24076 .spawn_session(
24077 WorkspaceId::new("primary").unwrap(),
24078 agent("claude"),
24079 SessionMode::Inline,
24080 terminal_size,
24081 None,
24082 )
24083 .await
24084 .unwrap_err();
24085 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
24086 assert_eq!(semantic.message, "unsupported-capability");
24087
24088 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
24089 assert!(shared
24090 .session_bindings
24091 .lock()
24092 .unwrap_or_else(|poisoned| poisoned.into_inner())
24093 .is_empty());
24094 assert!(shared
24095 .session_records
24096 .lock()
24097 .unwrap_or_else(|poisoned| poisoned.into_inner())
24098 .records
24099 .is_empty());
24100 assert!(shared.handle.snapshot().sessions.is_empty());
24101 }
24102
24103 #[tokio::test]
24104 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
24105 let catalog = active_registry().unwrap();
24106 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24107 let workspace_id = WorkspaceId::new("primary").unwrap();
24108 let workspace = WorkspaceConfig::new(
24109 workspace_id.clone(),
24110 std::env::current_dir().unwrap(),
24111 )
24112 .unwrap();
24113 let shared = NodeShared::new(
24114 handle,
24115 "fixture-token".to_owned(),
24116 NodeId::new("node-raw-spawn-record").unwrap(),
24117 vec![workspace],
24118 vec![agent("claude")],
24119 );
24120 let address = SessionAddress {
24121 workspace_id,
24122 session: SessionKey {
24123 instance_id: AgentInstanceId(71),
24124 generation: SessionGeneration::default(),
24125 },
24126 };
24127
24128 let record_id = shared
24129 .bind_spawn_session(
24130 &address,
24131 agent("claude"),
24132 SessionMode::Pty,
24133 ProviderRuntimePolicy::raw_pty(),
24134 SpawnRecordPolicy::Always,
24135 None,
24136 )
24137 .unwrap()
24138 .unwrap();
24139 shared
24140 .handle
24141 .dispatch(shared.prepare_command(ControlCommand::Register {
24142 instance_id: address.session.instance_id,
24143 agent_id: agent("claude"),
24144 transport: TransportKind::Pty,
24145 }))
24146 .unwrap();
24147 runtime.tick().await;
24148
24149 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24150 session.instance_id == address.session.instance_id
24151 && session.generation == address.session.generation
24152 }));
24153 let record = shared.record(&record_id).unwrap();
24154 assert_eq!(record.display_name, "claude #1");
24155 assert_eq!(record.state, ManagedSessionState::Live);
24156 assert_eq!(record.active_session.as_ref(), Some(&address));
24157 assert!(record.provider_session.is_none());
24158 let binding_record_id = shared
24159 .session_bindings
24160 .lock()
24161 .unwrap_or_else(|poisoned| poisoned.into_inner())
24162 .get(&address.session.instance_id)
24163 .and_then(|binding| binding.record_id.clone());
24164 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
24165
24166 let renamed = shared
24167 .rename_session_record(&record_id, "raw review".to_owned())
24168 .unwrap();
24169 assert_eq!(renamed.record_id, record_id);
24170 assert_eq!(renamed.display_name, "raw review");
24171 shared.reconcile_managed_record(
24172 &record_id,
24173 &address,
24174 &ControlEvent {
24175 sequence: 1,
24176 command_id: None,
24177 instance_id: address.session.instance_id,
24178 generation: address.session.generation,
24179 event: ControlEventKind::Running { process_id: None },
24180 },
24181 false,
24182 false,
24183 );
24184 let running = shared.record(&record_id).unwrap();
24185 assert_eq!(running.state, ManagedSessionState::Live);
24186 assert_eq!(running.active_session.as_ref(), Some(&address));
24187 assert_eq!(running.display_name, "raw review");
24188
24189 shared.reconcile_managed_record(
24190 &record_id,
24191 &address,
24192 &ControlEvent {
24193 sequence: 2,
24194 command_id: None,
24195 instance_id: address.session.instance_id,
24196 generation: address.session.generation,
24197 event: ControlEventKind::Exited {
24198 exit_code: Some(0),
24199 forced: false,
24200 },
24201 },
24202 false,
24203 false,
24204 );
24205 let detached = shared.record(&record_id).unwrap();
24206 assert_eq!(detached.state, ManagedSessionState::Unavailable);
24207 assert!(detached.active_session.is_none());
24208 assert_eq!(detached.display_name, "raw review");
24209 let renamed_detached = shared
24210 .rename_session_record(&record_id, "raw review detached".to_owned())
24211 .unwrap();
24212 assert_eq!(renamed_detached.record_id, record_id);
24213 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
24214 assert!(renamed_detached.active_session.is_none());
24215 assert_eq!(renamed_detached.display_name, "raw review detached");
24216 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
24217 }
24218
24219 #[test]
24220 fn legacy_raw_spawn_binding_remains_untracked() {
24221 let catalog = active_registry().unwrap();
24222 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24223 let workspace_id = WorkspaceId::new("primary").unwrap();
24224 let workspace = WorkspaceConfig::new(
24225 workspace_id.clone(),
24226 std::env::current_dir().unwrap(),
24227 )
24228 .unwrap();
24229 let shared = NodeShared::new(
24230 handle,
24231 "fixture-token".to_owned(),
24232 NodeId::new("node-legacy-raw-spawn").unwrap(),
24233 vec![workspace],
24234 vec![agent("claude")],
24235 );
24236 let address = SessionAddress {
24237 workspace_id,
24238 session: SessionKey {
24239 instance_id: AgentInstanceId(72),
24240 generation: SessionGeneration::default(),
24241 },
24242 };
24243
24244 let record_id = shared
24245 .bind_spawn_session(
24246 &address,
24247 agent("claude"),
24248 SessionMode::Pty,
24249 ProviderRuntimePolicy::raw_pty(),
24250 SpawnRecordPolicy::ProviderIdentityOnly,
24251 None,
24252 )
24253 .unwrap();
24254
24255 assert!(record_id.is_none());
24256 let binding_record_id = shared
24257 .session_bindings
24258 .lock()
24259 .unwrap_or_else(|poisoned| poisoned.into_inner())
24260 .get(&address.session.instance_id)
24261 .and_then(|binding| binding.record_id.clone());
24262 assert!(binding_record_id.is_none());
24263 assert!(shared.snapshot().session_records.is_empty());
24264 }
24265
24266 #[tokio::test]
24267 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
24268 let catalog = active_registry().unwrap();
24269 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24270 let workspace_id = WorkspaceId::new("primary").unwrap();
24271 let workspace = WorkspaceConfig::new(
24272 workspace_id.clone(),
24273 std::env::current_dir().unwrap(),
24274 )
24275 .unwrap();
24276 let mut shared = NodeShared::new(
24277 handle,
24278 "fixture-token".to_owned(),
24279 NodeId::new("node-raw-semantic-gate").unwrap(),
24280 vec![workspace],
24281 vec![agent("claude")],
24282 );
24283 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24284 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
24285 ])
24286 .unwrap();
24287 shared
24288 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24289 .unwrap();
24290 let address = SessionAddress {
24291 workspace_id,
24292 session: SessionKey {
24293 instance_id: AgentInstanceId(72),
24294 generation: SessionGeneration::default(),
24295 },
24296 };
24297 shared.bind_session_with_policy(
24298 &address,
24299 ProviderRuntimePolicy::raw_pty(),
24300 None,
24301 );
24302 shared
24303 .handle
24304 .dispatch(shared.prepare_command(ControlCommand::Register {
24305 instance_id: address.session.instance_id,
24306 agent_id: agent("claude"),
24307 transport: TransportKind::Pty,
24308 }))
24309 .unwrap();
24310 runtime.tick().await;
24311 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
24312 let revision_before = shared.handle.snapshot().revision;
24313 let history_sequence_before = shared.current_sequence();
24314 let terminal_size = gate4agent_types::TerminalSize {
24315 rows: 24,
24316 columns: 80,
24317 };
24318
24319 for request in [
24320 NodeRequest::Prompt {
24321 session: address.clone(),
24322 text: "prompt".to_owned(),
24323 },
24324 NodeRequest::Paste {
24325 session: address.clone(),
24326 text: "paste".to_owned(),
24327 },
24328 NodeRequest::Resume {
24334 session: address.clone(),
24335 terminal_size,
24336 initial_prompt: Some("resume".to_owned()),
24337 },
24338 ] {
24339 let error = process_request_inner(
24340 &shared,
24341 77,
24342 ClientRole::Operator,
24343 request,
24344 )
24345 .await
24346 .unwrap_err();
24347 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
24348 }
24349
24350 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
24351 assert_eq!(shared.handle.snapshot().revision, revision_before);
24352 assert_eq!(shared.current_sequence(), history_sequence_before);
24353 let bindings = shared
24354 .session_bindings
24355 .lock()
24356 .unwrap_or_else(|poisoned| poisoned.into_inner());
24357 let binding = bindings.get(&address.session.instance_id).unwrap();
24358 assert_eq!(binding.generation, address.session.generation);
24359 assert!(binding.pending_resume.is_none());
24360 assert!(binding.record_id.is_none());
24361 }
24362
24363 #[tokio::test]
24364 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
24365 let catalog = active_registry().unwrap();
24366 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24367 let primary = WorkspaceConfig::new(
24368 WorkspaceId::new("primary").unwrap(),
24369 std::env::current_dir().unwrap(),
24370 )
24371 .unwrap();
24372 let shared = NodeShared::new(
24373 handle,
24374 "fixture-token".to_owned(),
24375 NodeId::new("node-1").unwrap(),
24376 vec![primary],
24377 vec![agent("claude")],
24378 );
24379 shared
24380 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24381 .unwrap();
24382 let before = shared.snapshot();
24383 let error = process_request_inner(
24384 &shared,
24385 77,
24386 ClientRole::Operator,
24387 NodeRequest::RegisterWorkspace {
24388 workspace_id: WorkspaceId::new("foreign").unwrap(),
24389 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
24390 },
24391 )
24392 .await
24393 .unwrap_err();
24394 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
24395 assert_eq!(error.message, "invalid-request");
24396 assert_eq!(shared.snapshot().workspaces, before.workspaces);
24397 }
24398
24399 #[tokio::test]
24400 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
24401 let catalog = active_registry().unwrap();
24402 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24403 let primary_id = WorkspaceId::new("primary").unwrap();
24404 let primary = WorkspaceConfig::new(
24405 primary_id.clone(),
24406 std::env::current_dir().unwrap(),
24407 )
24408 .unwrap();
24409 let shared = NodeShared::new(
24410 handle,
24411 "fixture-token".to_owned(),
24412 NodeId::new("node-1").unwrap(),
24413 vec![primary.clone()],
24414 vec![agent("claude")],
24415 );
24416 let secondary_id = WorkspaceId::new("secondary").unwrap();
24417 let secondary = shared
24418 .register_workspace(
24419 secondary_id.clone(),
24420 std::env::temp_dir().to_string_lossy().into_owned(),
24421 )
24422 .await
24423 .unwrap();
24424 assert_eq!(secondary.workspace_id, secondary_id);
24425 assert!(secondary.sessions.is_empty());
24426 assert_eq!(shared.snapshot().workspaces.len(), 2);
24427
24428 let duplicate_id = shared
24429 .register_workspace(
24430 secondary_id.clone(),
24431 primary.canonical_root().to_owned(),
24432 )
24433 .await
24434 .unwrap_err();
24435 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
24436 let duplicate_root = shared
24437 .register_workspace(
24438 WorkspaceId::new("duplicate-root").unwrap(),
24439 windows_path_text(&secondary.canonical_root).to_owned(),
24440 )
24441 .await
24442 .unwrap_err();
24443 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
24444
24445 let address = SessionAddress {
24446 workspace_id: secondary_id.clone(),
24447 session: SessionKey {
24448 instance_id: AgentInstanceId(41),
24449 generation: SessionGeneration(1),
24450 },
24451 };
24452 shared.bind_session(
24453 address.session.instance_id,
24454 secondary_id.clone(),
24455 address.session.generation,
24456 );
24457 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
24458 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
24459 shared.remove_binding(&address);
24460 shared.unregister_workspace(&secondary_id).unwrap();
24461 assert_eq!(shared.snapshot().workspaces.len(), 1);
24462
24463 let last = shared.unregister_workspace(&primary_id).unwrap_err();
24464 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
24465 let history = shared
24466 .history
24467 .lock()
24468 .unwrap_or_else(|poisoned| poisoned.into_inner());
24469 assert!(matches!(
24470 &history.events[0],
24471 NodeEventEnvelope {
24472 sequence: 1,
24473 event: NodeEvent::WorkspaceAdded { workspace },
24474 } if workspace.workspace_id == secondary_id
24475 ));
24476 assert!(matches!(
24477 &history.events[1],
24478 NodeEventEnvelope {
24479 sequence: 2,
24480 event: NodeEvent::WorkspaceRemoved { workspace_id },
24481 } if workspace_id == &secondary_id
24482 ));
24483 }
24484
24485 #[tokio::test]
24486 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
24487 let secondary_root = temporary_workspace_root("provider-session-reference-index");
24488 std::fs::create_dir_all(&secondary_root).unwrap();
24489 let catalog = active_registry().unwrap();
24490 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24491 let primary_id = WorkspaceId::new("primary").unwrap();
24492 let secondary_id = WorkspaceId::new("secondary").unwrap();
24493 let shared = NodeShared::new(
24494 handle,
24495 "fixture-token".to_owned(),
24496 NodeId::new("node-session-reference-index").unwrap(),
24497 vec![
24498 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
24499 .unwrap(),
24500 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
24501 ],
24502 vec![agent("claude"), agent("codex")],
24503 );
24504 let identity = ProviderSessionIdentity {
24505 key: gate4agent_types::ProviderSessionKey::SessionId,
24506 id: "provider-session-42".to_owned(),
24507 transcript_path: None,
24508 };
24509 let index_request = |workspace_id: WorkspaceId,
24510 provider: AgentId,
24511 identity: ProviderSessionIdentity,
24512 display_name: &str| {
24513 NodeRequest::IndexProviderSession {
24514 workspace_id,
24515 provider,
24516 identity,
24517 display_name: display_name.to_owned(),
24518 }
24519 };
24520 let request = index_request(
24521 primary_id.clone(),
24522 agent("claude"),
24523 identity.clone(),
24524 "release shepherd",
24525 );
24526 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24527 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
24528 }));
24529 let observer = process_request_inner(
24530 &shared,
24531 77,
24532 ClientRole::Observer,
24533 request.clone(),
24534 )
24535 .await
24536 .unwrap_err();
24537 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
24538 let uncontrolled = process_request_inner(
24539 &shared,
24540 77,
24541 ClientRole::Operator,
24542 request.clone(),
24543 )
24544 .await
24545 .unwrap_err();
24546 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
24547 shared
24548 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24549 .unwrap();
24550
24551 let indexed = process_request_inner(
24552 &shared,
24553 77,
24554 ClientRole::Operator,
24555 request.clone(),
24556 )
24557 .await
24558 .unwrap();
24559 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
24560 panic!("provider session reference index returned another response");
24561 };
24562 assert_eq!(record.state, ManagedSessionState::Dormant);
24563 assert_eq!(record.mode, SessionMode::Pty);
24564 assert_eq!(record.workspace_id, primary_id);
24565 assert_eq!(record.provider_session.as_ref(), Some(&identity));
24566 assert!(record.active_session.is_none());
24567 assert!(record.environment_profile.is_none());
24568 assert!(record.bundle.is_none());
24569 assert!(record.context.is_none());
24570
24571 let repeated = process_request_inner(
24572 &shared,
24573 77,
24574 ClientRole::Operator,
24575 index_request(
24576 primary_id.clone(),
24577 agent("claude"),
24578 identity.clone(),
24579 "ignored replacement",
24580 ),
24581 )
24582 .await
24583 .unwrap();
24584 assert!(matches!(
24585 repeated,
24586 NodeResponse::ProviderSessionIndexed { record: ref existing }
24587 if existing.record_id == record.record_id
24588 && existing.display_name == "release shepherd"
24589 ));
24590 let conflicting_workspace = process_request_inner(
24591 &shared,
24592 77,
24593 ClientRole::Operator,
24594 index_request(
24595 secondary_id,
24596 agent("claude"),
24597 identity.clone(),
24598 "release shepherd",
24599 ),
24600 )
24601 .await
24602 .unwrap_err();
24603 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
24604 let conflicting_provider = process_request_inner(
24605 &shared,
24606 77,
24607 ClientRole::Operator,
24608 index_request(
24609 primary_id.clone(),
24610 agent("codex"),
24611 identity.clone(),
24612 "release shepherd",
24613 ),
24614 )
24615 .await
24616 .unwrap_err();
24617 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
24618 let transcript_rejected = process_request_inner(
24619 &shared,
24620 77,
24621 ClientRole::Operator,
24622 index_request(
24623 primary_id,
24624 agent("claude"),
24625 ProviderSessionIdentity {
24626 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
24627 ..identity
24628 },
24629 "release shepherd",
24630 ),
24631 )
24632 .await
24633 .unwrap_err();
24634 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
24635 let upserts = shared
24636 .history
24637 .lock()
24638 .unwrap_or_else(|poisoned| poisoned.into_inner())
24639 .events
24640 .iter()
24641 .filter(|event| matches!(
24642 &event.event,
24643 NodeEvent::SessionRecordUpserted { record: published }
24644 if published.record_id == record.record_id
24645 ))
24646 .count();
24647 assert_eq!(upserts, 1);
24648 std::fs::remove_dir_all(secondary_root).unwrap();
24649 }
24650
24651 #[tokio::test]
24652 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
24653 let fixture_root = temporary_workspace_root("standalone-independent");
24654 let target = fixture_root.join("independent");
24655 std::fs::create_dir_all(&fixture_root).unwrap();
24656 let catalog = active_registry().unwrap();
24657 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24658 let selected = WorkspaceConfig::new(
24659 WorkspaceId::new("primary").unwrap(),
24660 std::env::current_dir().unwrap(),
24661 )
24662 .unwrap();
24663 let selected_root = selected.canonical_root().to_owned();
24664 let shared = NodeShared::new(
24665 handle,
24666 "fixture-token".to_owned(),
24667 NodeId::new("node-standalone").unwrap(),
24668 vec![selected],
24669 vec![agent("claude")],
24670 );
24671 let workspace_id = WorkspaceId::new("independent").unwrap();
24672 let request = NodeRequest::CreateStandaloneWorkspace {
24673 workspace_id: workspace_id.clone(),
24674 root: opaque_windows_path(target.to_string_lossy().into_owned()),
24675 initial_branch: Some("main".to_owned()),
24676 };
24677 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24678 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
24679 }));
24680 assert!(!request_uses_unnegotiated_capability(
24681 &request,
24682 &baseline_capabilities().unwrap(),
24683 ));
24684 assert!(request_uses_unnegotiated_capability(&request, &[]));
24685
24686 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
24687 .await
24688 .unwrap_err();
24689 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
24690 assert!(!target.exists());
24691 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
24692 .await
24693 .unwrap_err();
24694 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
24695 assert!(!target.exists());
24696 shared
24697 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24698 .unwrap();
24699
24700 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
24701 .await
24702 .unwrap();
24703 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
24704 panic!("standalone workspace creation returned another response");
24705 };
24706 assert_eq!(workspace.workspace_id, workspace_id);
24707 assert!(target.join(".git").is_dir());
24708 let branch = run_git_bounded(
24709 target.to_str().unwrap(),
24710 &["branch", "--show-current"],
24711 GIT_OUTPUT_MAX_BYTES,
24712 )
24713 .await
24714 .unwrap();
24715 assert!(branch.success);
24716 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
24717 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
24718 assert_eq!(worktrees.len(), 1);
24719 let listed_root = WorkspaceConfig::new(
24720 WorkspaceId::new("listed-independent").unwrap(),
24721 &worktrees[0].path,
24722 )
24723 .unwrap();
24724 assert!(platform::roots_equal(
24725 listed_root.canonical_root(),
24726 windows_path_text(&workspace.canonical_root),
24727 ));
24728 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
24729 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
24730 snapshot.workspace_id == workspace_id
24731 && snapshot.canonical_root == workspace.canonical_root
24732 }));
24733 assert!(shared
24734 .history
24735 .lock()
24736 .unwrap_or_else(|poisoned| poisoned.into_inner())
24737 .events
24738 .iter()
24739 .any(|event| matches!(
24740 &event.event,
24741 NodeEvent::WorkspaceAdded { workspace }
24742 if workspace.workspace_id == workspace_id
24743 )));
24744 std::fs::remove_dir_all(fixture_root).unwrap();
24745 }
24746
24747 #[tokio::test]
24748 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
24749 let fixture_root = temporary_workspace_root("standalone-compensation");
24750 let created_target = fixture_root.join("created-target");
24751 let existing_target = fixture_root.join("existing-target");
24752 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
24753 let invalid_state_path = invalid_state_parent.join("state.json");
24754 std::fs::create_dir_all(&existing_target).unwrap();
24755 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
24756 let catalog = active_registry().unwrap();
24757 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24758 let primary = WorkspaceConfig::new(
24759 WorkspaceId::new("primary").unwrap(),
24760 std::env::current_dir().unwrap(),
24761 )
24762 .unwrap();
24763 let mut shared = NodeShared::new(
24764 handle,
24765 "fixture-token".to_owned(),
24766 NodeId::new("node-standalone-compensation").unwrap(),
24767 vec![primary],
24768 vec![agent("claude")],
24769 );
24770 shared.state_path = Some(invalid_state_path);
24771
24772 let created_recovery = shared
24773 .create_standalone_workspace(
24774 WorkspaceId::new("compensated").unwrap(),
24775 created_target.to_string_lossy().into_owned(),
24776 Some("main".to_owned()),
24777 )
24778 .await
24779 .unwrap_err();
24780 assert_eq!(
24781 created_recovery.code,
24782 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
24783 );
24784 assert!(created_target.join(".git").is_dir());
24785 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
24786 workspace.workspace_id.as_str() != "compensated"
24787 }));
24788
24789 let existing_recovery = shared
24790 .create_standalone_workspace(
24791 WorkspaceId::new("restored").unwrap(),
24792 existing_target.to_string_lossy().into_owned(),
24793 None,
24794 )
24795 .await
24796 .unwrap_err();
24797 assert_eq!(
24798 existing_recovery.code,
24799 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
24800 );
24801 assert!(existing_target.is_dir());
24802 assert!(existing_target.join(".git").is_dir());
24803 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
24804 workspace.workspace_id.as_str() != "restored"
24805 }));
24806
24807 let registered_empty = fixture_root.join("registered-empty");
24808 std::fs::create_dir(®istered_empty).unwrap();
24809 let catalog = active_registry().unwrap();
24810 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24811 let registered = WorkspaceConfig::new(
24812 WorkspaceId::new("registered-empty").unwrap(),
24813 ®istered_empty,
24814 )
24815 .unwrap();
24816 let duplicate_shared = NodeShared::new(
24817 handle,
24818 "fixture-token".to_owned(),
24819 NodeId::new("node-standalone-duplicate-root").unwrap(),
24820 vec![registered],
24821 vec![agent("claude")],
24822 );
24823 let duplicate = duplicate_shared
24824 .create_standalone_workspace(
24825 WorkspaceId::new("duplicate-root").unwrap(),
24826 registered_empty.to_string_lossy().into_owned(),
24827 None,
24828 )
24829 .await
24830 .unwrap_err();
24831 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
24832 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
24833 std::fs::remove_dir_all(fixture_root).unwrap();
24834 }
24835
24836 #[test]
24837 fn managed_record_id_collision_preserves_the_existing_record() {
24838 let catalog = active_registry().unwrap();
24839 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24840 let workspace = WorkspaceConfig::new(
24841 WorkspaceId::new("primary").unwrap(),
24842 std::env::current_dir().unwrap(),
24843 )
24844 .unwrap();
24845 let shared = NodeShared::new(
24846 handle,
24847 "fixture-token".to_owned(),
24848 NodeId::new("node-1").unwrap(),
24849 vec![workspace.clone()],
24850 vec![agent("claude")],
24851 );
24852 let record_id = SessionRecordId::new("sr-collision").unwrap();
24853 let original = ManagedSessionRecord {
24854 record_id: record_id.clone(),
24855 display_name: "original".to_owned(),
24856 provider: agent("claude"),
24857 mode: SessionMode::Pty,
24858 state: ManagedSessionState::Dormant,
24859 workspace_id: workspace.workspace_id,
24860 canonical_root: opaque_windows_path(workspace.canonical_root),
24861 provider_session: None,
24862 active_session: None,
24863 environment_profile: None,
24864 bundle: None,
24865 context_id: None,
24866 context: None,
24867 exported_context: None,
24868 task_binding: None,
24869 created_at_unix_ms: 1,
24870 updated_at_unix_ms: 1,
24871 last_error: None,
24872 };
24873 shared.insert_record(original.clone()).unwrap();
24874 let mut collision = original.clone();
24875 collision.display_name = "replacement".to_owned();
24876 let error = shared.insert_record(collision).unwrap_err();
24877 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24878 assert_eq!(shared.record(&record_id).unwrap(), original);
24879 }
24880
24881 #[test]
24882 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
24883 let catalog = active_registry().unwrap();
24884 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24885 let workspace = WorkspaceConfig::new(
24886 WorkspaceId::new("primary").unwrap(),
24887 std::env::current_dir().unwrap(),
24888 )
24889 .unwrap();
24890 let shared = NodeShared::new(
24891 handle,
24892 "fixture-token".to_owned(),
24893 NodeId::new("node-1").unwrap(),
24894 vec![workspace.clone()],
24895 vec![agent("claude")],
24896 );
24897 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
24898 shared
24899 .insert_record(ManagedSessionRecord {
24900 record_id: record_id.clone(),
24901 display_name: "runtime error".to_owned(),
24902 provider: agent("claude"),
24903 mode: SessionMode::Pty,
24904 state: ManagedSessionState::IdentityPending,
24905 workspace_id: workspace.workspace_id,
24906 canonical_root: opaque_windows_path(workspace.canonical_root),
24907 provider_session: None,
24908 active_session: None,
24909 environment_profile: None,
24910 bundle: None,
24911 context_id: None,
24912 context: None,
24913 exported_context: None,
24914 task_binding: None,
24915 created_at_unix_ms: 1,
24916 updated_at_unix_ms: 1,
24917 last_error: None,
24918 })
24919 .unwrap();
24920
24921 shared
24922 .mark_record_error(
24923 &record_id,
24924 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
24925 )
24926 .unwrap();
24927 let record = shared.record(&record_id).unwrap();
24928 assert_eq!(record.state, ManagedSessionState::Unavailable);
24929 assert_eq!(
24930 record.last_error.as_deref(),
24931 Some("provider-runtime-failed"),
24932 );
24933 let snapshot = shared.snapshot();
24934 assert_eq!(snapshot.session_records, vec![record]);
24935 }
24936
24937 #[test]
24946 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
24947 let catalog = active_registry().unwrap();
24948 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24949 let workspace = WorkspaceConfig::new(
24950 WorkspaceId::new("primary").unwrap(),
24951 std::env::current_dir().unwrap(),
24952 )
24953 .unwrap();
24954 let shared = NodeShared::new(
24955 handle,
24956 "fixture-token".to_owned(),
24957 NodeId::new("node-1").unwrap(),
24958 vec![workspace],
24959 vec![agent("claude")],
24960 );
24961
24962 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
24963
24964 shared.record_rejected_command(
24965 CommandId(1),
24966 "agent 'claude' does not support transport Acp".to_owned(),
24967 true,
24968 );
24969 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
24971
24972 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
24973 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
24974 assert!(reason.unsupported_transport);
24975
24976 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
24978
24979 shared.record_rejected_command(
24984 CommandId(3),
24985 "backend busy".to_owned(),
24986 false,
24987 );
24988 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
24989 assert!(!unclassified.unsupported_transport);
24990 }
24991
24992 #[test]
24993 fn provider_identity_update_is_ignored_without_exact_session_policy() {
24994 let catalog = active_registry().unwrap();
24995 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24996 let workspace = WorkspaceConfig::new(
24997 WorkspaceId::new("primary").unwrap(),
24998 std::env::current_dir().unwrap(),
24999 )
25000 .unwrap();
25001 let shared = NodeShared::new(
25002 handle,
25003 "fixture-token".to_owned(),
25004 NodeId::new("node-identity-policy").unwrap(),
25005 vec![workspace.clone()],
25006 vec![agent("claude")],
25007 );
25008 let address = SessionAddress {
25009 workspace_id: workspace.workspace_id.clone(),
25010 session: SessionKey {
25011 instance_id: AgentInstanceId(90),
25012 generation: SessionGeneration(1),
25013 },
25014 };
25015 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
25016 shared
25017 .insert_record(ManagedSessionRecord {
25018 record_id: record_id.clone(),
25019 display_name: "policy denied".to_owned(),
25020 provider: agent("claude"),
25021 mode: SessionMode::Pty,
25022 state: ManagedSessionState::IdentityPending,
25023 workspace_id: workspace.workspace_id,
25024 canonical_root: opaque_windows_path(workspace.canonical_root),
25025 provider_session: None,
25026 active_session: Some(address.clone()),
25027 environment_profile: None,
25028 bundle: None,
25029 context_id: None,
25030 context: None,
25031 exported_context: None,
25032 task_binding: None,
25033 created_at_unix_ms: 1,
25034 updated_at_unix_ms: 1,
25035 last_error: None,
25036 })
25037 .unwrap();
25038 let event = ControlEvent {
25039 sequence: 1,
25040 command_id: None,
25041 instance_id: address.session.instance_id,
25042 generation: address.session.generation,
25043 event: ControlEventKind::ProviderEvent {
25044 sequence: 1,
25045 source: gate4agent_types::ProviderSource {
25046 family: AdapterFamily::PtySemantic,
25047 binding: AdapterBinding::new(
25048 gate4agent_types::AdapterId::new("claude").unwrap(),
25049 "fixture/v1",
25050 gate4agent_types::AdapterVerification::SyntheticFixture,
25051 )
25052 .unwrap(),
25053 },
25054 source_sequence: 1,
25055 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25056 identity: gate4agent_types::ProviderSessionIdentity {
25057 key: gate4agent_types::ProviderSessionKey::SessionId,
25058 id: "provider-session".to_owned(),
25059 transcript_path: None,
25060 },
25061 },
25062 },
25063 };
25064
25065 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
25066
25067 let record = shared.record(&record_id).unwrap();
25068 assert!(record.provider_session.is_none());
25069 assert_eq!(record.state, ManagedSessionState::IdentityPending);
25070 assert_eq!(record.active_session.as_ref(), Some(&address));
25071 }
25072
25073 #[test]
25074 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
25075 let secondary_root = temporary_workspace_root("identity-scope");
25076 std::fs::create_dir_all(&secondary_root).unwrap();
25077 let catalog = active_registry().unwrap();
25078 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25079 let primary = WorkspaceConfig::new(
25080 WorkspaceId::new("primary").unwrap(),
25081 std::env::current_dir().unwrap(),
25082 )
25083 .unwrap();
25084 let secondary = WorkspaceConfig::new(
25085 WorkspaceId::new("secondary").unwrap(),
25086 &secondary_root,
25087 )
25088 .unwrap();
25089 let shared = NodeShared::new(
25090 handle,
25091 "fixture-token".to_owned(),
25092 NodeId::new("node-1").unwrap(),
25093 vec![primary.clone(), secondary.clone()],
25094 vec![agent("claude")],
25095 );
25096 let identity = gate4agent_types::ProviderSessionIdentity {
25097 key: gate4agent_types::ProviderSessionKey::SessionId,
25098 id: "provider-session".to_owned(),
25099 transcript_path: None,
25100 };
25101 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
25102 shared
25103 .insert_record(ManagedSessionRecord {
25104 record_id: dormant_id.clone(),
25105 display_name: "dormant".to_owned(),
25106 provider: agent("claude"),
25107 mode: SessionMode::Pty,
25108 state: ManagedSessionState::Dormant,
25109 workspace_id: primary.workspace_id,
25110 canonical_root: opaque_windows_path(primary.canonical_root),
25111 provider_session: Some(identity.clone()),
25112 active_session: None,
25113 environment_profile: None,
25114 bundle: None,
25115 context_id: None,
25116 context: None,
25117 exported_context: None,
25118 task_binding: None,
25119 created_at_unix_ms: 1,
25120 updated_at_unix_ms: 1,
25121 last_error: None,
25122 })
25123 .unwrap();
25124 let address = SessionAddress {
25125 workspace_id: secondary.workspace_id.clone(),
25126 session: SessionKey {
25127 instance_id: AgentInstanceId(91),
25128 generation: SessionGeneration(1),
25129 },
25130 };
25131 let current_id = SessionRecordId::new("sr-current").unwrap();
25132 shared
25133 .insert_record(ManagedSessionRecord {
25134 record_id: current_id.clone(),
25135 display_name: "current".to_owned(),
25136 provider: agent("claude"),
25137 mode: SessionMode::Pty,
25138 state: ManagedSessionState::IdentityPending,
25139 workspace_id: secondary.workspace_id,
25140 canonical_root: opaque_windows_path(secondary.canonical_root),
25141 provider_session: None,
25142 active_session: Some(address.clone()),
25143 environment_profile: None,
25144 bundle: None,
25145 context_id: None,
25146 context: None,
25147 exported_context: None,
25148 task_binding: None,
25149 created_at_unix_ms: 2,
25150 updated_at_unix_ms: 2,
25151 last_error: None,
25152 })
25153 .unwrap();
25154 shared.bind_managed_session(
25155 &address,
25156 current_id.clone(),
25157 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25158 None,
25159 );
25160 let event = ControlEvent {
25161 sequence: 1,
25162 command_id: None,
25163 instance_id: address.session.instance_id,
25164 generation: address.session.generation,
25165 event: ControlEventKind::ProviderEvent {
25166 sequence: 1,
25167 source: gate4agent_types::ProviderSource {
25168 family: gate4agent_types::AdapterFamily::PtySemantic,
25169 binding: gate4agent_types::AdapterBinding::new(
25170 gate4agent_types::AdapterId::new("claude").unwrap(),
25171 "fixture/v1",
25172 gate4agent_types::AdapterVerification::SyntheticFixture,
25173 )
25174 .unwrap(),
25175 },
25176 source_sequence: 1,
25177 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25178 identity,
25179 },
25180 },
25181 };
25182 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
25183
25184 let binding = shared
25185 .session_bindings
25186 .lock()
25187 .unwrap_or_else(|poisoned| poisoned.into_inner())
25188 .get(&address.session.instance_id)
25189 .cloned()
25190 .unwrap();
25191 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
25192 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
25193 let current = shared.record(¤t_id).unwrap();
25194 assert_eq!(current.state, ManagedSessionState::Unavailable);
25195 assert_eq!(current.active_session.as_ref(), Some(&address));
25196 assert_eq!(
25197 current.last_error.as_deref(),
25198 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
25199 );
25200 std::fs::remove_dir_all(secondary_root).unwrap();
25201 }
25202
25203 #[tokio::test]
25204 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
25205 let root = temporary_workspace_root("semantic-provider-session");
25206 std::fs::create_dir_all(&root).unwrap();
25207 let state_path = root.join("state-v1.json");
25208 let catalog = active_registry().unwrap();
25209 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25210 let workspace = WorkspaceConfig::new(
25211 WorkspaceId::new("primary").unwrap(),
25212 &root,
25213 )
25214 .unwrap();
25215 let node_id = NodeId::new("node-semantic-identity").unwrap();
25216 let shared = NodeShared::new_with_incarnation(
25217 handle,
25218 "fixture-token".to_owned(),
25219 node_id.clone(),
25220 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
25221 vec![workspace.clone()],
25222 vec![agent("claude")],
25223 ProviderRuntimeStatuses::default(),
25224 None,
25225 Vec::new(),
25226 Vec::new(),
25227 SpawnProfileRegistry::default(),
25228 None,
25229 None,
25230 None,
25231 Some(state_path.clone()),
25232 Vec::new(),
25233 Vec::new(),
25234 Vec::new(),
25235 Vec::new(),
25236 None,
25237 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
25238 );
25239 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
25240 key: gate4agent_types::ProviderSessionKey::SessionId,
25241 id: "provider-session".to_owned(),
25242 transcript_path: None,
25243 };
25244 let durable_id = SessionRecordId::new("sr-durable").unwrap();
25245 shared
25246 .insert_record(ManagedSessionRecord {
25247 record_id: durable_id.clone(),
25248 display_name: "durable".to_owned(),
25249 provider: agent("claude"),
25250 mode: SessionMode::Pty,
25251 state: ManagedSessionState::Dormant,
25252 workspace_id: workspace.workspace_id.clone(),
25253 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25254 provider_session: Some(persisted_identity.clone()),
25255 active_session: None,
25256 environment_profile: None,
25257 bundle: None,
25258 context_id: None,
25259 context: None,
25260 exported_context: None,
25261 task_binding: None,
25262 created_at_unix_ms: 1,
25263 updated_at_unix_ms: 1,
25264 last_error: None,
25265 })
25266 .unwrap();
25267 let address = SessionAddress {
25268 workspace_id: workspace.workspace_id.clone(),
25269 session: SessionKey {
25270 instance_id: AgentInstanceId(92),
25271 generation: SessionGeneration(2),
25272 },
25273 };
25274 let pending_id = SessionRecordId::new("sr-pending").unwrap();
25275 shared
25276 .insert_record(ManagedSessionRecord {
25277 record_id: pending_id.clone(),
25278 display_name: "pending".to_owned(),
25279 provider: agent("claude"),
25280 mode: SessionMode::Pty,
25281 state: ManagedSessionState::IdentityPending,
25282 workspace_id: workspace.workspace_id.clone(),
25283 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25284 provider_session: None,
25285 active_session: Some(address.clone()),
25286 environment_profile: None,
25287 bundle: None,
25288 context_id: None,
25289 context: None,
25290 exported_context: None,
25291 task_binding: None,
25292 created_at_unix_ms: 2,
25293 updated_at_unix_ms: 2,
25294 last_error: None,
25295 })
25296 .unwrap();
25297 shared.bind_managed_session(
25298 &address,
25299 pending_id.clone(),
25300 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25301 None,
25302 );
25303 let observed_identity = gate4agent_types::ProviderSessionIdentity {
25304 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
25305 ..persisted_identity
25306 };
25307 let event = ControlEvent {
25308 sequence: 1,
25309 command_id: None,
25310 instance_id: address.session.instance_id,
25311 generation: address.session.generation,
25312 event: ControlEventKind::ProviderEvent {
25313 sequence: 1,
25314 source: gate4agent_types::ProviderSource {
25315 family: gate4agent_types::AdapterFamily::PtySemantic,
25316 binding: gate4agent_types::AdapterBinding::new(
25317 gate4agent_types::AdapterId::new("claude").unwrap(),
25318 "fixture/v1",
25319 gate4agent_types::AdapterVerification::SyntheticFixture,
25320 )
25321 .unwrap(),
25322 },
25323 source_sequence: 1,
25324 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25325 identity: observed_identity.clone(),
25326 },
25327 },
25328 };
25329 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
25330
25331 let snapshot = shared.snapshot();
25332 assert_eq!(snapshot.session_records.len(), 1);
25333 let live = &snapshot.session_records[0];
25334 assert_eq!(live.record_id, durable_id);
25335 assert_eq!(live.state, ManagedSessionState::Live);
25336 assert_eq!(live.active_session.as_ref(), Some(&address));
25337 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
25338 let binding = shared
25339 .session_bindings
25340 .lock()
25341 .unwrap_or_else(|poisoned| poisoned.into_inner())
25342 .get(&address.session.instance_id)
25343 .cloned()
25344 .unwrap();
25345 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
25346
25347 let mut duplicate = live.clone();
25348 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
25349 duplicate.display_name = "duplicate".to_owned();
25350 duplicate.state = ManagedSessionState::Dormant;
25351 duplicate.active_session = None;
25352 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
25353 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
25354 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
25355 assert_eq!(shared.snapshot().session_records.len(), 1);
25356
25357 let terminal_size = gate4agent_types::TerminalSize {
25358 rows: 24,
25359 columns: 80,
25360 };
25361 let (first_busy, second_busy) = tokio::join!(
25362 shared.resume_session_record(&durable_id, terminal_size, None),
25363 shared.resume_session_record(&durable_id, terminal_size, None),
25364 );
25365 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
25366 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
25367 assert_eq!(busy.message, "session-record-busy");
25368 }
25369
25370 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
25371 assert_eq!(loaded.records.len(), 1);
25372 assert_eq!(loaded.records[0].record_id, durable_id);
25373 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
25374 assert_eq!(
25375 loaded.records[0]
25376 .provider_session
25377 .as_ref()
25378 .unwrap()
25379 .transcript_path,
25380 None,
25381 );
25382 let catalog = active_registry().unwrap();
25383 let (reloaded_handle, _reloaded_runtime) =
25384 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25385 let reloaded = NodeShared::new_with_incarnation(
25386 reloaded_handle,
25387 "fixture-token".to_owned(),
25388 node_id,
25389 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
25390 vec![workspace],
25391 vec![agent("claude")],
25392 ProviderRuntimeStatuses::default(),
25393 None,
25394 Vec::new(),
25395 Vec::new(),
25396 SpawnProfileRegistry::default(),
25397 None,
25398 None,
25399 None,
25400 None,
25401 loaded.records,
25402 loaded.managed_worktrees,
25403 loaded.managed_worktree_tombstones,
25404 loaded.materializations,
25405 loaded.warning,
25406 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
25407 );
25408 assert_eq!(reloaded.snapshot().session_records.len(), 1);
25409 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
25410 std::fs::remove_dir_all(root).unwrap();
25411 }
25412
25413 #[tokio::test]
25414 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
25415 let secondary_root = temporary_workspace_root("workspace-restore");
25416 let other_root = temporary_workspace_root("workspace-conflict");
25417 std::fs::create_dir_all(&secondary_root).unwrap();
25418 std::fs::create_dir_all(&other_root).unwrap();
25419 let catalog = active_registry().unwrap();
25420 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25421 let primary = WorkspaceConfig::new(
25422 WorkspaceId::new("primary").unwrap(),
25423 std::env::current_dir().unwrap(),
25424 )
25425 .unwrap();
25426 let secondary = WorkspaceConfig::new(
25427 WorkspaceId::new("secondary").unwrap(),
25428 &secondary_root,
25429 )
25430 .unwrap();
25431 let shared = NodeShared::new(
25432 handle,
25433 "fixture-token".to_owned(),
25434 NodeId::new("node-1").unwrap(),
25435 vec![primary, secondary.clone()],
25436 vec![agent("claude")],
25437 );
25438 let record_id = SessionRecordId::new("sr-workspace").unwrap();
25439 shared
25440 .insert_record(ManagedSessionRecord {
25441 record_id: record_id.clone(),
25442 display_name: "workspace record".to_owned(),
25443 provider: agent("claude"),
25444 mode: SessionMode::Pty,
25445 state: ManagedSessionState::Dormant,
25446 workspace_id: secondary.workspace_id.clone(),
25447 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
25448 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
25449 key: gate4agent_types::ProviderSessionKey::SessionId,
25450 id: "provider-session".to_owned(),
25451 transcript_path: None,
25452 }),
25453 active_session: None,
25454 environment_profile: None,
25455 bundle: None,
25456 context_id: None,
25457 context: None,
25458 exported_context: None,
25459 task_binding: None,
25460 created_at_unix_ms: 1,
25461 updated_at_unix_ms: 1,
25462 last_error: None,
25463 })
25464 .unwrap();
25465 shared.unregister_workspace(&secondary.workspace_id).unwrap();
25466 let unavailable = shared.record(&record_id).unwrap();
25467 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
25468 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
25469
25470 let conflict = shared
25471 .register_workspace(
25472 secondary.workspace_id.clone(),
25473 other_root.to_string_lossy().into_owned(),
25474 )
25475 .await
25476 .unwrap_err();
25477 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
25478 shared
25479 .register_workspace(
25480 secondary.workspace_id,
25481 secondary_root.to_string_lossy().into_owned(),
25482 )
25483 .await
25484 .unwrap();
25485 let restored = shared.record(&record_id).unwrap();
25486 assert_eq!(restored.state, ManagedSessionState::Dormant);
25487 assert!(restored.last_error.is_none());
25488 std::fs::remove_dir_all(secondary_root).unwrap();
25489 std::fs::remove_dir_all(other_root).unwrap();
25490 }
25491
25492 #[test]
25493 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
25494 let error = validate_workspace_request_root(
25495 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
25496 )
25497 .unwrap_err();
25498 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
25499 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
25500 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
25501 }
25502
25503 #[test]
25504 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
25505 let catalog = active_registry().unwrap();
25506 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25507 let workspace = WorkspaceConfig::new(
25508 WorkspaceId::new("primary").unwrap(),
25509 std::env::current_dir().unwrap(),
25510 )
25511 .unwrap();
25512 let shared = NodeShared::new(
25513 handle,
25514 "fixture-token".to_owned(),
25515 NodeId::new("node-1").unwrap(),
25516 vec![workspace],
25517 vec![agent("claude")],
25518 );
25519 let publishers = 4_u64;
25520 let events_per_publisher = 256_u64;
25521 let start = std::sync::Barrier::new(publishers as usize + 1);
25522 let finished = AtomicU64::new(0);
25523
25524 std::thread::scope(|scope| {
25525 for _ in 0..publishers {
25526 scope.spawn(|| {
25527 start.wait();
25528 for _ in 0..events_per_publisher {
25529 shared.publish(NodeEvent::ControllerChanged { controller: None });
25530 }
25531 finished.fetch_add(1, Ordering::Release);
25532 });
25533 }
25534 start.wait();
25535 while finished.load(Ordering::Acquire) != publishers {
25536 assert_resync_history_is_linearized(shared.resync(0));
25537 std::thread::yield_now();
25538 }
25539 });
25540 let response = shared.resync(0);
25541 assert_resync_history_is_linearized(response.clone());
25542 let NodeResponse::Resync { event_sequence, events, .. } = response else {
25543 unreachable!("resync helper returned another response");
25544 };
25545 assert_eq!(event_sequence, publishers * events_per_publisher);
25546 assert_eq!(events.len() as u64, event_sequence);
25547 }
25548
25549 fn assert_resync_history_is_linearized(response: NodeResponse) {
25550 let NodeResponse::Resync { event_sequence, events, .. } = response else {
25551 unreachable!("resync helper returned another response");
25552 };
25553 assert!(events.windows(2).all(|pair| {
25554 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
25555 }));
25556 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
25557 }
25558
25559 #[test]
25560 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
25561 assert_eq!(
25562 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
25563 r"C:\repo\worktree",
25564 );
25565 assert_eq!(
25566 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
25567 r"\\server\share\repo",
25568 );
25569 assert!(validate_workspace_root(
25570 &WorkspaceId::new("network").unwrap(),
25571 r"\\server\share\repo",
25572 )
25573 .is_err());
25574 let workspace = WorkspaceConfig::new(
25575 WorkspaceId::new("primary").unwrap(),
25576 std::env::current_dir().unwrap(),
25577 )
25578 .unwrap();
25579 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
25580 }
25581
25582 #[test]
25583 fn resume_rebinds_only_on_the_expected_authorized_generation() {
25584 let catalog = active_registry().unwrap();
25585 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25586 let workspace_id = WorkspaceId::new("primary").unwrap();
25587 let workspace = WorkspaceConfig::new(
25588 workspace_id.clone(),
25589 std::env::current_dir().unwrap(),
25590 )
25591 .unwrap();
25592 let shared = NodeShared::new(
25593 handle,
25594 "fixture-token".to_owned(),
25595 NodeId::new("node-1").unwrap(),
25596 vec![workspace],
25597 vec![agent("claude")],
25598 );
25599 let instance_id = AgentInstanceId(17);
25600 let original = SessionAddress {
25601 workspace_id,
25602 session: SessionKey {
25603 instance_id,
25604 generation: SessionGeneration(3),
25605 },
25606 };
25607 shared.bind_session(
25608 instance_id,
25609 original.workspace_id.clone(),
25610 original.session.generation,
25611 );
25612 shared
25613 .arm_resume(
25614 &original,
25615 CommandId(41),
25616 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25617 )
25618 .unwrap();
25619
25620 shared.publish_control(ControlEvent {
25621 sequence: 1,
25622 command_id: None,
25623 instance_id,
25624 generation: SessionGeneration(4),
25625 event: ControlEventKind::Running { process_id: None },
25626 });
25627 assert_eq!(
25628 shared.address_for(instance_id, SessionGeneration(3)),
25629 Some(original.clone()),
25630 );
25631 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
25632
25633 shared.publish_control(ControlEvent {
25634 sequence: 2,
25635 command_id: Some(CommandId(42)),
25636 instance_id,
25637 generation: SessionGeneration(3),
25638 event: ControlEventKind::CommandRejected {
25639 message: "unrelated command".to_owned(),
25640 },
25641 });
25642
25643 shared.publish_control(ControlEvent {
25644 sequence: 3,
25645 command_id: None,
25646 instance_id,
25647 generation: SessionGeneration(4),
25648 event: ControlEventKind::ResumeAuthorized {
25649 session: gate4agent_types::ResumeSessionSummary {
25650 key: gate4agent_types::ProviderSessionKey::SessionId,
25651 id: "fixture-session".to_owned(),
25652 },
25653 },
25654 });
25655 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
25656 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
25657 assert_eq!(rebound.workspace_id, original.workspace_id);
25658 let history = shared
25659 .history
25660 .lock()
25661 .unwrap_or_else(|poisoned| poisoned.into_inner());
25662 assert!(history.events.iter().any(|envelope| matches!(
25663 &envelope.event,
25664 NodeEvent::Control { address, event }
25665 if address == &rebound
25666 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
25667 )));
25668 }
25669
25670 #[test]
25671 fn all_node_pty_prompts_use_bracketed_paste_framing() {
25672 for agent in ["claude", "codex", "kimi"] {
25673 assert_eq!(
25674 prompt_framing(&AgentId::new(agent).unwrap()),
25675 PromptFraming::BracketedPaste
25676 );
25677 }
25678 }
25679
25680 fn generous_walk_deadline() -> Instant {
25681 Instant::now() + Duration::from_secs(30)
25682 }
25683
25684 #[test]
25685 fn workspace_tree_is_relative_and_skips_heavy_directories() {
25686 let root = temporary_workspace_root("tree");
25687 std::fs::create_dir_all(root.join("src")).unwrap();
25688 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
25689 std::fs::create_dir_all(root.join("target/debug")).unwrap();
25690 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
25691 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
25692 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
25693 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
25699 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
25700 std::fs::create_dir_all(root.join("target-notes")).unwrap();
25704 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
25705
25706 let (entries, truncated, _budget) =
25707 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
25708 assert!(!truncated);
25709 assert!(entries.iter().any(|entry| {
25710 entry.relative_path.as_utf8() == Some("src")
25711 && entry.kind == WorkspaceEntryKind::Directory
25712 }));
25713 assert!(entries.iter().any(|entry| {
25714 entry.relative_path.as_utf8() == Some("src/lib.rs")
25715 && entry.kind == WorkspaceEntryKind::File
25716 }));
25717 assert!(entries.iter().any(|entry| {
25718 entry.relative_path.as_utf8() == Some("target-notes")
25719 && entry.kind == WorkspaceEntryKind::Directory
25720 }));
25721 assert!(entries.iter().any(|entry| {
25722 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
25723 && entry.kind == WorkspaceEntryKind::File
25724 }));
25725 assert!(entries.iter().all(|entry| {
25726 let path = entry.relative_path.as_utf8().unwrap();
25727 !path.starts_with(".git")
25728 && !(path == "target" || path.starts_with("target/"))
25729 && !(path == "target-a2" || path.starts_with("target-a2/"))
25730 && !path.starts_with("node_modules")
25731 }));
25732
25733 std::fs::remove_dir_all(&root).unwrap();
25734 }
25735
25736 #[test]
25737 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
25738 let root = temporary_workspace_root("tree-entry-cap");
25739 std::fs::create_dir_all(&root).unwrap();
25740 for index in 0..20 {
25741 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
25742 }
25743
25744 let (entries, truncated, budget) =
25745 collect_workspace_entries(&root, generous_walk_deadline(), 5);
25746 assert!(truncated);
25747 assert!(budget.entry_cap_exceeded);
25748 assert!(!budget.time_budget_exceeded);
25749 assert!(entries.len() <= 5);
25750
25751 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
25752 .expect("an entry-cap-truncated walk must report a truncation marker");
25753 assert!(inspection_truncation.walk_entry_cap_exceeded);
25754 assert!(!inspection_truncation.walk_time_budget_exceeded);
25755 assert!(!inspection_truncation.git_time_budget_exceeded);
25756 assert!(inspection_truncation.entries_visited >= 5);
25757
25758 std::fs::remove_dir_all(&root).unwrap();
25759 }
25760
25761 #[test]
25762 fn workspace_walk_within_budget_reports_no_truncation() {
25763 let root = temporary_workspace_root("tree-no-truncation");
25764 std::fs::create_dir_all(&root).unwrap();
25765 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
25766
25767 let (_entries, truncated, budget) =
25768 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
25769 assert!(!truncated);
25770 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
25771
25772 std::fs::remove_dir_all(&root).unwrap();
25773 }
25774
25775 #[test]
25776 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
25777 let root = temporary_workspace_root("tree-prefix-siblings");
25778 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
25779 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
25780 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
25781 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
25782
25783 let (entries, truncated, _budget) =
25784 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
25785 assert!(!truncated);
25786 let paths = entries
25787 .iter()
25788 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
25789 .collect::<Vec<_>>();
25790 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
25791 assert_eq!(
25792 paths,
25793 vec![
25794 "gate4agent-c2".to_owned(),
25795 "gate4agent-c2-client".to_owned(),
25796 "gate4agent-c2-client/lib.rs".to_owned(),
25797 "gate4agent-c2/src".to_owned(),
25798 "gate4agent-c2/src/lib.rs".to_owned(),
25799 ],
25800 );
25801
25802 std::fs::remove_dir_all(&root).unwrap();
25803 }
25804
25805 #[test]
25806 fn git_parsers_cap_status_and_commit_results() {
25807 let mut status_output = Vec::new();
25808 for index in 0..=GIT_STATUS_MAX_ENTRIES {
25809 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
25810 }
25811 let mut snapshot = git_snapshot_for_parser();
25812 parse_git_status(&status_output, &mut snapshot);
25813 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
25814 assert!(snapshot.truncated);
25815 assert_eq!(snapshot.status[0].index_status, " ");
25816 assert_eq!(snapshot.status[0].worktree_status, "M");
25817
25818 snapshot.truncated = false;
25819 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
25820 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
25821 .collect::<String>();
25822 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
25823 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
25824 assert!(snapshot.truncated);
25825 }
25826
25827 #[test]
25828 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
25829 let mut snapshot = git_snapshot_for_parser();
25830 parse_git_status(
25831 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
25832 &mut snapshot,
25833 );
25834
25835 assert!(!snapshot.truncated);
25836 assert_eq!(snapshot.status.len(), 3);
25837 assert_eq!(snapshot.status[0].index_status, "R");
25838 assert_eq!(snapshot.status[0].worktree_status, " ");
25839 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
25840 assert_eq!(
25841 snapshot.status[0]
25842 .previous_path
25843 .as_ref()
25844 .and_then(RepositoryPath::as_utf8),
25845 Some("src/previous name.rs"),
25846 );
25847 assert_eq!(snapshot.status[1].index_status, " ");
25848 assert_eq!(snapshot.status[1].worktree_status, "C");
25849 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
25850 assert_eq!(
25851 snapshot.status[1]
25852 .previous_path
25853 .as_ref()
25854 .and_then(RepositoryPath::as_utf8),
25855 Some("src/original.rs"),
25856 );
25857 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
25858 assert!(snapshot.status[2].previous_path.is_none());
25859 }
25860
25861 #[test]
25862 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
25863 let mut snapshot = git_snapshot_for_parser();
25864 parse_git_status(
25865 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
25866 &mut snapshot,
25867 );
25868
25869 assert!(snapshot.truncated);
25870 assert_eq!(snapshot.status.len(), 1);
25871 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
25872 assert!(snapshot.status[0].previous_path.is_none());
25873 }
25874
25875 #[tokio::test]
25876 async fn observer_can_browse_host_directories_without_controller() {
25877 let root = temporary_workspace_root("host-directory-observer");
25878 std::fs::create_dir_all(root.join("directory-b")).unwrap();
25879 std::fs::create_dir_all(root.join("directory-a")).unwrap();
25880 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
25881 let workspace_id = WorkspaceId::new("primary").unwrap();
25882 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
25883 let catalog = active_registry().unwrap();
25884 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25885 let shared = NodeShared::new(
25886 handle,
25887 "fixture-token".to_owned(),
25888 NodeId::new("node-1").unwrap(),
25889 vec![workspace],
25890 vec![agent("claude")],
25891 );
25892 let request = NodeRequest::BrowseHostDirectories {
25893 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
25894 after: None,
25895 };
25896 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25897 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
25898 }));
25899 assert!(!request_uses_unnegotiated_capability(
25900 &request,
25901 &baseline_capabilities().unwrap(),
25902 ));
25903 assert!(request_uses_unnegotiated_capability(&request, &[]));
25904
25905 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
25906 .await
25907 .unwrap();
25908 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
25909 panic!("host directory browse returned another response");
25910 };
25911 assert_eq!(
25912 listing
25913 .entries
25914 .iter()
25915 .map(|entry| entry.display_name.as_str())
25916 .collect::<Vec<_>>(),
25917 vec!["directory-a", "directory-b"],
25918 );
25919 assert!(listing.entries.iter().all(|entry| !entry.is_link));
25920 assert!(!listing.incomplete);
25921 std::fs::remove_dir_all(root).unwrap();
25922 }
25923
25924 #[tokio::test]
25925 async fn observer_can_inspect_only_a_registered_workspace() {
25926 let root = temporary_workspace_root("observer");
25927 std::fs::create_dir_all(root.join("src")).unwrap();
25928 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
25929 let workspace_id = WorkspaceId::new("primary").unwrap();
25930 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
25931 let catalog = active_registry().unwrap();
25932 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25933 let shared = NodeShared::new(
25934 handle,
25935 "fixture-token".to_owned(),
25936 NodeId::new("node-1").unwrap(),
25937 vec![workspace],
25938 vec![agent("claude")],
25939 );
25940
25941 let response = process_request_inner(
25942 &shared,
25943 77,
25944 ClientRole::Observer,
25945 NodeRequest::InspectWorkspace {
25946 workspace_id: workspace_id.clone(),
25947 },
25948 )
25949 .await
25950 .unwrap();
25951 let NodeResponse::WorkspaceInspected { inspection } = response else {
25952 panic!("workspace inspection returned another response");
25953 };
25954 assert_eq!(inspection.workspace_id, workspace_id);
25955 assert!(inspection.entries.iter().any(|entry| {
25956 entry.relative_path.as_utf8() == Some("src/main.rs")
25957 }));
25958
25959 let unknown = process_request_inner(
25960 &shared,
25961 77,
25962 ClientRole::Observer,
25963 NodeRequest::InspectWorkspace {
25964 workspace_id: WorkspaceId::new("unknown").unwrap(),
25965 },
25966 )
25967 .await
25968 .unwrap_err();
25969 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
25970 std::fs::remove_dir_all(&root).unwrap();
25971 }
25972
25973 #[test]
25974 fn context_pack_source_accepts_only_running_or_successful_exit() {
25975 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
25976 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
25977 exit_code: Some(0),
25978 }));
25979 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
25980 exit_code: Some(1),
25981 }));
25982 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
25983 exit_code: None,
25984 }));
25985 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
25986 message: "provider failed".to_owned(),
25987 }));
25988 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
25989 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
25990 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
25991 }
25992
25993 #[test]
25994 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
25995 let git = git_snapshot_for_parser();
25996 let first = ContextPackRepositoryObservation {
25997 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
25998 git: git.clone(),
25999 files: Vec::new(),
26000 };
26001 let second = ContextPackRepositoryObservation {
26002 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
26003 git,
26004 files: Vec::new(),
26005 };
26006
26007 let error = stable_context_pack_repository(first, second).unwrap_err();
26008
26009 assert_eq!(
26010 error.code,
26011 NodeFailureCode::ContextPackMaterializationFailed,
26012 );
26013 }
26014
26015 #[test]
26016 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
26017 let git = git_snapshot_for_parser();
26018 let first = ContextPackRepositoryObservation {
26019 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26020 git: git.clone(),
26021 files: vec![ContextPackRepositoryFileSource::utf8(
26022 "README.md",
26023 "first\n".to_owned(),
26024 6,
26025 )],
26026 };
26027 let second = ContextPackRepositoryObservation {
26028 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26029 git,
26030 files: vec![ContextPackRepositoryFileSource::utf8(
26031 "README.md",
26032 "other\n".to_owned(),
26033 6,
26034 )],
26035 };
26036
26037 let error = stable_context_pack_repository(first, second).unwrap_err();
26038
26039 assert_eq!(
26040 error.code,
26041 NodeFailureCode::ContextPackMaterializationFailed,
26042 );
26043 }
26044
26045 #[tokio::test]
26046 async fn native_session_catalog_zero_active_session_fixture() {
26047 let workspace_root = temporary_workspace_root("native-catalog-workspace");
26048 let history_root = temporary_workspace_root("native-catalog-history");
26049 let outside_root = temporary_workspace_root("native-catalog-outside");
26050 std::fs::create_dir_all(&workspace_root).unwrap();
26051 std::fs::create_dir_all(&history_root).unwrap();
26052 std::fs::create_dir_all(&outside_root).unwrap();
26053 let transcript = |session_id: &str, cwd: &Path, title: &str| {
26054 [
26055 serde_json::json!({
26056 "type": "user",
26057 "sessionId": session_id,
26058 "cwd": cwd.to_str().unwrap(),
26059 "message": { "content": "raw user text must not cross the catalog wire" }
26060 }),
26061 serde_json::json!({
26062 "type": "assistant",
26063 "sessionId": session_id,
26064 "cwd": cwd.to_str().unwrap(),
26065 "message": { "model": "claude-sonnet", "content": "raw answer" }
26066 }),
26067 serde_json::json!({
26068 "type": "ai-title",
26069 "sessionId": session_id,
26070 "aiTitle": title
26071 }),
26072 ]
26073 .into_iter()
26074 .map(|record| record.to_string())
26075 .collect::<Vec<_>>()
26076 .join("\n")
26077 };
26078 std::fs::write(
26079 history_root.join("stable-session.jsonl"),
26080 transcript("stable-session", &workspace_root, "Workspace review"),
26081 )
26082 .unwrap();
26083 std::fs::write(
26084 history_root.join("outside-session.jsonl"),
26085 transcript("outside-session", &outside_root, "Outside review"),
26086 )
26087 .unwrap();
26088 let history = NativeHistoryConfig::new(vec![
26089 NativeHistoryRoot::new(
26090 gate4agent_types::AdapterId::new("claude-code").unwrap(),
26091 HistorySourceLayout::SingleNdjson,
26092 history_root.clone(),
26093 )
26094 .unwrap(),
26095 ])
26096 .unwrap();
26097 let catalog = active_registry().unwrap();
26098 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26099 assert!(handle.snapshot().sessions.is_empty());
26100 let workspace_id = WorkspaceId::new("primary").unwrap();
26101 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
26102 let mut shared = NodeShared::new(
26103 handle,
26104 "fixture-token".to_owned(),
26105 NodeId::new("node-native-catalog").unwrap(),
26106 vec![workspace],
26107 vec![agent("claude")],
26108 );
26109 shared.native_session_catalog = Some(Arc::new(Mutex::new(
26110 NativeSessionCatalogAuthority::new(history),
26111 )));
26112 let route = NativeSessionCatalogRoute::workspace(
26113 workspace_id.clone(),
26114 agent("claude"),
26115 );
26116 let request = NodeRequest::CatalogNativeSessions {
26117 route: route.clone(),
26118 limit: 2,
26119 };
26120 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
26121 .await
26122 .unwrap_err();
26123 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26124 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
26125 .await
26126 .unwrap();
26127 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
26128 panic!("native catalog returned a different response");
26129 };
26130 let summary = summary.expect("node must emit native session catalog summary metadata");
26131 assert_eq!(entries.len(), 1);
26132 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
26133 assert_eq!(entries[0].external_group, None);
26134 assert_eq!(entries[0].record_id, None);
26135 assert_eq!(entries[0].title, None);
26136 assert_eq!(entries[0].model, None);
26137 assert_eq!(entries[0].message_count, 0);
26138 let encoded_entries = serde_json::to_string(&entries).unwrap();
26139 assert!(!encoded_entries.contains("stable-session"));
26140 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
26141 let page_request = NodeRequest::PageNativeSessions {
26142 route: route.clone(),
26143 window: crate::protocol::NativeSessionCatalogWindow::Recent,
26144 catalog_revision: summary.catalog_revision,
26145 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26146 after_selection_id: None,
26147 limit: 2,
26148 };
26149 let observer = process_request_inner(
26150 &shared,
26151 7,
26152 ClientRole::Observer,
26153 page_request.clone(),
26154 )
26155 .await
26156 .unwrap_err();
26157 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26158 let page_response = process_request_inner(
26159 &shared,
26160 7,
26161 ClientRole::Operator,
26162 page_request.clone(),
26163 )
26164 .await
26165 .unwrap();
26166 assert!(matches!(
26167 page_response,
26168 NodeResponse::NativeSessionsPaged { page, .. }
26169 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
26170 ));
26171 let stale = process_request_inner(
26172 &shared,
26173 7,
26174 ClientRole::Operator,
26175 NodeRequest::PageNativeSessions {
26176 route: route.clone(),
26177 window: crate::protocol::NativeSessionCatalogWindow::Recent,
26178 catalog_revision: summary.catalog_revision.wrapping_add(1),
26179 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26180 after_selection_id: None,
26181 limit: 2,
26182 },
26183 )
26184 .await
26185 .unwrap_err();
26186 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
26187 let selection = NativeSessionSelection {
26188 route: route.clone(),
26189 catalog_revision: summary.catalog_revision,
26190 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26191 selection_id: entries[0].selection_id.clone(),
26192 };
26193 let preview_response = process_request_inner(
26194 &shared,
26195 7,
26196 ClientRole::Operator,
26197 NodeRequest::PreviewNativeSession {
26198 selection: selection.clone(),
26199 message_limit: 1,
26200 },
26201 )
26202 .await
26203 .unwrap();
26204 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
26205 panic!("native preview returned a different response");
26206 };
26207 assert_eq!(preview.messages.len(), 1);
26208 assert!(preview.message_count_exact);
26209 assert_eq!(preview.messages[0].text, "raw answer");
26210 assert!(preview.truncated);
26211
26212 let index_request = NodeRequest::IndexNativeSession {
26213 selection: selection.clone(),
26214 display_name: "Saved Claude".to_owned(),
26215 };
26216 let observer = process_request_inner(
26217 &shared,
26218 7,
26219 ClientRole::Observer,
26220 index_request.clone(),
26221 )
26222 .await
26223 .unwrap_err();
26224 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26225 let without_controller = process_request_inner(
26226 &shared,
26227 7,
26228 ClientRole::Operator,
26229 index_request.clone(),
26230 )
26231 .await
26232 .unwrap_err();
26233 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
26234 let acquired = process_request_inner(
26235 &shared,
26236 7,
26237 ClientRole::Operator,
26238 NodeRequest::AcquireController { lease_ms: 5_000 },
26239 )
26240 .await
26241 .unwrap();
26242 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
26243 let indexed = process_request_inner(
26244 &shared,
26245 7,
26246 ClientRole::Operator,
26247 index_request,
26248 )
26249 .await
26250 .unwrap();
26251 assert!(indexed.native_session_index_contract_is_valid());
26252 let NodeResponse::NativeSessionIndexed {
26253 selection: echoed_selection,
26254 record,
26255 } = indexed else {
26256 panic!("native session index returned a different response");
26257 };
26258 assert_eq!(echoed_selection, selection);
26259 let recataloged = process_request_inner(
26260 &shared,
26261 7,
26262 ClientRole::Operator,
26263 NodeRequest::CatalogNativeSessions {
26264 route,
26265 limit: 2,
26266 },
26267 )
26268 .await
26269 .unwrap();
26270 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
26271 panic!("native catalog returned a different response after indexing");
26272 };
26273 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
26274
26275 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
26276 let external_catalog = process_request_inner(
26277 &shared,
26278 7,
26279 ClientRole::Operator,
26280 NodeRequest::CatalogNativeSessions {
26281 route: unregistered_route.clone(),
26282 limit: 2,
26283 },
26284 )
26285 .await
26286 .unwrap();
26287 let external_json = serde_json::to_string(&external_catalog).unwrap();
26288 assert!(!external_json.contains("outside-session"));
26289 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
26290 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
26291 assert!(!external_json.contains("\"session_id\""));
26292 assert!(!external_json.contains("\"cwd\""));
26293 assert!(!external_json.contains("\"path\""));
26294 let NodeResponse::NativeSessionsCataloged {
26295 route: echoed_route,
26296 entries: external_entries,
26297 summary: Some(external_summary),
26298 } = external_catalog
26299 else {
26300 panic!("unregistered native catalog returned a different response");
26301 };
26302 assert_eq!(echoed_route, unregistered_route);
26303 assert_eq!(external_entries.len(), 1);
26304 assert_eq!(external_entries[0].record_id, None);
26305 let external_group = external_entries[0]
26306 .external_group
26307 .as_ref()
26308 .expect("unregistered session must have an external group");
26309 assert_eq!(
26310 external_group.kind,
26311 gate4agent_types::NativeSessionExternalGroupKind::Project,
26312 );
26313 assert!(external_group.group_id.starts_with("external-"));
26314 assert_eq!(
26315 external_group.display_name,
26316 outside_root.file_name().unwrap().to_string_lossy(),
26317 );
26318 let external_selection = NativeSessionSelection {
26319 route: unregistered_route,
26320 catalog_revision: external_summary.catalog_revision,
26321 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
26322 selection_id: external_entries[0].selection_id.clone(),
26323 };
26324 let external_preview = process_request_inner(
26325 &shared,
26326 7,
26327 ClientRole::Operator,
26328 NodeRequest::PreviewNativeSession {
26329 selection: external_selection.clone(),
26330 message_limit: 1,
26331 },
26332 )
26333 .await
26334 .unwrap();
26335 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
26336 assert!(!external_preview_json.contains("outside-session"));
26337 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
26338 assert!(!external_preview_json.contains("\"session_id\""));
26339 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
26340 panic!("unregistered native preview returned a different response");
26341 };
26342 assert_eq!(preview.messages.len(), 1);
26343 let external_index = process_request_inner(
26344 &shared,
26345 7,
26346 ClientRole::Operator,
26347 NodeRequest::IndexNativeSession {
26348 selection: external_selection,
26349 display_name: "External Claude".to_owned(),
26350 },
26351 )
26352 .await
26353 .unwrap_err();
26354 assert_eq!(
26355 external_index.code,
26356 NodeFailureCode::WorkspaceRegistrationRequired,
26357 );
26358 let record_response = process_request_inner(
26359 &shared,
26360 7,
26361 ClientRole::Operator,
26362 NodeRequest::PreviewSessionRecord {
26363 record_id: record.record_id.clone(),
26364 message_limit: 2,
26365 },
26366 )
26367 .await
26368 .unwrap();
26369 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
26370 panic!("record preview returned a different response");
26371 };
26372 assert_eq!(record_id, record.record_id);
26373 assert_eq!(preview.messages.len(), 2);
26374 let encoded = serde_json::to_string(&preview).unwrap();
26375 assert!(!encoded.contains("stable-session"));
26376 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
26377 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
26378 panic!("record preview history summary returned a different response");
26379 };
26380 let summaries = events
26381 .iter()
26382 .filter_map(|envelope| match &envelope.event {
26383 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
26384 if record_id == &record.record_id =>
26385 {
26386 Some(*summary)
26387 }
26388 _ => None,
26389 })
26390 .collect::<Vec<_>>();
26391 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
26392 let summary_json = serde_json::to_string(&summaries).unwrap();
26393 for private in [
26394 "stable-session",
26395 "raw user text must not cross the catalog wire",
26396 "raw answer",
26397 "Workspace review",
26398 "claude-sonnet",
26399 ] {
26400 assert!(!summary_json.contains(private), "{private}");
26401 }
26402 let identity = record.provider_session.clone().unwrap();
26403 assert_eq!(
26404 shared.revalidate_session_record_preview(&record, &identity),
26405 Ok(())
26406 );
26407 shared.remove_record_memory(&record.record_id);
26408 assert_eq!(
26409 shared
26410 .revalidate_session_record_preview(&record, &identity)
26411 .unwrap_err()
26412 .code,
26413 NodeFailureCode::UnknownSessionRecord
26414 );
26415 let released = process_request_inner(
26416 &shared,
26417 7,
26418 ClientRole::Operator,
26419 NodeRequest::ReleaseController,
26420 )
26421 .await
26422 .unwrap();
26423 assert!(matches!(released, NodeResponse::Controller { controller: None }));
26424 assert!(shared.controller_state().is_none());
26425 std::fs::remove_dir_all(workspace_root).unwrap();
26426 std::fs::remove_dir_all(history_root).unwrap();
26427 std::fs::remove_dir_all(outside_root).unwrap();
26428 }
26429
26430 #[tokio::test]
26431 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
26432 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
26433 std::fs::create_dir_all(&history_root).unwrap();
26434 let history = NativeHistoryConfig::new(vec![
26435 NativeHistoryRoot::new(
26436 gate4agent_types::AdapterId::new("claude-code").unwrap(),
26437 HistorySourceLayout::SingleNdjson,
26438 history_root.clone(),
26439 )
26440 .unwrap(),
26441 ])
26442 .unwrap();
26443 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
26444 let held = catalog.lock().unwrap();
26445 let result = timeout(
26446 Duration::from_secs(1),
26447 run_native_session_catalog_operation(
26448 Arc::clone(&catalog),
26449 "native session catalog",
26450 |_| Ok::<(), std::convert::Infallible>(()),
26451 ),
26452 )
26453 .await
26454 .expect("concurrent native catalog request must not wait for the operation deadline")
26455 .unwrap_err();
26456 assert_eq!(result.code, NodeFailureCode::BackendBusy);
26457 drop(held);
26458 std::fs::remove_dir_all(history_root).unwrap();
26459 }
26460
26461 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
26462 let unique = std::time::SystemTime::now()
26463 .duration_since(std::time::UNIX_EPOCH)
26464 .unwrap()
26465 .as_nanos();
26466 std::env::temp_dir().join(format!(
26467 "gate4agent-node-{label}-{}-{unique}",
26468 std::process::id(),
26469 ))
26470 }
26471
26472 fn git_snapshot_for_parser() -> GitSnapshot {
26473 GitSnapshot {
26474 is_repository: true,
26475 branch: Some("main".to_owned()),
26476 status: Vec::new(),
26477 recent_commits: Vec::new(),
26478 worktrees: Vec::new(),
26479 managed_worktree: None,
26480 truncated: false,
26481 diagnostic: None,
26482 }
26483 }
26484
26485 #[tokio::test]
26486 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
26487 let root = temporary_workspace_root("managed-git-scope");
26488 std::fs::create_dir_all(&root).unwrap();
26489 run_test_git(&root, &["init"]);
26490 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
26491 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
26492 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
26493 run_test_git(&root, &["add", "seed.txt"]);
26494 run_test_git(&root, &["commit", "-m", "seed"]);
26495 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
26496
26497 let workspace_id = WorkspaceId::new("managed-a").unwrap();
26498 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26499 let catalog = active_registry().unwrap();
26500 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26501 let shared = NodeShared::new(
26502 handle,
26503 "fixture-token".to_owned(),
26504 NodeId::new("node-1").unwrap(),
26505 vec![workspace],
26506 vec![agent("claude")],
26507 );
26508 let mut lease = ManagedWorktreeLeaseRecord {
26509 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
26510 source_workspace_id: WorkspaceId::new("primary").unwrap(),
26511 workspace_id: workspace_id.clone(),
26512 profile_id: WorktreeProfileId::new("default").unwrap(),
26513 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
26514 target_root: root.to_string_lossy().into_owned(),
26515 branch: "gate4agent/a".to_owned(),
26516 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
26517 expected_head: None,
26518 retention: ManagedWorktreeRetention::Retain,
26519 state: ManagedWorktreeLeaseState::InUse,
26520 session_holders: vec![ManagedWorktreeSessionHolder {
26521 incarnation_id: shared.incarnation_id,
26522 instance_id: AgentInstanceId(7),
26523 generation: SessionGeneration(1),
26524 }],
26525 record_holders: Vec::new(),
26526 cleanup_failure: None,
26527 created_at_unix_ms: 1,
26528 updated_at_unix_ms: 1,
26529 };
26530 *shared
26531 .managed_worktrees
26532 .lock()
26533 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
26534 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
26535
26536 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
26537 let scope = inspection.git.managed_worktree.unwrap();
26538 assert_eq!(scope.lease_id, lease.lease_id);
26539 assert_eq!(scope.branch, "gate4agent/a");
26540 assert_eq!(scope.active_session_count, 1);
26541
26542 lease.branch = "gate4agent/b".to_owned();
26543 *shared
26544 .managed_worktrees
26545 .lock()
26546 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
26547 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
26548 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
26549 assert!(mismatch.git.managed_worktree.is_none());
26550 std::fs::remove_dir_all(root).unwrap();
26551 }
26552
26553 #[tokio::test]
26554 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
26555 let root = temporary_workspace_root("workspace-file-write-controller");
26556 std::fs::create_dir_all(root.join("src")).unwrap();
26557 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
26558 let workspace_id = WorkspaceId::new("primary").unwrap();
26559 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26560 let catalog = active_registry().unwrap();
26561 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26562 let shared = NodeShared::new(
26563 handle,
26564 "fixture-token".to_owned(),
26565 NodeId::new("node-1").unwrap(),
26566 vec![workspace],
26567 vec![agent("claude")],
26568 );
26569 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
26570 let expected_revision = workspace_file_revision(b"old\n");
26571 let request = NodeRequest::WriteWorkspaceFile {
26572 workspace_id: workspace_id.clone(),
26573 path: path.clone(),
26574 expected_revision: expected_revision.clone(),
26575 text: "new\n".to_owned(),
26576 };
26577 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26578 .await
26579 .unwrap_err();
26580 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26581 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26582 .await
26583 .unwrap_err();
26584 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26585 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
26586 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
26587 let NodeResponse::WorkspaceFileWritten { file } = response else {
26588 panic!("workspace file write returned another response");
26589 };
26590 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
26591 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
26592 let stale = process_request_inner(
26593 &shared,
26594 77,
26595 ClientRole::Operator,
26596 NodeRequest::WriteWorkspaceFile {
26597 workspace_id,
26598 path,
26599 expected_revision,
26600 text: "lost update\n".to_owned(),
26601 },
26602 )
26603 .await
26604 .unwrap_err();
26605 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
26606 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
26607 std::fs::remove_dir_all(root).unwrap();
26608 }
26609
26610 #[tokio::test]
26611 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
26612 let root = temporary_workspace_root("workspace-entry-create-controller");
26613 std::fs::create_dir_all(root.join("src")).unwrap();
26614 let workspace_id = WorkspaceId::new("primary").unwrap();
26615 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26616 let catalog = active_registry().unwrap();
26617 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26618 let shared = NodeShared::new(
26619 handle,
26620 "fixture-token".to_owned(),
26621 NodeId::new("node-1").unwrap(),
26622 vec![workspace],
26623 vec![agent("claude")],
26624 );
26625 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
26626 let request = NodeRequest::CreateWorkspaceFile {
26627 workspace_id: workspace_id.clone(),
26628 path: path.clone(),
26629 };
26630 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26631 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
26632 }));
26633 assert!(!request_uses_unnegotiated_capability(
26634 &request,
26635 &baseline_capabilities().unwrap(),
26636 ));
26637 assert!(request_uses_unnegotiated_capability(&request, &[]));
26638 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26639 .await
26640 .unwrap_err();
26641 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26642 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26643 .await
26644 .unwrap_err();
26645 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26646 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
26647
26648 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26649 .await
26650 .unwrap();
26651 let NodeResponse::WorkspaceFileCreated { file } = response else {
26652 panic!("workspace file create returned another response");
26653 };
26654 assert_eq!(file.workspace_id, workspace_id);
26655 assert_eq!(file.path, path);
26656 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
26657 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
26658
26659 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
26660 .await
26661 .unwrap_err();
26662 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
26663
26664 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
26665 let response = process_request_inner(
26666 &shared,
26667 77,
26668 ClientRole::Operator,
26669 NodeRequest::CreateWorkspaceDirectory {
26670 workspace_id: workspace_id.clone(),
26671 path: directory_path.clone(),
26672 },
26673 )
26674 .await
26675 .unwrap();
26676 assert_eq!(
26677 response,
26678 NodeResponse::WorkspaceDirectoryCreated {
26679 workspace_id: workspace_id.clone(),
26680 entry: WorkspaceEntry {
26681 relative_path: directory_path,
26682 kind: WorkspaceEntryKind::Directory,
26683 },
26684 },
26685 );
26686 assert!(root.join("src/nested").is_dir());
26687
26688 let missing_parent = process_request_inner(
26689 &shared,
26690 77,
26691 ClientRole::Operator,
26692 NodeRequest::CreateWorkspaceFile {
26693 workspace_id: workspace_id.clone(),
26694 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
26695 },
26696 )
26697 .await
26698 .unwrap_err();
26699 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
26700 let non_directory_parent = process_request_inner(
26701 &shared,
26702 77,
26703 ClientRole::Operator,
26704 NodeRequest::CreateWorkspaceFile {
26705 workspace_id,
26706 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
26707 },
26708 )
26709 .await
26710 .unwrap_err();
26711 assert_eq!(
26712 non_directory_parent.code,
26713 NodeFailureCode::RepositoryParentNotDirectory,
26714 );
26715 std::fs::remove_dir_all(root).unwrap();
26716 }
26717
26718 #[tokio::test]
26719 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
26720 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
26721 std::fs::create_dir_all(root.join("src")).unwrap();
26722 #[cfg(windows)]
26723 let root = std::fs::canonicalize(root).unwrap();
26724 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
26725 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
26726 let barrier = Arc::new(std::sync::Barrier::new(2));
26727 let done = Arc::new(AtomicBool::new(false));
26728 let task_commit_state = Arc::clone(&commit_state);
26729 let task_barrier = Arc::clone(&barrier);
26730 let task_done = Arc::clone(&done);
26731 let task_root = root.clone();
26732 let task = tokio::task::spawn_blocking(move || {
26733 task_barrier.wait();
26734 let result = create_workspace_file_on_disk(
26735 &task_root,
26736 &path,
26737 &task_commit_state,
26738 );
26739 task_done.store(true, Ordering::Release);
26740 result
26741 });
26742
26743 let error = settle_workspace_entry_create(
26744 task,
26745 Arc::clone(&commit_state),
26746 Duration::from_millis(10),
26747 "fixture deadline",
26748 "fixture task failed",
26749 )
26750 .await
26751 .unwrap_err();
26752 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
26753 assert_eq!(
26754 commit_state.load(Ordering::Acquire),
26755 WORKSPACE_ENTRY_CREATE_CANCELED,
26756 );
26757
26758 barrier.wait();
26759 timeout(Duration::from_secs(1), async {
26760 while !done.load(Ordering::Acquire) {
26761 tokio::task::yield_now().await;
26762 }
26763 })
26764 .await
26765 .expect("canceled blocking create did not settle");
26766 assert!(!root.join("src/late.rs").exists());
26767 std::fs::remove_dir_all(root).unwrap();
26768 }
26769
26770}