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(
1315 mut self,
1316 helper_program: impl Into<PathBuf>,
1317 ) -> Result<Self, NodeServerError> {
1318 self.harness_mcp_helper = Some(
1319 ReviewedHarnessMcpProgram::review(helper_program.into())
1320 .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1321 );
1322 Ok(self)
1323 }
1324
1325 pub fn with_network_allowlist_catalog(
1330 mut self,
1331 catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1332 ) -> Result<Self, NodeServerError> {
1333 if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1334 return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1335 max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1336 });
1337 }
1338 self.network_allowlist_catalog = catalog;
1339 Ok(self)
1340 }
1341
1342 pub fn with_network_allowlist_catalog_file(
1346 self,
1347 path: impl AsRef<std::path::Path>,
1348 ) -> Result<Self, NodeServerError> {
1349 let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1350 .map_err(NodeServerError::from)?;
1351 self.with_network_allowlist_catalog(catalog)
1352 }
1353}
1354
1355pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1356 platform::default_state_path(node_id.as_str())
1357 .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1358}
1359
1360pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1361 platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1362}
1363
1364fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1365 let parent = state_path.parent()?;
1366 let mut name = state_path.file_name()?.to_os_string();
1367 name.push(".delivery-store");
1368 Some(parent.join(name))
1369}
1370
1371fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1372 let parent = state_path.parent()?;
1373 let mut name = state_path.file_name()?.to_os_string();
1374 name.push(".context-pack-store");
1375 Some(parent.join(name))
1376}
1377
1378pub struct NodeServer {
1379 config: NodeServerConfig,
1380 runtime: NativeRuntime,
1381 shared: Arc<NodeShared>,
1382 events: EventSubscription,
1383 state_path_lock: Option<session_registry::StatePathLock>,
1384}
1385
1386#[cfg(feature = "fixture")]
1387#[derive(Clone, Default)]
1388pub struct SpawnManagedWorktreeV2FailureProbe {
1389 latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1390}
1391
1392#[cfg(feature = "fixture")]
1393impl SpawnManagedWorktreeV2FailureProbe {
1394 pub fn latest_code(&self) -> Option<NodeFailureCode> {
1395 *self
1396 .latest_code
1397 .lock()
1398 .unwrap_or_else(|poisoned| poisoned.into_inner())
1399 }
1400
1401 fn record(&self, code: Option<NodeFailureCode>) {
1402 *self
1403 .latest_code
1404 .lock()
1405 .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1406 }
1407}
1408
1409#[cfg(all(feature = "fixture", windows))]
1410struct IsolatedCodexHomeFixtureEnvironment;
1411
1412#[cfg(all(feature = "fixture", windows))]
1413impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1414 for IsolatedCodexHomeFixtureEnvironment
1415{
1416 fn resolve_child_environment(
1417 &self,
1418 ) -> Result<
1419 Vec<gate4agent_catalog::EnvMutation>,
1420 gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1421 > {
1422 Ok(vec![gate4agent_catalog::EnvMutation {
1423 key: OsString::from("USERPROFILE"),
1424 value: None,
1425 }])
1426 }
1427}
1428
1429impl NodeServer {
1430 pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1431 let catalog = active_registry()?;
1432 Self::new_with_registry(config, catalog)
1433 }
1434
1435 #[cfg(feature = "fixture")]
1436 pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1437 let mut spec = gate4agent_testkit::interactive_agent_spec();
1438 spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1439 spec.display_name = "Controlled Claude PTY fixture".to_owned();
1440 Self::new_fixture_with_spec(config, spec)
1441 }
1442
1443 #[cfg(feature = "fixture")]
1444 pub fn new_clean_exit_fixture(
1445 config: NodeServerConfig,
1446 fixture_root: PathBuf,
1447 started_marker: PathBuf,
1448 release_signal: PathBuf,
1449 ) -> Result<Self, NodeServerError> {
1450 let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1451 &fixture_root,
1452 &started_marker,
1453 &release_signal,
1454 )
1455 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1456 spec.id = AgentId::new("claude")
1457 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1458 spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1459 Self::new_fixture_with_spec(config, spec)
1460 }
1461
1462 #[cfg(all(feature = "fixture", windows))]
1472 pub fn new_durable_context_pack_clean_exit_fixture(
1473 config: NodeServerConfig,
1474 fixture_root: PathBuf,
1475 started_marker: PathBuf,
1476 release_signal: PathBuf,
1477 session_id: &str,
1478 ) -> Result<Self, NodeServerError> {
1479 let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1480 &fixture_root,
1481 &started_marker,
1482 &release_signal,
1483 session_id,
1484 )
1485 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1486 let source_id = AgentId::new("claude")
1487 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1488 let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1489 NodeServerError::Registry(
1490 "durable context pack clean-exit fixture source adapter is unavailable"
1491 .to_owned(),
1492 )
1493 })?;
1494 source_spec.capabilities.adapters.history =
1495 source_provider.capabilities.adapters.history.clone();
1496 source_spec.id = source_id;
1497 source_spec.display_name =
1498 "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1499 source_spec.detection.command =
1505 "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1506 source_spec.detection.aliases.clear();
1507
1508 let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1509 target_spec.id = AgentId::new("codex")
1510 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1511 target_spec.display_name =
1512 "Controlled Codex durable context-pack target fixture".to_owned();
1513 target_spec.detection.command =
1514 "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1515 target_spec.detection.aliases.clear();
1516
1517 let catalog = AgentRegistry::new([source_spec, target_spec])
1518 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1519 let mut server = Self::new_with_registry(config, catalog)?;
1520 Arc::get_mut(&mut server.shared)
1521 .expect("fixture Node shared state must remain uniquely owned before run")
1522 .fixture_semantic_hook_policy = true;
1523 Ok(server)
1524 }
1525
1526 #[cfg(feature = "fixture")]
1527 pub fn new_monitoring_hook_fixture(
1528 config: NodeServerConfig,
1529 ) -> Result<Self, NodeServerError> {
1530 let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1531 spec.id = AgentId::new("claude")
1532 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1533 let mut server = Self::new_fixture_with_spec(config, spec)?;
1534 Arc::get_mut(&mut server.shared)
1535 .expect("fixture Node shared state must remain uniquely owned before run")
1536 .fixture_semantic_hook_policy = true;
1537 Ok(server)
1538 }
1539
1540 #[cfg(all(feature = "fixture", windows))]
1541 pub fn new_provider_bundle_argv_fixture(
1542 config: NodeServerConfig,
1543 agent_id: AgentId,
1544 proof_path: PathBuf,
1545 ) -> Result<Self, NodeServerError> {
1546 let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1547 Self::new_fixture_with_spec(config, spec)
1548 }
1549
1550 #[cfg(all(feature = "fixture", windows))]
1551 pub fn new_provider_bundle_argv_hold_fixture(
1552 config: NodeServerConfig,
1553 agent_id: AgentId,
1554 proof_path: PathBuf,
1555 release_signal: PathBuf,
1556 ) -> Result<Self, NodeServerError> {
1557 let spec = Self::provider_bundle_argv_fixture_spec(
1558 agent_id,
1559 proof_path,
1560 Some(release_signal),
1561 )?;
1562 Self::new_fixture_with_spec(config, spec)
1563 }
1564
1565 #[cfg(all(feature = "fixture", windows))]
1566 fn provider_bundle_argv_fixture_spec(
1567 agent_id: AgentId,
1568 proof_path: PathBuf,
1569 release_signal: Option<PathBuf>,
1570 ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1571 if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1572 return Err(NodeServerError::Registry(
1573 "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1574 ));
1575 }
1576 let mut spec = gate4agent_testkit::interactive_agent_spec();
1577 spec.id = agent_id.clone();
1578 spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1579 let provider = builtin_registry()
1580 .get(&agent_id)
1581 .ok_or_else(|| {
1582 NodeServerError::Registry(
1583 "provider bundle argv fixture adapter is unavailable".to_owned(),
1584 )
1585 })?;
1586 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1587 if !proof_path.is_absolute()
1588 || !proof_path
1589 .parent()
1590 .is_some_and(|parent| parent.is_dir())
1591 {
1592 return Err(NodeServerError::Registry(
1593 "provider bundle argv proof path requires an existing absolute parent"
1594 .to_owned(),
1595 ));
1596 }
1597 if let Some(release_signal) = release_signal.as_ref() {
1598 if !release_signal.is_absolute()
1599 || !release_signal
1600 .parent()
1601 .is_some_and(|parent| parent.is_dir())
1602 {
1603 return Err(NodeServerError::Registry(
1604 "provider bundle argv release path requires an existing absolute parent"
1605 .to_owned(),
1606 ));
1607 }
1608 if release_signal.exists() {
1609 return Err(NodeServerError::Registry(
1610 "provider bundle argv release path must not exist".to_owned(),
1611 ));
1612 }
1613 let proof_parent = std::fs::canonicalize(
1614 proof_path
1615 .parent()
1616 .expect("validated provider bundle proof parent"),
1617 )
1618 .map_err(|_| {
1619 NodeServerError::Registry(
1620 "provider bundle argv proof parent is unavailable".to_owned(),
1621 )
1622 })?;
1623 let release_parent = std::fs::canonicalize(
1624 release_signal
1625 .parent()
1626 .expect("validated provider bundle release parent"),
1627 )
1628 .map_err(|_| {
1629 NodeServerError::Registry(
1630 "provider bundle argv release parent is unavailable".to_owned(),
1631 )
1632 })?;
1633 if proof_parent != release_parent {
1634 return Err(NodeServerError::Registry(
1635 "provider bundle argv release path must share the proof parent".to_owned(),
1636 ));
1637 }
1638 }
1639 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1640 NodeServerError::Registry(
1641 "provider bundle argv proof path is not valid Unicode".to_owned(),
1642 )
1643 })?;
1644 let release_signal = release_signal.map(|release_signal| {
1645 release_signal.into_os_string().into_string().map_err(|_| {
1646 NodeServerError::Registry(
1647 "provider bundle argv release path is not valid Unicode".to_owned(),
1648 )
1649 })
1650 }).transpose()?;
1651 let script = spec
1652 .launch
1653 .fixed_args
1654 .pop()
1655 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1656 let bundle_validation = if agent_id.as_str() == "codex" {
1657 "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') }"
1658 } else if agent_id.as_str() == "claude" {
1659 "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 }"
1660 } else {
1661 "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--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 }"
1662 };
1663 if let Some(release_signal) = release_signal {
1664 spec.launch.fixed_args.push(format!(
1665 "& {{ 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} }}",
1666 ));
1667 spec.launch.fixed_args.push(proof_path);
1668 spec.launch.fixed_args.push(release_signal);
1669 } else {
1670 spec.launch.fixed_args.push(format!(
1671 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1672 ));
1673 spec.launch.fixed_args.push(proof_path);
1674 }
1675 Ok(spec)
1676 }
1677
1678 #[cfg(all(feature = "fixture", windows))]
1679 pub fn new_context_pack_fixture(
1680 config: NodeServerConfig,
1681 proof_path: PathBuf,
1682 ) -> Result<Self, NodeServerError> {
1683 let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1684 Self::new_with_registry(config, catalog)
1685 }
1686
1687 #[cfg(all(feature = "fixture", windows))]
1688 pub fn new_monitoring_context_pack_fixture(
1689 config: NodeServerConfig,
1690 proof_path: PathBuf,
1691 ) -> Result<Self, NodeServerError> {
1692 let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1693 let mut server = Self::new_with_registry(config, catalog)?;
1694 Arc::get_mut(&mut server.shared)
1695 .expect("fixture Node shared state must remain uniquely owned before run")
1696 .fixture_semantic_hook_policy = true;
1697 Ok(server)
1698 }
1699
1700 #[cfg(all(feature = "fixture", windows))]
1701 pub fn install_isolated_codex_home_environment_fixture(
1702 &mut self,
1703 profile_id: SpawnEnvironmentProfileId,
1704 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1705 ) -> Result<(), NodeServerError> {
1706 let provider = AgentId::new("codex")
1707 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1708 let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1709 gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1710 "{}-pty",
1711 profile_id.as_str(),
1712 ))
1713 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1714 provider.clone(),
1715 TransportKind::Pty,
1716 vec![OsString::from("USERPROFILE")],
1717 Arc::new(IsolatedCodexHomeFixtureEnvironment),
1718 )
1719 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1720 let materialization = NodeSessionMaterializationProfile::new(
1721 Vec::new(),
1722 vec![crate::session_environment::NodeSessionPathBinding::new(
1723 "CODEX_HOME",
1724 crate::session_environment::NodeSessionPathClass::ProviderHome,
1725 )
1726 .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1727 Vec::new(),
1728 )
1729 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1730 self.install_environment_profile(
1731 NodeEnvironmentProfile::new_with_materialization(
1732 profile_id,
1733 profile_revision,
1734 provider,
1735 [native_profile],
1736 Some(materialization),
1737 )
1738 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1739 )
1740 }
1741
1742 #[cfg(all(feature = "fixture", windows))]
1743 pub fn new_context_only_proof_fixture(
1744 config: NodeServerConfig,
1745 target_provider: AgentId,
1746 proof_path: PathBuf,
1747 ) -> Result<Self, NodeServerError> {
1748 if target_provider.as_str() != "kimi" {
1749 return Err(NodeServerError::Registry(
1750 "context-only proof fixture requires Kimi".to_owned(),
1751 ));
1752 }
1753 let catalog = Self::context_pack_fixture_catalog(
1754 proof_path,
1755 Some(&target_provider),
1756 false,
1757 )?;
1758 Self::new_with_registry(config, catalog)
1759 }
1760
1761 #[cfg(all(feature = "fixture", windows))]
1762 fn context_pack_fixture_catalog(
1763 proof_path: PathBuf,
1764 context_only_target: Option<&AgentId>,
1765 monitoring_claude_source: bool,
1766 ) -> Result<AgentRegistry, NodeServerError> {
1767 if !proof_path.is_absolute()
1768 || !proof_path
1769 .parent()
1770 .is_some_and(|parent| parent.is_dir())
1771 {
1772 return Err(NodeServerError::Registry(
1773 "context pack proof path requires an existing absolute parent".to_owned(),
1774 ));
1775 }
1776 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1777 NodeServerError::Registry(
1778 "context pack proof path is not valid Unicode".to_owned(),
1779 )
1780 })?;
1781 let providers = ["claude", "codex", "grok", "kimi"];
1782 let builtins = builtin_registry();
1783 let mut specs = Vec::with_capacity(providers.len());
1784 for provider_id in providers {
1785 let agent_id = AgentId::new(provider_id)
1786 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1787 let provider = builtins.get(&agent_id).ok_or_else(|| {
1788 NodeServerError::Registry(
1789 "context pack fixture provider adapter is unavailable".to_owned(),
1790 )
1791 })?;
1792 let monitored_provider =
1793 monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1794 let mut spec = if monitored_provider {
1795 gate4agent_testkit::monitoring_hook_agent_spec()
1796 } else {
1797 gate4agent_testkit::interactive_agent_spec()
1798 };
1799 spec.id = agent_id.clone();
1800 spec.display_name = format!("Controlled {agent_id} context pack fixture");
1801 spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1802 spec.detection.aliases.clear();
1803 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1804 if provider_id == "codex" {
1805 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1806 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1807 })?;
1808 let validation = r#"
1809$bundleArgs = @($bundleArgs)
1810if ($bundleArgs.Count -ne 0) { exit 91 }
1811$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1812$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1813$cwd = (Get-Location).ProviderPath
1814if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1815$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1816if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1817$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1818if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1819$sha = [Security.Cryptography.SHA256]::Create()
1820try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1821if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1822if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1823$contextPath = Join-Path $contextRoot 'context-pack.json'
1824if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1825$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1826if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1827$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1828$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1829$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1830$separator = [IO.Path]::DirectorySeparatorChar
1831if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1832if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1833$rawContext = [IO.File]::ReadAllText($contextPath)
1834if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1835try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1836$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1837$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1838foreach ($property in @($document.PSObject.Properties.Name)) {
1839 if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1840}
1841foreach ($property in $requiredTopLevel) {
1842 if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1843}
1844if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1845if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1846$messages = @($document.retained_messages)
1847if ($messages.Count -eq 0) { exit 107 }
1848foreach ($message in $messages) {
1849 $messageProperties = @($message.PSObject.Properties.Name)
1850 foreach ($property in $messageProperties) {
1851 if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1852 }
1853 if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1854}
1855$sha = [Security.Cryptography.SHA256]::Create()
1856try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1857[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))
1858$rawProof = [IO.File]::ReadAllText($proofPath)
1859if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1860[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1861"#;
1862 spec.launch.fixed_args.push(format!(
1863 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1864 ));
1865 spec.launch.fixed_args.push(proof_path.clone());
1866 } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1867 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1868 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1869 })?;
1870 let validation = r#"
1871$bundleArgs = @($bundleArgs)
1872if ($bundleArgs.Count -ne 0) { exit 91 }
1873$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1874$cwd = (Get-Location).ProviderPath
1875if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1876$contextPath = Join-Path $contextRoot 'context-pack.json'
1877if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1878$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1879if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1880$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1881$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1882$separator = [IO.Path]::DirectorySeparatorChar
1883if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1884try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1885if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1886if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1887$messages = @($document.retained_messages)
1888if ($messages.Count -eq 0) { exit 99 }
1889$hasUser = $false
1890$hasAssistant = $false
1891foreach ($message in $messages) {
1892 if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1893 if ([string]$message.role -ceq 'user') { $hasUser = $true }
1894 elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1895 else { exit 101 }
1896}
1897if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1898$sha = [Security.Cryptography.SHA256]::Create()
1899try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1900[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1901[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1902"#;
1903 spec.launch.fixed_args.push(format!(
1904 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1905 ));
1906 spec.launch.fixed_args.push(proof_path.clone());
1907 }
1908 specs.push(spec);
1909 }
1910 let catalog = AgentRegistry::new(specs)
1911 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1912 Ok(catalog)
1913 }
1914
1915 #[cfg(feature = "fixture")]
1916 pub fn new_exact_launcher_fixture(
1917 config: NodeServerConfig,
1918 agent_id: AgentId,
1919 launcher: String,
1920 ) -> Result<Self, NodeServerError> {
1921 if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1922 return Err(NodeServerError::Registry(
1923 "exact launcher fixture requires a supported provider".to_owned(),
1924 ));
1925 }
1926 if launcher.contains('\0') {
1927 return Err(NodeServerError::Registry(
1928 "exact launcher fixture path contains NUL".to_owned(),
1929 ));
1930 }
1931 let launcher_path = Path::new(&launcher);
1932 if !launcher_path.is_absolute() {
1933 return Err(NodeServerError::Registry(
1934 "exact launcher fixture path must be absolute".to_owned(),
1935 ));
1936 }
1937 let metadata = std::fs::metadata(launcher_path).map_err(|_| {
1938 NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
1939 })?;
1940 if !metadata.is_file() {
1941 return Err(NodeServerError::Registry(
1942 "exact launcher fixture path must be a regular file".to_owned(),
1943 ));
1944 }
1945 let mut spec = builtin_registry()
1946 .get(&agent_id)
1947 .ok_or_else(|| {
1948 NodeServerError::Registry(
1949 "exact launcher fixture provider is unavailable".to_owned(),
1950 )
1951 })?
1952 .clone();
1953 spec.launch.program = launcher;
1954 Self::new_fixture_with_spec(config, spec)
1955 }
1956
1957 #[cfg(feature = "fixture")]
1966 fn h3b_provider_spec_with_program_and_args(
1967 agent_id: AgentId,
1968 provider_program: PathBuf,
1969 fixed_args: Vec<OsString>,
1970 ) -> Result<AgentSpec, NodeServerError> {
1971 if !provider_program.is_absolute() || fixed_args.len() > 32 {
1972 return Err(NodeServerError::Registry(
1973 "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
1974 ));
1975 }
1976 let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
1977 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
1978 })?;
1979 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
1980 return Err(NodeServerError::Registry(
1981 "H3B fixture provider must be an exact regular file".to_owned(),
1982 ));
1983 }
1984 let program = provider_program.into_os_string().into_string().map_err(|_| {
1985 NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
1986 })?;
1987 let fixed_args = fixed_args.into_iter().map(|argument| {
1988 argument.into_string().map_err(|_| NodeServerError::Registry(
1989 "H3B fixture argument is not Unicode".to_owned(),
1990 ))
1991 }).collect::<Result<Vec<_>, _>>()?;
1992 let argument_bytes = fixed_args.iter()
1993 .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
1994 if program.contains('\0')
1995 || fixed_args.iter().any(|argument| argument.contains('\0'))
1996 || !matches!(argument_bytes, Some(total) if total <= 65_536)
1997 {
1998 return Err(NodeServerError::Registry(
1999 "H3B fixture provider or arguments are invalid".to_owned(),
2000 ));
2001 }
2002 let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2003 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2004 })?.clone();
2005 let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2006 .ok_or_else(|| NodeServerError::Registry(
2007 "H3B fixture provider basename is unavailable".to_owned(),
2008 ))?.to_owned();
2009 spec.detection.command = process_name.clone();
2010 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2011 name: process_name,
2012 }];
2013 spec.launch.program = program;
2014 spec.launch.fixed_args = fixed_args;
2015 Ok(spec)
2016 }
2017
2018 #[cfg(feature = "fixture")]
2019 pub fn new_harness_mcp_proxy_fixture(
2020 config: NodeServerConfig,
2021 provider_program: PathBuf,
2022 fixed_args: Vec<OsString>,
2023 harness_mcp_program: PathBuf,
2024 ) -> Result<Self, NodeServerError> {
2025 let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2026 let agent_id = AgentId::new("codex")
2027 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2028 let mut spec =
2029 Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2030 spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2031 config.fixture_raw_pty_runtime = true;
2032 Self::new_fixture_with_spec(config, spec)
2033 }
2034
2035 #[cfg(feature = "fixture")]
2059 pub fn new_harness_mcp_acp_launcher_fixture(
2060 config: NodeServerConfig,
2061 helper_program: PathBuf,
2062 provider: AgentId,
2063 program: PathBuf,
2064 args: Vec<OsString>,
2065 ) -> Result<Self, NodeServerError> {
2066 if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2067 return Err(NodeServerError::Registry(
2068 "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2069 ));
2070 }
2071 let config = config.with_harness_mcp_helper(helper_program)?;
2072 let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2073 let launch = spec.launch.clone();
2074 let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2075 NodeServerError::Registry(
2076 "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2077 .to_owned(),
2078 )
2079 })?;
2080 acp.launch_override = Some(launch);
2088 spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2089 Self::new_fixture_with_spec(config, spec)
2090 }
2091
2092 #[cfg(feature = "fixture")]
2093 pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2094 let mut spec = gate4agent_testkit::interactive_agent_spec();
2095 let claude_id = AgentId::new("claude")
2096 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2097 let claude = builtin_registry()
2098 .get(&claude_id)
2099 .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2100 spec.id = claude_id;
2101 spec.display_name = "Resumable Claude PTY fixture".to_owned();
2102 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2103 name: "claude".to_owned(),
2104 }];
2105 spec.capabilities.transports.pty_adapter = claude
2106 .capabilities
2107 .transports
2108 .pty_adapter
2109 .clone();
2110 spec.capabilities.adapters.resume = claude
2111 .capabilities
2112 .adapters
2113 .resume
2114 .clone();
2115 let script = spec
2116 .launch
2117 .fixed_args
2118 .pop()
2119 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2120 spec.launch.fixed_args.push(format!(
2121 "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2122 ));
2123 Self::new_fixture_with_spec(config, spec)
2124 }
2125
2126 #[cfg(feature = "fixture")]
2127 pub fn new_cmd_cwd_fixture(
2128 config: NodeServerConfig,
2129 command: String,
2130 ) -> Result<Self, NodeServerError> {
2131 let mut spec = gate4agent_testkit::interactive_agent_spec();
2132 spec.id = AgentId::new("claude")
2133 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2134 spec.display_name = "Controlled cmd cwd fixture".to_owned();
2135 spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2136 spec.launch.program = command;
2137 spec.launch.fixed_args.clear();
2138 Self::new_fixture_with_spec(config, spec)
2139 }
2140
2141 #[cfg(feature = "fixture")]
2142 fn new_fixture_with_spec(
2143 config: NodeServerConfig,
2144 spec: gate4agent_types::AgentSpec,
2145 ) -> Result<Self, NodeServerError> {
2146 let catalog = AgentRegistry::new([spec])
2147 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2148 Self::new_with_registry(config, catalog)
2149 }
2150
2151 fn new_with_registry(
2152 config: NodeServerConfig,
2153 catalog: AgentRegistry,
2154 ) -> Result<Self, NodeServerError> {
2155 for profile in config.spawn_profiles.iter() {
2156 if catalog.get(&profile.provider).is_none() {
2157 return Err(NodeServerError::Registry(
2158 "spawn profile references an unavailable provider".to_owned(),
2159 ));
2160 }
2161 }
2162 let input_settle_timeout_ms = catalog
2163 .iter()
2164 .map(|spec| spec.readiness.timeout_ms)
2165 .max()
2166 .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2167 .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2168 .max(MUTATION_SETTLE_TIMEOUT_MS);
2169 let (provider_contracts, provider_adapter_contracts) =
2170 provider_contract_manifest(&catalog)?;
2171 let compatibility_support = node_compatibility_support_for_manifest(
2172 &provider_contracts,
2173 &provider_adapter_contracts,
2174 )?;
2175 validate_node_negotiated_handshake_capacity(&compatibility_support)
2176 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2177 let enabled_providers = provider_contracts
2178 .iter()
2179 .map(|contract| contract.provider.clone())
2180 .collect();
2181 #[cfg(feature = "fixture")]
2182 let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2183 #[cfg(not(feature = "fixture"))]
2184 let fixture_raw_pty_runtime = false;
2185 let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2186 let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2187 crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2188 })).expect("fixture provider catalog remains bounded");
2189 (None, statuses)
2190 } else {
2191 let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2192 let statuses = monitor.collect();
2193 (Some(monitor), statuses)
2194 };
2195 let state_path_lock =
2196 session_registry::StatePathLock::acquire(config.state_path.as_deref())
2197 .map_err(|error| {
2198 durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2199 })?;
2200 let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2201 Some(state_path) => {
2202 let delivery_root = delivery_store_root_for_state_path(state_path)
2203 .ok_or_else(|| {
2204 NodeServerError::DeliveryStore(
2205 "delivery store root could not be derived from state path".to_owned(),
2206 )
2207 })?;
2208 let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2209 let detail = match &error {
2210 DeliveryStoreError::Storage(io_error) => {
2211 format!("{error}: {io_error}")
2212 }
2213 _ => error.to_string(),
2214 };
2215 NodeServerError::DeliveryStore(detail)
2216 })?;
2217 (Some(store), bundles)
2218 }
2219 None => (None, Vec::new()),
2220 };
2221 let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2222 NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2223 })?;
2224 let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2225 Some(state_path) => {
2226 let context_pack_root = context_pack_store_root_for_state_path(state_path)
2227 .ok_or(NodeServerError::ContextPackStore)?;
2228 let (store, packs) = ContextPackStore::open(context_pack_root)
2229 .map_err(|_| NodeServerError::ContextPackStore)?;
2230 (Some(store), packs)
2231 }
2232 None => (None, Vec::new()),
2233 };
2234 let mut durable_context_catalog = ContextPackCatalog::default();
2235 for pack in durable_context_packs {
2236 durable_context_catalog
2237 .insert(pack)
2238 .map_err(|_| NodeServerError::ContextPackStore)?;
2239 }
2240 let session_environment_materializer = config
2241 .session_environment
2242 .as_ref()
2243 .map(|environment| {
2244 SessionEnvironmentMaterializer::new(
2245 environment.root.clone(),
2246 Arc::clone(&environment.resolver),
2247 )
2248 .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2249 })
2250 .transpose()?;
2251 let native_session_catalog = config.history.clone().map(|history| {
2252 Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2253 });
2254 let (handle, runtime) = match config.history.clone() {
2255 Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2256 None => NativeRuntime::new(catalog, config.runtime),
2257 };
2258 let native_launch_profile_control = runtime.native_launch_profile_control();
2259 let harness_mcp_registry = config
2260 .harness_mcp_helper
2261 .clone()
2262 .map(HarnessMcpProxyRegistry::new);
2263 let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2264 let incarnation_id = random_incarnation_id()
2265 .map_err(NodeServerError::IncarnationIdentity)?;
2266 let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2267 .map_err(durable_state_load_error)?;
2268 let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2269 let managed_worktree_tombstones =
2270 std::mem::take(&mut loaded.managed_worktree_tombstones);
2271 let materializations = std::mem::take(&mut loaded.materializations);
2272 let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2273 let (workspaces, records, persistence_warning) =
2274 merge_durable_state(&config.workspaces, loaded)?;
2275 let mut shared = NodeShared::new_with_incarnation(
2276 handle,
2277 config.access_token.clone(),
2278 config.node_id.clone(),
2279 incarnation_id,
2280 workspaces,
2281 enabled_providers,
2282 provider_runtime_statuses,
2283 provider_runtime_monitor,
2284 provider_contracts,
2285 provider_adapter_contracts,
2286 config.spawn_profiles.clone(),
2287 Some(native_launch_profile_control),
2288 native_session_catalog,
2289 session_environment_materializer,
2290 config.state_path.clone(),
2291 records,
2292 managed_worktrees,
2293 managed_worktree_tombstones,
2294 materializations,
2295 persistence_warning,
2296 input_settle_timeout_ms,
2297 );
2298 shared.harness_mcp_registry = harness_mcp_registry.clone();
2299 shared.session_record_retention = config.session_record_retention;
2300 shared.network_allowlist_catalog =
2301 RwLock::new(config.network_allowlist_catalog.clone());
2302 shared.bundle_catalog = RwLock::new(delivered_catalog);
2303 shared.delivery_store = Mutex::new(delivery_store);
2304 shared.context_catalog = RwLock::new(durable_context_catalog);
2305 shared.context_pack_store = Mutex::new(context_pack_store);
2306 shared.managed_spawn_replays = Mutex::new(
2307 managed_spawn_replays
2308 .into_iter()
2309 .map(|record| (record.idempotency_key.clone(), record))
2310 .collect(),
2311 );
2312 let shared = Arc::new(shared);
2313 if let Some(registry) = harness_mcp_registry {
2314 let weak = Arc::downgrade(&shared);
2315 registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2316 Some(shared) => shared.publish_transient(event),
2317 None => false,
2318 }));
2319 }
2320 if shared.persistence_error().is_none() {
2321 shared.persist_state().map_err(|error| {
2322 durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2323 })?;
2324 }
2325 Ok(Self {
2326 config,
2327 runtime,
2328 shared,
2329 events,
2330 state_path_lock,
2331 })
2332 }
2333
2334 pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2335 NodeShutdownHandle {
2336 shared: Arc::clone(&self.shared),
2337 }
2338 }
2339
2340 #[cfg(feature = "fixture")]
2341 pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2342 &self,
2343 ) -> SpawnManagedWorktreeV2FailureProbe {
2344 self.shared
2345 .fixture_spawn_managed_worktree_v2_failure_probe
2346 .clone()
2347 }
2348
2349 pub fn install_environment_profile(
2354 &mut self,
2355 profile: NodeEnvironmentProfile,
2356 ) -> Result<(), NodeServerError> {
2357 if !self.shared.enabled_providers.contains(profile.provider()) {
2358 return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2359 profile.provider().clone(),
2360 ));
2361 }
2362 let (binding, native_profiles, materialization) = profile.into_parts();
2363 if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2364 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2365 }
2366 let mut profiles = self
2367 .shared
2368 .environment_profiles
2369 .write()
2370 .unwrap_or_else(|poisoned| poisoned.into_inner());
2371 if profiles.contains_key(&binding.id) {
2372 return Err(NodeServerError::DuplicateEnvironmentProfile(
2373 binding.id.clone(),
2374 ));
2375 }
2376 if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2377 return Err(NodeServerError::EnvironmentProfileCapacity {
2378 max: MAX_NODE_ENVIRONMENT_PROFILES,
2379 });
2380 }
2381 for native_id in binding.native_profile_ids() {
2382 if profiles.values().any(|current| {
2383 current.native_profile_ids().any(|current_id| current_id == native_id)
2384 }) {
2385 return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2386 native_id.to_string(),
2387 ));
2388 }
2389 }
2390
2391 let mut installed = Vec::with_capacity(native_profiles.len());
2392 for native_profile in native_profiles {
2393 let native_id = native_profile.id().clone();
2394 if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2395 for installed_id in installed.into_iter().rev() {
2396 let _ = self.runtime.remove_native_launch_profile(&installed_id);
2397 }
2398 return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2399 }
2400 installed.push(native_id);
2401 }
2402 if let Some(materialization) = materialization {
2403 self.shared
2404 .environment_materialization_profiles
2405 .write()
2406 .unwrap_or_else(|poisoned| poisoned.into_inner())
2407 .insert(binding.id.clone(), materialization);
2408 }
2409 profiles.insert(binding.id.clone(), binding);
2410 Ok(())
2411 }
2412
2413 pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2415 if self.shared.session_environment_materializer.is_none() {
2416 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2417 }
2418 let mut catalog = self
2419 .shared
2420 .bundle_catalog
2421 .write()
2422 .unwrap_or_else(|poisoned| poisoned.into_inner());
2423 catalog
2424 .insert_idempotent(bundle)
2425 .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2426 Ok(())
2427 }
2428
2429 pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2430 let shutdown = self.shutdown_handle();
2431 let run = self.run();
2432 tokio::pin!(run);
2433 tokio::select! {
2434 result = &mut run => result,
2435 signal = wait_for_ctrl_signal() => {
2436 signal?;
2437 let shutdown_result = shutdown.request_shutdown().await;
2438 let run_result = run.await;
2439 shutdown_result.and(run_result)
2440 }
2441 }
2442 }
2443
2444 pub async fn run(self) -> Result<(), NodeServerError> {
2445 let NodeServer {
2446 config,
2447 mut runtime,
2448 shared,
2449 events,
2450 state_path_lock: _state_path_lock,
2451 } = self;
2452 shared.reconcile_materializations();
2453 shared.reconcile_managed_worktrees().await;
2454 shared.retire_unavailable_session_records().await;
2455 shared.reconcile_context_pack_exports().await;
2456 let endpoint = config.endpoint.clone();
2460 let call_home = config.call_home;
2461 let api_listen = config.api_listen;
2462 let bridge_listen = config.bridge_listen;
2463 let bridge_underlay = config.bridge_underlay;
2464 let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2465 .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2466 let accept_shared = Arc::clone(&shared);
2467 let api_shared = Arc::clone(&shared);
2468 let bridge_shared = Arc::clone(&shared);
2469 let shutdown_shared = Arc::clone(&shared);
2470 let shutdown_timeout = Duration::from_millis(
2471 config.runtime.provider_shutdown_timeout_ms.max(1),
2472 );
2473 let result = {
2474 let runtime_loop = drive_runtime_until_shutdown(
2475 &mut runtime,
2476 events,
2477 Arc::clone(&shared),
2478 shutdown_timeout,
2479 );
2480 let accept_loop = accept_connections(&endpoint, call_home, accept_shared);
2481 let api_loop = http_api::run(api_listen, api_shared);
2482 let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2483 tokio::pin!(runtime_loop);
2484 tokio::pin!(accept_loop);
2485 tokio::pin!(api_loop);
2486 tokio::pin!(bridge_loop);
2487 tokio::select! {
2488 runtime_result = &mut runtime_loop => {
2489 let shutdown_result = shutdown_shared
2490 .begin_shutdown()
2491 .await
2492 .map_err(NodeServerError::ShutdownDispatch);
2493 let (accept_result, api_result, bridge_result) =
2494 tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2495 match runtime_result {
2496 Err(error) => Err(error),
2497 Ok(()) => shutdown_result
2498 .and(accept_result)
2499 .and(api_result.map_err(NodeServerError::HttpApi))
2500 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2501 }
2502 }
2503 accept_result = &mut accept_loop => {
2504 let shutdown_result = shutdown_shared
2505 .begin_shutdown()
2506 .await
2507 .map_err(NodeServerError::ShutdownDispatch);
2508 let (runtime_result, api_result, bridge_result) =
2509 tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2510 match accept_result {
2511 Err(error) => Err(error),
2512 Ok(()) => shutdown_result
2513 .and(runtime_result)
2514 .and(api_result.map_err(NodeServerError::HttpApi))
2515 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2516 }
2517 }
2518 api_result = &mut api_loop => {
2519 let shutdown_result = shutdown_shared
2520 .begin_shutdown()
2521 .await
2522 .map_err(NodeServerError::ShutdownDispatch);
2523 let (runtime_result, accept_result, bridge_result) =
2524 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2525 match api_result {
2526 Err(error) => Err(NodeServerError::HttpApi(error)),
2527 Ok(()) => shutdown_result
2528 .and(runtime_result)
2529 .and(accept_result)
2530 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2531 }
2532 }
2533 bridge_result = &mut bridge_loop => {
2534 let shutdown_result = shutdown_shared
2535 .begin_shutdown()
2536 .await
2537 .map_err(NodeServerError::ShutdownDispatch);
2538 let (runtime_result, accept_result, api_result) =
2539 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2540 match bridge_result {
2541 Err(error) => Err(NodeServerError::BridgeApi(error)),
2542 Ok(()) => shutdown_result
2543 .and(runtime_result)
2544 .and(accept_result)
2545 .and(api_result.map_err(NodeServerError::HttpApi)),
2546 }
2547 }
2548 }
2549 };
2550 if let Ok(mut guard) = shared.delivery_store.lock() {
2557 *guard = None;
2558 }
2559 if let Ok(mut guard) = shared.context_pack_store.lock() {
2560 *guard = None;
2561 }
2562 result
2563 }
2564}
2565
2566async fn drive_runtime_until_shutdown(
2567 runtime: &mut NativeRuntime,
2568 events: EventSubscription,
2569 shared: Arc<NodeShared>,
2570 shutdown_timeout: Duration,
2571) -> Result<(), NodeServerError> {
2572 let _ = shared
2576 .shell_efficiency_profile
2577 .set(runtime.shell_efficiency_profile());
2578 let mut shutdown_started = None;
2579 let mut last_retention_sweep = Instant::now();
2580 loop {
2581 let runtime_tick_start = Instant::now();
2588 let tick_result = runtime.tick().await;
2589 let runtime_tick_elapsed = runtime_tick_start.elapsed();
2590
2591 let event_drain_start = Instant::now();
2592 for outcome in &tick_result.command_outcomes {
2600 if let Err(error) = &outcome.result {
2601 shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2602 tracing::warn!(
2603 command_id = outcome.command_id.0,
2604 cause = %error,
2605 "control command rejected"
2606 );
2607 shared.record_rejected_command(
2608 outcome.command_id,
2609 error.to_string(),
2610 error.is_unsupported_transport(),
2611 );
2612 }
2613 }
2614 let mut events_drained: u64 = 0;
2615 while let Ok(event) = events.try_recv() {
2616 events_drained += 1;
2617 let clean_exit = matches!(
2618 event.event,
2619 ControlEventKind::Exited { exit_code: Some(0), forced: false },
2620 );
2621 let instance_id = event.instance_id;
2622 let generation = event.generation;
2623 if clean_exit {
2635 if let Some((record_id, session)) =
2636 shared.managed_record_export_target_for_instance(instance_id, generation)
2637 {
2638 let export_shared = Arc::clone(&shared);
2639 tokio::spawn(async move {
2640 export_shared
2641 .reconcile_context_pack_export_for_record(&record_id, session)
2642 .await;
2643 });
2644 }
2645 }
2646 shared.publish_control(event);
2647 }
2648 let event_drain_elapsed = event_drain_start.elapsed();
2649
2650 let publish_terminal_frames_start = Instant::now();
2651 shared.publish_terminal_frames();
2652 let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2653
2654 shared
2660 .native_session_gauge
2661 .store(runtime.active_native_sessions(), Ordering::Relaxed);
2662 *shared
2663 .runtime_tick_profile
2664 .lock()
2665 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2666
2667 let remainder_start = Instant::now();
2668 if shared.shutdown.load(Ordering::Acquire) {
2669 let started = *shutdown_started.get_or_insert_with(Instant::now);
2670 let snapshot = shared.handle.snapshot();
2671 let sessions_settled = snapshot.sessions.iter().all(|session| {
2672 !matches!(
2673 session.status,
2674 gate4agent_types::SessionStatus::Starting
2675 | gate4agent_types::SessionStatus::Running
2676 | gate4agent_types::SessionStatus::Stopping
2677 )
2678 });
2679 if runtime.active_native_sessions() == 0 && sessions_settled {
2680 return Ok(());
2681 }
2682 if started.elapsed() >= shutdown_timeout {
2683 return Err(NodeServerError::ShutdownTimedOut {
2684 active_native_sessions: runtime.active_native_sessions(),
2685 });
2686 }
2687 }
2688 let remainder_elapsed = remainder_start.elapsed();
2689
2690 {
2691 let mut profile = shared
2692 .drive_loop_profile
2693 .lock()
2694 .unwrap_or_else(|poisoned| poisoned.into_inner());
2695 profile.record_runtime_tick(runtime_tick_elapsed);
2696 profile.record_event_drain(event_drain_elapsed);
2697 profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2698 profile.record_remainder(remainder_elapsed);
2699 profile.note_iteration(Instant::now());
2700 }
2701
2702 shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2714 if tick_result.observations_applied == 0 && events_drained == 0 {
2715 shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2716 }
2717
2718 if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2723 shared.retire_unavailable_session_records().await;
2724 last_retention_sweep = Instant::now();
2725 }
2726
2727 sleep(Duration::from_millis(10)).await;
2728 }
2729}
2730
2731#[derive(Clone)]
2732pub struct NodeShutdownHandle {
2733 shared: Arc<NodeShared>,
2734}
2735
2736impl NodeShutdownHandle {
2737 pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2738 self.shared
2739 .begin_shutdown()
2740 .await
2741 .map_err(NodeServerError::ShutdownDispatch)
2742 }
2743}
2744
2745async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2746 #[cfg(windows)]
2747 {
2748 let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2749 let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2750 return tokio::select! {
2751 signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2752 signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2753 };
2754 }
2755 #[cfg(unix)]
2756 {
2757 let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2758 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2759 tokio::select! {
2760 signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2761 signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2762 }
2763 }
2764}
2765
2766fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2779 let specs = builtin_registry()
2780 .iter()
2781 .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2782 .cloned()
2783 .collect::<Vec<_>>();
2784 AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2785}
2786
2787fn provider_contract_manifest(
2788 catalog: &AgentRegistry,
2789) -> Result<
2790 (
2791 Vec<ProviderContractSupport>,
2792 Vec<ProviderAdapterContractSupport>,
2793 ),
2794 NodeServerError,
2795> {
2796 let mut provider_contracts = Vec::new();
2797 let mut provider_adapter_contracts = Vec::new();
2798 for spec in catalog.iter() {
2799 let provider = spec.id.clone();
2800 let revision = ProviderContractRevision::new(spec.revision.clone())
2801 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2802 provider_contracts.push(ProviderContractSupport {
2803 provider: provider.clone(),
2804 revision,
2805 });
2806 for (family, binding) in declared_adapter_bindings(spec)? {
2807 let revision = AdapterContractRevision::new(binding.revision.clone())
2808 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2809 provider_adapter_contracts.push(ProviderAdapterContractSupport {
2810 provider: provider.clone(),
2811 family,
2812 adapter_id: binding.id.clone(),
2813 revision,
2814 });
2815 }
2816 }
2817 validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2818 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2819 Ok((provider_contracts, provider_adapter_contracts))
2820}
2821
2822fn declared_adapter_bindings(
2823 spec: &AgentSpec,
2824) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2825 let mut bindings = Vec::new();
2826 push_declared_binding(
2827 &mut bindings,
2828 AdapterFamily::PtySemantic,
2829 spec.capabilities.transports.pty_adapter.as_ref(),
2830 )?;
2831 push_declared_binding(
2832 &mut bindings,
2833 AdapterFamily::Pipe,
2834 spec.capabilities.adapters.pty_sidecar.as_ref(),
2835 )?;
2836 let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2837 let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2838 gate4agent_types::PipeProtocol::SemanticNdjson
2839 | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2840 gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2841 });
2842 push_declared_binding(
2843 &mut bindings,
2844 AdapterFamily::Pipe,
2845 pipe_transport
2846 .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2847 .map(|transport| &transport.adapter),
2848 )?;
2849 push_declared_binding(
2850 &mut bindings,
2851 AdapterFamily::Acp,
2852 spec.capabilities
2853 .transports
2854 .acp
2855 .as_ref()
2856 .map(|transport| &transport.adapter),
2857 )?;
2858 push_declared_binding(
2859 &mut bindings,
2860 AdapterFamily::Hook,
2861 spec.capabilities.adapters.hook.as_ref(),
2862 )?;
2863 push_declared_binding(
2864 &mut bindings,
2865 AdapterFamily::ManagedHook,
2866 spec.capabilities.adapters.managed_hook.as_ref(),
2867 )?;
2868 push_declared_binding(
2869 &mut bindings,
2870 AdapterFamily::OneShot,
2871 pipe_transport
2872 .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
2873 .map(|transport| &transport.adapter),
2874 )?;
2875 push_declared_binding(
2876 &mut bindings,
2877 AdapterFamily::OneShot,
2878 spec.capabilities.adapters.one_shot.as_ref(),
2879 )?;
2880 push_declared_binding(
2881 &mut bindings,
2882 AdapterFamily::History,
2883 spec.capabilities.adapters.history.as_ref(),
2884 )?;
2885 push_declared_binding(
2886 &mut bindings,
2887 AdapterFamily::Resume,
2888 spec.capabilities.adapters.resume.as_ref(),
2889 )?;
2890 push_declared_binding(
2891 &mut bindings,
2892 AdapterFamily::SessionOptions,
2893 spec.capabilities.adapters.session_options.as_ref(),
2894 )?;
2895 push_declared_binding(
2896 &mut bindings,
2897 AdapterFamily::CapabilityProbe,
2898 spec.capabilities.adapters.capability_probe.as_ref(),
2899 )?;
2900 Ok(bindings)
2901}
2902
2903fn push_declared_binding<'a>(
2904 bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
2905 family: AdapterFamily,
2906 binding: Option<&'a AdapterBinding>,
2907) -> Result<(), NodeServerError> {
2908 let Some(binding) = binding else {
2909 return Ok(());
2910 };
2911 if let Some((_, existing)) = bindings
2912 .iter()
2913 .find(|(existing_family, _)| *existing_family == family)
2914 {
2915 if *existing == binding {
2916 return Ok(());
2917 }
2918 return Err(NodeServerError::ProviderContractManifest(format!(
2919 "provider declares conflicting {family:?} adapter bindings"
2920 )));
2921 }
2922 bindings.push((family, binding));
2923 Ok(())
2924}
2925
2926fn merge_durable_state(
2927 configured: &[WorkspaceConfig],
2928 mut loaded: LoadedNodeState,
2929) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
2930 let mut workspaces = configured
2931 .iter()
2932 .cloned()
2933 .map(|workspace| (workspace.workspace_id.clone(), workspace))
2934 .collect::<BTreeMap<_, _>>();
2935 let mut roots = workspaces
2936 .values()
2937 .map(|workspace| {
2938 (
2939 platform::root_identity(&workspace.canonical_root),
2940 workspace.workspace_id.clone(),
2941 )
2942 })
2943 .collect::<BTreeMap<_, _>>();
2944 let mut warning = loaded
2945 .warning
2946 .take()
2947 .map(|message| session_registry::sanitized_persistence_summary(&message));
2948
2949 for (workspace_id, canonical_root) in loaded.workspaces {
2950 if let Some(existing) = workspaces.get(&workspace_id) {
2951 if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
2952 return Err(NodeServerError::DurableState(io::Error::new(
2953 io::ErrorKind::InvalidData,
2954 DURABLE_STATE_CONFLICT_ERROR,
2955 )));
2956 }
2957 continue;
2958 }
2959 if roots.get(&platform::root_identity(&canonical_root)).is_some() {
2960 return Err(NodeServerError::DurableState(io::Error::new(
2961 io::ErrorKind::InvalidData,
2962 DURABLE_STATE_CONFLICT_ERROR,
2963 )));
2964 }
2965 if !Path::new(&canonical_root).is_dir() {
2966 warning.get_or_insert_with(|| {
2967 session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
2968 });
2969 continue;
2970 }
2971 let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
2972 NodeServerError::DurableState(io::Error::new(
2973 io::ErrorKind::InvalidData,
2974 DURABLE_STATE_CONFLICT_ERROR,
2975 ))
2976 })?;
2977 roots.insert(
2978 platform::root_identity(&workspace.canonical_root),
2979 workspace.workspace_id.clone(),
2980 );
2981 workspaces.insert(workspace_id, workspace);
2982 }
2983
2984 for record in &mut loaded.records {
2985 match workspaces.get(&record.workspace_id) {
2986 Some(workspace)
2987 if platform::roots_equal(
2988 &workspace.canonical_root,
2989 windows_path_text(&record.canonical_root),
2990 ) => {}
2991 Some(_) => {
2992 return Err(NodeServerError::DurableState(io::Error::new(
2993 io::ErrorKind::InvalidData,
2994 DURABLE_STATE_CONFLICT_ERROR,
2995 )));
2996 }
2997 None => {
2998 record.state = ManagedSessionState::Unavailable;
2999 record.active_session = None;
3000 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3001 }
3002 }
3003 }
3004 Ok((workspaces.into_values().collect(), loaded.records, warning))
3005}
3006
3007struct RejectedCommandReason {
3010 message: String,
3011 unsupported_transport: bool,
3018 expires_at: Instant,
3019}
3020
3021struct NodeShared {
3022 handle: Gate4AgentHandle,
3023 access_token: String,
3024 node_id: NodeId,
3025 incarnation_id: NodeIncarnationId,
3026 started_at_unix_ms: u64,
3027 workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3028 worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3029 managed_worktree_profiles:
3030 BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3031 managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3032 enabled_providers: Vec<AgentId>,
3033 provider_runtime_statuses: ProviderRuntimeStatuses,
3034 provider_runtime_status_updates:
3035 RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3036 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3037 provider_contracts: Vec<ProviderContractSupport>,
3038 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3039 spawn_profiles: SpawnProfileRegistry,
3040 environment_profiles:
3041 RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3042 environment_materialization_profiles:
3043 RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3044 network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3052 #[cfg(feature = "dig2-station-probe")]
3056 browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3057 bundle_catalog: RwLock<BundleCatalog>,
3058 delivery_store: Mutex<Option<DeliveryStore>>,
3059 context_pack_store: Mutex<Option<ContextPackStore>>,
3060 context_catalog: RwLock<ContextPackCatalog>,
3061 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3062 harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3063 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3064 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3065 materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3066 spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3067 managed_spawn_replays:
3068 Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3069 controller: Mutex<Option<ControllerLease>>,
3070 history: Mutex<NodeEventHistory>,
3071 event_tx: broadcast::Sender<NodeEventEnvelope>,
3072 terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3073 agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3081 harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3090 terminal_frame_watermarks:
3091 Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3092 next_connection_id: AtomicU64,
3093 next_instance_id: AtomicU64,
3094 next_command_id: AtomicU64,
3095 shutdown: AtomicBool,
3096 shutdown_notify: Notify,
3097 preauth_slots: Arc<Semaphore>,
3098 authenticated_slots: Arc<Semaphore>,
3099 inspection_slots: Arc<Semaphore>,
3100 mutation_gate: AsyncMutex<()>,
3101 session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3102 session_records: Mutex<SessionRecords>,
3103 state_path: Option<PathBuf>,
3104 persistence_error: RwLock<Option<String>>,
3105 state_transaction: Mutex<()>,
3106 input_settle_timeout_ms: u64,
3107 drive_loop_profile: Mutex<DriveLoopProfiler>,
3111 runtime_tick_profile: Mutex<TickProfileSnapshot>,
3117 shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3129 native_session_gauge: AtomicUsize,
3136 connection_loop_iterations: AtomicU64,
3147 connection_events_sent: AtomicU64,
3148 rejected_commands_total: AtomicU64,
3157 rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3177 drive_loop_iterations_total: AtomicU64,
3178 drive_loop_iterations_idle: AtomicU64,
3179 session_record_retention: SessionRecordRetentionConfig,
3183 retired_records_total: AtomicU64,
3189 #[cfg(feature = "fixture")]
3190 fixture_semantic_hook_policy: bool,
3191 #[cfg(feature = "fixture")]
3192 fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3193}
3194
3195impl Drop for NodeShared {
3196 fn drop(&mut self) {
3197 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3198 let instances = registry.shutdown();
3199 if let Some(control) = self.native_launch_profile_control.as_ref() {
3200 for instance_id in instances {
3201 control.clear_native_mcp_server_launch_overlay(instance_id);
3202 }
3203 }
3204 }
3205 }
3206}
3207
3208#[derive(Clone, Debug, Eq, PartialEq)]
3209struct SessionBinding {
3210 workspace_id: WorkspaceId,
3211 generation: SessionGeneration,
3212 runtime_policy: ProviderRuntimePolicy,
3213 pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3214 record_id: Option<SessionRecordId>,
3215 managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3216 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3217 bundle: Option<ResolvedBundleReceipt>,
3218 context: Option<ResolvedContextPackReceipt>,
3219 materialization_id: Option<MaterializationId>,
3220}
3221
3222#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3223enum SpawnRecordPolicy {
3224 ProviderIdentityOnly,
3225 Always,
3226}
3227
3228struct NativeEnvironmentSelectionGuard {
3229 control: Option<NativeLaunchProfileControl>,
3230 instance_id: AgentInstanceId,
3231}
3232
3233struct NativeInstanceOverlayGuard {
3234 control: Option<NativeLaunchProfileControl>,
3235 instance_id: AgentInstanceId,
3236}
3237
3238struct NativeHarnessMcpOverlayGuard {
3239 control: NativeLaunchProfileControl,
3240 instance_id: AgentInstanceId,
3241}
3242
3243impl NativeHarnessMcpOverlayGuard {
3244 fn retain(self) { std::mem::forget(self); }
3245}
3246
3247impl Drop for NativeHarnessMcpOverlayGuard {
3248 fn drop(&mut self) {
3249 self.control
3250 .clear_native_mcp_server_launch_overlay(self.instance_id);
3251 }
3252}
3253
3254impl NativeInstanceOverlayGuard {
3255 fn retain(mut self) {
3256 self.control = None;
3257 }
3258}
3259
3260impl Drop for NativeInstanceOverlayGuard {
3261 fn drop(&mut self) {
3262 if let Some(control) = self.control.as_ref() {
3263 control.clear_native_instance_launch_overlay(self.instance_id);
3264 }
3265 }
3266}
3267
3268enum PreparedNativeLaunchOverlay {
3269 Environment(NativeLaunchEnvironmentOverlay),
3270 Instance(NativeInstanceLaunchOverlay),
3271}
3272
3273impl NativeEnvironmentSelectionGuard {
3274 fn retain(mut self) {
3275 self.control = None;
3276 }
3277}
3278
3279impl Drop for NativeEnvironmentSelectionGuard {
3280 fn drop(&mut self) {
3281 if let Some(control) = self.control.as_ref() {
3282 control.clear_native_launch_profile_selection(self.instance_id);
3283 }
3284 }
3285}
3286
3287struct SessionMaterializationGuard<'a> {
3288 shared: &'a NodeShared,
3289 id: Option<MaterializationId>,
3290 provider_home: Option<PathBuf>,
3293}
3294
3295impl SessionMaterializationGuard<'_> {
3296 fn id(&self) -> Option<&MaterializationId> {
3297 self.id.as_ref()
3298 }
3299
3300 fn provider_home(&self) -> Option<&Path> {
3301 self.provider_home.as_deref()
3302 }
3303
3304 fn retain(mut self) {
3305 self.id = None;
3306 }
3307}
3308
3309impl Drop for SessionMaterializationGuard<'_> {
3310 fn drop(&mut self) {
3311 if let Some(id) = self.id.take() {
3312 let _ = self.shared.cleanup_materialization(&id);
3313 }
3314 }
3315}
3316
3317struct SessionRecords {
3318 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3319}
3320
3321struct SpawnIdempotencyCache {
3322 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3323}
3324
3325struct SpawnIdempotencyEntry {
3326 value: SpawnIdempotencyValue,
3327 expires_at: Instant,
3328}
3329
3330enum SpawnIdempotencyValue {
3331 Standard {
3332 spec: SpawnSpec,
3333 result: Result<ResolvedSpawnReceipt, NodeFailure>,
3334 },
3335 Managed {
3336 request: ManagedWorktreeSpawnRequest,
3337 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3338 },
3339 HarnessMcp,
3340}
3341
3342enum DurableManagedSpawnReplayDecision {
3343 Reserved,
3344 PendingLinked,
3345 Committed(ManagedWorktreeSpawnReceipt),
3346}
3347
3348impl SpawnIdempotencyValue {
3349 fn references_context(&self, context_id: &SpawnContextId) -> bool {
3350 match self {
3351 Self::Standard { result, .. } => result
3352 .as_ref()
3353 .ok()
3354 .and_then(|receipt| receipt.context.as_ref()),
3355 Self::Managed { result, .. } => result
3356 .as_ref()
3357 .ok()
3358 .and_then(|receipt| receipt.spawn.context.as_ref()),
3359 Self::HarnessMcp => None,
3360 }
3361 .is_some_and(|receipt| &receipt.id == context_id)
3362 }
3363}
3364
3365#[derive(Clone, Copy)]
3366struct ControllerLease {
3367 connection_id: u64,
3368 expires_at: Instant,
3369}
3370
3371struct NodeEventHistory {
3372 last_sequence: u64,
3373 replay_floor_sequence: u64,
3374 events: VecDeque<NodeEventEnvelope>,
3375 record_providers: BTreeMap<SessionRecordId, AgentId>,
3376 removed_record_providers: BTreeMap<u64, AgentId>,
3377}
3378
3379impl NodeEventHistory {
3380 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
3381 Self {
3382 last_sequence: 0,
3383 replay_floor_sequence: 1,
3384 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
3385 record_providers,
3386 removed_record_providers: BTreeMap::new(),
3387 }
3388 }
3389}
3390
3391impl NodeShared {
3392 fn new_with_incarnation(
3393 handle: Gate4AgentHandle,
3394 access_token: String,
3395 node_id: NodeId,
3396 incarnation_id: NodeIncarnationId,
3397 workspaces: Vec<WorkspaceConfig>,
3398 enabled_providers: Vec<AgentId>,
3399 provider_runtime_statuses: ProviderRuntimeStatuses,
3400 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3401 provider_contracts: Vec<ProviderContractSupport>,
3402 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3403 spawn_profiles: SpawnProfileRegistry,
3404 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3405 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3406 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3407 state_path: Option<PathBuf>,
3408 records: Vec<ManagedSessionRecord>,
3409 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
3410 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
3411 materializations: Vec<MaterializationOwnershipRecord>,
3412 persistence_warning: Option<String>,
3413 input_settle_timeout_ms: u64,
3414 ) -> Self {
3415 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
3416 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
3417 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
3418 let (harness_mcp_proxy_event_tx, _) =
3419 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
3420 let record_providers = records
3421 .iter()
3422 .map(|record| (record.record_id.clone(), record.provider.clone()))
3423 .collect();
3424 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
3425 .iter()
3426 .map(|workspace| {
3427 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
3428 })
3429 .collect();
3430 for lease in &managed_worktrees {
3431 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
3432 }
3433 let managed_worktree_profiles = workspaces.iter()
3434 .map(|workspace| (
3435 workspace.workspace_id.clone(),
3436 workspace.managed_worktree_profiles.clone(),
3437 ))
3438 .collect();
3439 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
3440 managed_worktrees,
3441 managed_worktree_tombstones,
3442 ).expect("durable managed worktree registry was validated while loading");
3443 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
3444 managed_worktrees.reattach_record_holders(
3445 &managed_worktree_record_holders(&records),
3446 unix_time_ms(),
3447 );
3448 Self {
3449 handle,
3450 access_token,
3451 node_id,
3452 incarnation_id,
3453 started_at_unix_ms: std::time::SystemTime::now()
3454 .duration_since(std::time::UNIX_EPOCH)
3455 .unwrap_or_default()
3456 .as_millis()
3457 .min(u64::MAX as u128) as u64,
3458 workspaces: RwLock::new(
3459 workspaces
3460 .into_iter()
3461 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
3462 .collect(),
3463 ),
3464 worktree_service_modes,
3465 managed_worktree_profiles,
3466 managed_worktrees: Mutex::new(managed_worktrees),
3467 enabled_providers,
3468 provider_runtime_statuses,
3469 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
3470 provider_runtime_monitor,
3471 provider_contracts,
3472 provider_adapter_contracts,
3473 spawn_profiles,
3474 environment_profiles: RwLock::new(BTreeMap::new()),
3475 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
3476 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
3477 #[cfg(feature = "dig2-station-probe")]
3478 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
3479 bundle_catalog: RwLock::new(BundleCatalog::default()),
3480 delivery_store: Mutex::new(None),
3481 context_pack_store: Mutex::new(None),
3482 context_catalog: RwLock::new(ContextPackCatalog::default()),
3483 native_launch_profile_control,
3484 harness_mcp_registry: None,
3485 native_session_catalog,
3486 session_environment_materializer,
3487 materializations: Mutex::new(
3488 materializations
3489 .into_iter()
3490 .map(|record| (record.id().clone(), record))
3491 .collect(),
3492 ),
3493 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
3494 entries: BTreeMap::new(),
3495 }),
3496 managed_spawn_replays: Mutex::new(BTreeMap::new()),
3497 controller: Mutex::new(None),
3498 history: Mutex::new(NodeEventHistory::new(record_providers)),
3499 event_tx,
3500 terminal_event_tx,
3501 agent_stream_event_tx,
3502 harness_mcp_proxy_event_tx,
3503 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
3504 next_connection_id: AtomicU64::new(1),
3505 next_instance_id: AtomicU64::new(1),
3506 next_command_id: AtomicU64::new(1),
3507 shutdown: AtomicBool::new(false),
3508 shutdown_notify: Notify::new(),
3509 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
3510 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
3511 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
3512 mutation_gate: AsyncMutex::new(()),
3513 session_bindings: Mutex::new(BTreeMap::new()),
3514 session_records: Mutex::new(SessionRecords {
3515 records: records
3516 .into_iter()
3517 .map(|record| (record.record_id.clone(), record))
3518 .collect(),
3519 }),
3520 state_path,
3521 persistence_error: RwLock::new(
3522 persistence_warning
3523 .map(|message| session_registry::sanitized_persistence_summary(&message)),
3524 ),
3525 state_transaction: Mutex::new(()),
3526 input_settle_timeout_ms,
3527 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
3528 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
3529 shell_efficiency_profile: OnceLock::new(),
3530 native_session_gauge: AtomicUsize::new(0),
3531 connection_loop_iterations: AtomicU64::new(0),
3532 connection_events_sent: AtomicU64::new(0),
3533 rejected_commands_total: AtomicU64::new(0),
3534 rejected_command_reasons: Mutex::new(BTreeMap::new()),
3535 drive_loop_iterations_total: AtomicU64::new(0),
3536 drive_loop_iterations_idle: AtomicU64::new(0),
3537 session_record_retention: SessionRecordRetentionConfig::default(),
3538 retired_records_total: AtomicU64::new(0),
3539 #[cfg(feature = "fixture")]
3540 fixture_semantic_hook_policy: false,
3541 #[cfg(feature = "fixture")]
3542 fixture_spawn_managed_worktree_v2_failure_probe:
3543 SpawnManagedWorktreeV2FailureProbe::default(),
3544 }
3545 }
3546
3547 #[cfg(test)]
3548 fn new(
3549 handle: Gate4AgentHandle,
3550 access_token: String,
3551 node_id: NodeId,
3552 workspaces: Vec<WorkspaceConfig>,
3553 enabled_providers: Vec<AgentId>,
3554 ) -> Self {
3555 Self::new_with_incarnation(
3556 handle,
3557 access_token,
3558 node_id,
3559 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
3560 workspaces,
3561 enabled_providers,
3562 ProviderRuntimeStatuses::default(),
3563 None,
3564 Vec::new(),
3565 Vec::new(),
3566 SpawnProfileRegistry::default(),
3567 None,
3568 None,
3569 None,
3570 None,
3571 Vec::new(),
3572 Vec::new(),
3573 Vec::new(),
3574 Vec::new(),
3575 None,
3576 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
3577 )
3578 }
3579
3580 fn persistence_error(&self) -> Option<String> {
3581 self.persistence_error
3582 .read()
3583 .unwrap_or_else(|poisoned| poisoned.into_inner())
3584 .clone()
3585 }
3586
3587 async fn admit_provider_runtime(
3588 &self,
3589 provider: &AgentId,
3590 requirement: ProviderRuntimeRequirement,
3591 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
3592 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
3593 let pipe_transport = monitor.supports_pipe_transport(provider);
3594 let acp_transport = monitor.supports_acp_transport(provider);
3595 let provider = provider.clone();
3596 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
3597 let (status, admission) = timeout(
3598 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
3599 refresh,
3600 )
3601 .await
3602 .map_err(|_| failure(
3603 NodeFailureCode::BackendBusy,
3604 "provider runtime probe exceeded its bounded deadline",
3605 ))?
3606 .map_err(|_| failure(
3607 NodeFailureCode::BackendOperationFailed,
3608 "provider runtime probe task failed",
3609 ))?;
3610 if let Some(status) = status {
3611 self
3612 .provider_runtime_status_updates
3613 .write()
3614 .unwrap_or_else(|poisoned| poisoned.into_inner())
3615 .insert(status.provider().clone(), status);
3616 }
3617 admission.and_then(|policy| {
3618 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
3619 })
3620 } else {
3621 crate::provider_runtime::admit_status(
3627 &self.provider_runtime_statuses,
3628 provider,
3629 false,
3630 false,
3631 requirement,
3632 )
3633 };
3634 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
3635 admission.map_err(|error| match error {
3636 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
3637 NodeFailureCode::BackendOperationFailed,
3638 "provider launcher is unavailable",
3639 ),
3640 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
3641 NodeFailureCode::UnsupportedCapability,
3642 "provider semantic capability is not verified",
3643 ),
3644 ProviderRuntimeAdmissionError::ProbeBusy => failure(
3645 NodeFailureCode::BackendBusy,
3646 "provider runtime probe is already in progress",
3647 ),
3648 })
3649 }
3650
3651 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
3657 match self.provider_runtime_monitor.as_deref() {
3658 Some(monitor) => monitor.supports_pipe_transport(provider),
3659 None => false,
3660 }
3661 }
3662
3663 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
3669 match self.provider_runtime_monitor.as_deref() {
3670 Some(monitor) => monitor.supports_acp_transport(provider),
3671 None => false,
3672 }
3673 }
3674
3675 fn admit_declared_hook_semantics(
3691 &self,
3692 provider: &AgentId,
3693 policy: ProviderRuntimePolicy,
3694 ) -> ProviderRuntimePolicy {
3695 if policy.hook_semantics || !policy.raw_pty_lifecycle {
3696 return policy;
3697 }
3698 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
3699 contract.provider == *provider && contract.family == AdapterFamily::Hook
3700 });
3701 if !declares_hook {
3702 return policy;
3703 }
3704 ProviderRuntimePolicy::new(
3705 policy.raw_pty_lifecycle,
3706 policy.semantic_readiness,
3707 policy.structured_prompt,
3708 policy.provider_session_identity,
3709 policy.semantic_resume,
3710 true,
3711 )
3712 .expect("granting hook semantics over a raw-PTY policy is internally valid")
3713 }
3714
3715 fn resolve_network_allowlist_overlay_args(
3730 &self,
3731 provider: &AgentId,
3732 mode: SessionMode,
3733 approval_level: ApprovalLevel,
3734 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
3735 provider_home: Option<&Path>,
3736 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
3737 let Some(allowlist_id) = network_allowlist else {
3738 return Ok(Vec::new());
3739 };
3740 let catalog = self
3741 .network_allowlist_catalog
3742 .read()
3743 .unwrap_or_else(|poisoned| poisoned.into_inner());
3744 let Some(entry) = catalog.get(allowlist_id) else {
3745 return Err(failure(
3746 NodeFailureCode::UnknownNetworkAllowlist,
3747 "spawn network allowlist is unavailable on this node",
3748 ));
3749 };
3750
3751 if provider.as_str() == "claude" && !entry.permits.is_empty() {
3753 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
3754 |message| {
3755 failure(
3756 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3757 &message,
3758 )
3759 },
3760 )?;
3761 if !matches!(mode, SessionMode::Pty) {
3762 return Err(failure(
3763 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3764 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
3765 ));
3766 }
3767 let Some(home) = provider_home else {
3768 return Err(failure(
3769 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3770 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
3771 ));
3772 };
3773 let domains =
3774 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
3775 if domains.is_empty() {
3776 return Err(failure(
3777 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3778 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
3779 ));
3780 }
3781 let settings_path = home.join(
3782 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
3783 );
3784 let json =
3785 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
3786 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
3787 |_| {
3788 failure(
3789 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3790 "Claude station network settings sidecar could not be written under ProviderHome",
3791 )
3792 },
3793 )?;
3794 let overlay =
3795 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
3796 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
3797 }
3798
3799 let Some(native) = entry.provider_native.as_ref() else {
3800 return Ok(Vec::new());
3801 };
3802 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
3803 provider.as_str(),
3804 approval_level,
3805 native,
3806 )
3807 .map_err(|message| {
3808 failure(
3809 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3810 &message,
3811 )
3812 })?;
3813 if overlay.is_empty() {
3814 return Ok(Vec::new());
3815 }
3816 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
3817 return Err(failure(
3818 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3819 "Codex network_access -c overlay requires PTY or ACP transport",
3820 ));
3821 }
3822 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
3823 }
3824
3825 fn resolve_codex_network_access_overlay_args(
3827 &self,
3828 provider: &AgentId,
3829 mode: SessionMode,
3830 approval_level: ApprovalLevel,
3831 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
3832 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
3833 self.resolve_network_allowlist_overlay_args(
3834 provider,
3835 mode,
3836 approval_level,
3837 network_allowlist,
3838 None,
3839 )
3840 }
3841
3842 fn resolve_environment_profile(
3843 &self,
3844 resolved: &ResolvedSpawnSpec,
3845 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
3846 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
3852 let catalog = self
3853 .network_allowlist_catalog
3854 .read()
3855 .unwrap_or_else(|poisoned| poisoned.into_inner());
3856 let Some(entry) = catalog.get(allowlist_id) else {
3857 return Err(failure(
3858 NodeFailureCode::UnknownNetworkAllowlist,
3859 "spawn network allowlist is unavailable on this node",
3860 ));
3861 };
3862 if let Some(native) = entry.provider_native.as_ref() {
3865 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
3866 resolved.provider.as_str(),
3867 resolved.approval_level,
3868 native,
3869 ) {
3870 return Err(failure(
3871 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3872 &message,
3873 ));
3874 }
3875 }
3876 }
3877 #[cfg(feature = "dig2-station-probe")]
3886 if resolved.browser_profile_id.is_some() {
3887 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
3888 match probe_station_reachable() {
3889 Ok(()) => {}
3890 Err(StationProbeError::UnsupportedPlatform)
3891 | Err(StationProbeError::InvalidPipeSuffix) => {
3892 return Err(failure(
3893 NodeFailureCode::BrowserStationProbeUnavailable,
3894 "dig2browser station probe unavailable on this platform",
3895 ));
3896 }
3897 Err(StationProbeError::Unreachable) => {
3898 return Err(failure(
3899 NodeFailureCode::BrowserStationUnreachable,
3900 "dig2browser station named-pipe is unreachable",
3901 ));
3902 }
3903 }
3904 }
3905 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
3906 return Ok(None);
3907 };
3908 let profiles = self
3909 .environment_profiles
3910 .read()
3911 .unwrap_or_else(|poisoned| poisoned.into_inner());
3912 let binding = profiles.get(profile_id).ok_or_else(|| {
3913 failure(
3914 NodeFailureCode::UnknownEnvironmentProfile,
3915 "spawn environment profile is unavailable on this node",
3916 )
3917 })?;
3918 if binding.provider != resolved.provider
3919 || binding.native_profile_id(resolved.mode).is_none()
3920 {
3921 return Err(failure(
3922 NodeFailureCode::EnvironmentProfileBindingMismatch,
3923 "spawn environment profile does not match the provider and mode",
3924 ));
3925 }
3926 Ok(Some(ResolvedEnvironmentProfileReceipt {
3930 profile_id: binding.id.clone(),
3931 profile_revision: binding.revision.clone(),
3932 network_allowlist: resolved.network_allowlist.clone(),
3933 browser_profile_id: resolved.browser_profile_id.clone(),
3934 }))
3935 }
3936
3937 fn resolve_bundle(
3938 &self,
3939 resolved: &ResolvedSpawnSpec,
3940 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3941 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
3942 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
3943 return Ok(None);
3944 };
3945 let catalog = self.bundle_catalog
3946 .read()
3947 .unwrap_or_else(|poisoned| poisoned.into_inner());
3948 let bundle = catalog.get(bundle_id).ok_or_else(|| {
3949 failure(
3950 NodeFailureCode::UnknownBundle,
3951 "spawn bundle is unavailable on this node",
3952 )
3953 })?;
3954 self.bundle_layout(
3955 &resolved.provider,
3956 resolved.mode,
3957 environment_profile,
3958 bundle,
3959 )?;
3960 Ok(Some(bundle.receipt()))
3961 }
3962
3963 fn begin_delivery_stage(
3964 &self,
3965 manifest: DeliveryBundleManifestV2,
3966 ) -> Result<NodeResponse, NodeFailure> {
3967 let mut store = self
3968 .delivery_store
3969 .lock()
3970 .unwrap_or_else(|poisoned| poisoned.into_inner());
3971 let store = store.as_mut().ok_or_else(|| {
3972 failure(
3973 NodeFailureCode::DeliveryStageStorageFailed,
3974 "node delivery store is unavailable",
3975 )
3976 })?;
3977 let begun = store.begin(manifest).map_err(delivery_failure)?;
3978 Ok(NodeResponse::DeliveryStageBegun {
3979 stage_id: begun.stage_id,
3980 manifest_digest: begun.manifest_digest,
3981 missing_blobs: begun.missing_blobs,
3982 })
3983 }
3984
3985 fn put_delivery_blob_chunk(
3986 &self,
3987 stage_id: DeliveryStageId,
3988 blob_digest: DeliveryBlobDigestV1,
3989 offset: u64,
3990 chunk_hex: DeliveryBlobChunkHexV1,
3991 ) -> Result<NodeResponse, NodeFailure> {
3992 let mut store = self
3993 .delivery_store
3994 .lock()
3995 .unwrap_or_else(|poisoned| poisoned.into_inner());
3996 let store = store.as_mut().ok_or_else(|| {
3997 failure(
3998 NodeFailureCode::DeliveryStageStorageFailed,
3999 "node delivery store is unavailable",
4000 )
4001 })?;
4002 let accepted = store
4003 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4004 .map_err(delivery_failure)?;
4005 Ok(NodeResponse::DeliveryBlobChunkAccepted {
4006 stage_id: accepted.stage_id,
4007 blob_digest: accepted.blob_digest,
4008 next_offset: accepted.next_offset,
4009 })
4010 }
4011
4012 fn commit_delivery_stage(
4013 &self,
4014 stage_id: DeliveryStageId,
4015 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4016 let mut store = self
4017 .delivery_store
4018 .lock()
4019 .unwrap_or_else(|poisoned| poisoned.into_inner());
4020 let store = store.as_mut().ok_or_else(|| {
4021 failure(
4022 NodeFailureCode::DeliveryStageStorageFailed,
4023 "node delivery store is unavailable",
4024 )
4025 })?;
4026 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4027 let mut catalog = self
4028 .bundle_catalog
4029 .write()
4030 .unwrap_or_else(|poisoned| poisoned.into_inner());
4031 let mut next_catalog = catalog.clone();
4032 next_catalog
4033 .insert_idempotent(prepared.bundle().clone())
4034 .map_err(|_| {
4035 failure(
4036 NodeFailureCode::DeliveryStageConflict,
4037 "delivery bundle identity conflicts with installed content",
4038 )
4039 })?;
4040 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4041 *catalog = next_catalog;
4042 Ok(receipt)
4043 }
4044
4045 fn abort_delivery_stage(
4046 &self,
4047 stage_id: &DeliveryStageId,
4048 ) -> Result<(), NodeFailure> {
4049 let mut store = self
4050 .delivery_store
4051 .lock()
4052 .unwrap_or_else(|poisoned| poisoned.into_inner());
4053 let store = store.as_mut().ok_or_else(|| {
4054 failure(
4055 NodeFailureCode::DeliveryStageStorageFailed,
4056 "node delivery store is unavailable",
4057 )
4058 })?;
4059 store.abort(stage_id).map_err(delivery_failure)
4060 }
4061
4062 fn resolve_context(
4063 &self,
4064 resolved: &ResolvedSpawnSpec,
4065 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4066 let Some(context_id) = resolved.context_id.as_ref() else {
4067 return Ok(None);
4068 };
4069 self.context_catalog
4070 .read()
4071 .unwrap_or_else(|poisoned| poisoned.into_inner())
4072 .get(context_id)
4073 .map(|context| context.receipt().clone())
4074 .ok_or_else(|| {
4075 failure(
4076 NodeFailureCode::UnknownContextPack,
4077 "spawn context pack is unavailable on this node",
4078 )
4079 })
4080 .map(Some)
4081 }
4082
4083 fn bundle_layout(
4084 &self,
4085 provider: &AgentId,
4086 mode: SessionMode,
4087 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4088 bundle: &NodeBundle,
4089 ) -> Result<BundleProviderLayout, NodeFailure> {
4090 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4091 failure(
4092 NodeFailureCode::BundleBindingMismatch,
4093 "spawn bundle does not match the provider and mode",
4094 )
4095 })?;
4096 if layout != BundleProviderLayout::Codex {
4097 return Ok(layout);
4098 }
4099 let profile = environment_profile.and_then(|receipt| {
4100 let profiles = self
4101 .environment_profiles
4102 .read()
4103 .unwrap_or_else(|poisoned| poisoned.into_inner());
4104 let binding = profiles.get(&receipt.profile_id)?;
4105 if binding.revision != receipt.profile_revision
4106 || &binding.provider != provider
4107 || binding.native_profile_id(mode).is_none()
4108 {
4109 return None;
4110 }
4111 self.environment_materialization_profiles
4112 .read()
4113 .unwrap_or_else(|poisoned| poisoned.into_inner())
4114 .get(&receipt.profile_id)
4115 .cloned()
4116 });
4117 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4118 return Err(failure(
4119 NodeFailureCode::BundleBindingMismatch,
4120 "Codex bundle requires an exact isolated CODEX_HOME profile",
4121 ));
4122 }
4123 Ok(layout)
4124 }
4125
4126 fn select_environment_profile(
4127 &self,
4128 instance_id: AgentInstanceId,
4129 provider: &AgentId,
4130 mode: SessionMode,
4131 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4132 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4133 let Some(environment_profile) = environment_profile else {
4134 return Ok(None);
4135 };
4136 let native_profile_id = {
4137 let profiles = self
4138 .environment_profiles
4139 .read()
4140 .unwrap_or_else(|poisoned| poisoned.into_inner());
4141 let binding = profiles
4142 .get(&environment_profile.profile_id)
4143 .ok_or_else(|| {
4144 failure(
4145 NodeFailureCode::UnknownEnvironmentProfile,
4146 "session environment profile is unavailable on this node",
4147 )
4148 })?;
4149 if &binding.revision != &environment_profile.profile_revision
4150 || &binding.provider != provider
4151 {
4152 return Err(failure(
4153 NodeFailureCode::EnvironmentProfileBindingMismatch,
4154 "session environment profile revision or provider changed",
4155 ));
4156 }
4157 binding.native_profile_id(mode).cloned().ok_or_else(|| {
4158 failure(
4159 NodeFailureCode::EnvironmentProfileBindingMismatch,
4160 "session environment profile does not support this mode",
4161 )
4162 })?
4163 };
4164 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4165 failure(
4166 NodeFailureCode::BackendOperationFailed,
4167 "native environment profile controller is unavailable",
4168 )
4169 })?;
4170 control
4171 .select_native_launch_profile(instance_id, native_profile_id)
4172 .map_err(|_| {
4173 failure(
4174 NodeFailureCode::BackendOperationFailed,
4175 "native environment profile selection failed",
4176 )
4177 })?;
4178 Ok(Some(NativeEnvironmentSelectionGuard {
4179 control: Some(control),
4180 instance_id,
4181 }))
4182 }
4183
4184 fn install_prepared_launch_overlay(
4185 &self,
4186 instance_id: AgentInstanceId,
4187 overlay: PreparedNativeLaunchOverlay,
4188 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4189 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4190 failure(
4191 NodeFailureCode::BackendOperationFailed,
4192 "native launch profile controller is unavailable",
4193 )
4194 })?;
4195 match overlay {
4196 PreparedNativeLaunchOverlay::Environment(overlay) => {
4197 control
4198 .install_native_launch_environment_overlay(instance_id, overlay)
4199 .map_err(|_| failure(
4200 NodeFailureCode::BackendOperationFailed,
4201 "session environment overlay installation failed",
4202 ))?;
4203 Ok(None)
4204 }
4205 PreparedNativeLaunchOverlay::Instance(overlay) => {
4206 control
4207 .install_native_instance_launch_overlay(instance_id, overlay)
4208 .map_err(|_| failure(
4209 NodeFailureCode::BundleBindingMismatch,
4210 "session bundle launch overlay installation failed",
4211 ))?;
4212 Ok(Some(NativeInstanceOverlayGuard {
4213 control: Some(control),
4214 instance_id,
4215 }))
4216 }
4217 }
4218 }
4219
4220 fn environment_materialization_profile(
4221 &self,
4222 environment_profile: &ResolvedEnvironmentProfileReceipt,
4223 ) -> Option<NodeSessionMaterializationProfile> {
4224 self.environment_materialization_profiles
4225 .read()
4226 .unwrap_or_else(|poisoned| poisoned.into_inner())
4227 .get(&environment_profile.profile_id)
4228 .cloned()
4229 }
4230
4231 fn materialization_profile(
4232 &self,
4233 provider: &AgentId,
4234 mode: SessionMode,
4235 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4236 bundle: Option<&ResolvedBundleReceipt>,
4237 context: Option<&ResolvedContextPackReceipt>,
4238 ) -> Result<Option<(
4239 NodeSessionMaterializationProfile,
4240 bool,
4241 Option<BundleProviderLayout>,
4242 )>, NodeFailure> {
4243 let environment = environment_profile
4244 .and_then(|receipt| self.environment_materialization_profile(receipt));
4245 let (bundle, bundle_layout) = match bundle {
4246 Some(receipt) => {
4247 let catalog = self.bundle_catalog.read()
4248 .unwrap_or_else(|poisoned| poisoned.into_inner());
4249 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4250 NodeFailureCode::UnknownBundle,
4251 "session bundle is unavailable on this node",
4252 ))?;
4253 if installed.receipt() != *receipt {
4254 return Err(failure(
4255 NodeFailureCode::BundleBindingMismatch,
4256 "session bundle revision or digest changed",
4257 ));
4258 }
4259 let layout = self.bundle_layout(
4260 provider,
4261 mode,
4262 environment_profile,
4263 installed,
4264 )?;
4265 (Some(installed.clone()), Some(layout))
4266 }
4267 None => (None, None),
4268 };
4269 let has_environment_overlay = environment.is_some() || context.is_some();
4270 let profile = match (environment, bundle.as_ref(), bundle_layout) {
4271 (None, None, None) => None,
4272 (Some(profile), None, None) => Some(Ok(profile)),
4273 (None, Some(bundle), Some(layout)) => {
4274 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4275 }
4276 (Some(profile), Some(bundle), Some(layout)) => {
4277 Some(profile.with_bundle(bundle, layout))
4278 }
4279 _ => {
4280 return Err(failure(
4281 NodeFailureCode::BundleBindingMismatch,
4282 "session bundle provider layout is unavailable",
4283 ));
4284 }
4285 };
4286 let profile = profile
4287 .transpose()
4288 .map_err(|_| failure(
4289 NodeFailureCode::BundleMaterializationFailed,
4290 "session bundle materialization profile is invalid",
4291 ))?;
4292 let profile = match (profile, context) {
4293 (Some(profile), Some(receipt)) => {
4294 let catalog = self.context_catalog
4295 .read()
4296 .unwrap_or_else(|poisoned| poisoned.into_inner());
4297 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4298 NodeFailureCode::UnknownContextPack,
4299 "session context pack is unavailable on this node",
4300 ))?;
4301 if pack.receipt() != receipt {
4302 return Err(failure(
4303 NodeFailureCode::ContextPackMaterializationFailed,
4304 "session context pack identity changed",
4305 ));
4306 }
4307 profile.with_context(pack)
4308 }
4309 (None, Some(receipt)) => {
4310 let catalog = self.context_catalog
4311 .read()
4312 .unwrap_or_else(|poisoned| poisoned.into_inner());
4313 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4314 NodeFailureCode::UnknownContextPack,
4315 "session context pack is unavailable on this node",
4316 ))?;
4317 if pack.receipt() != receipt {
4318 return Err(failure(
4319 NodeFailureCode::ContextPackMaterializationFailed,
4320 "session context pack identity changed",
4321 ));
4322 }
4323 NodeSessionMaterializationProfile::from_context(pack)
4324 }
4325 (Some(profile), None) => Ok(profile),
4326 (None, None) => return Ok(None),
4327 }
4328 .map_err(|_| failure(
4329 NodeFailureCode::ContextPackMaterializationFailed,
4330 "session context pack materialization profile is invalid",
4331 ))?;
4332 Ok(Some((profile, has_environment_overlay, bundle_layout)))
4333 }
4334
4335 fn prepare_session_materialization<'a>(
4336 &'a self,
4337 address: &SessionAddress,
4338 provider: &AgentId,
4339 mode: SessionMode,
4340 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4341 bundle: Option<&ResolvedBundleReceipt>,
4342 context: Option<&ResolvedContextPackReceipt>,
4343 managed_lease_id: Option<ManagedWorktreeLeaseId>,
4344 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
4345 let Some((profile, has_environment_overlay, bundle_layout)) =
4346 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
4347 else {
4348 return Ok(None);
4349 };
4350 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4351 failure(
4352 NodeFailureCode::BackendOperationFailed,
4353 "session environment materializer is unavailable",
4354 )
4355 })?;
4356 let materialization_failure_code = if context.is_some() {
4357 NodeFailureCode::ContextPackMaterializationFailed
4358 } else if bundle.is_some() {
4359 NodeFailureCode::BundleMaterializationFailed
4360 } else {
4361 NodeFailureCode::BackendOperationFailed
4362 };
4363 let id = MaterializationId::new(format!(
4364 "mat-{}-{}-{}",
4365 self.incarnation_id,
4366 address.session.instance_id.0,
4367 address.session.generation.0,
4368 ))
4369 .map_err(|_| {
4370 failure(
4371 NodeFailureCode::BackendOperationFailed,
4372 "session environment materialization identity failed",
4373 )
4374 })?;
4375 let owner = MaterializationOwner::Session {
4376 incarnation_id: self.incarnation_id,
4377 instance_id: address.session.instance_id,
4378 generation: address.session.generation,
4379 };
4380 let mut ownership = materializer
4381 .begin(
4382 id.clone(),
4383 environment_profile.cloned(),
4384 bundle.cloned(),
4385 context.cloned(),
4386 owner,
4387 managed_lease_id,
4388 &profile,
4389 unix_time_ms(),
4390 )
4391 .map_err(|_| {
4392 failure(
4393 materialization_failure_code,
4394 "session materialization preparation failed",
4395 )
4396 })?;
4397 {
4398 let _transaction = self
4399 .state_transaction
4400 .lock()
4401 .unwrap_or_else(|poisoned| poisoned.into_inner());
4402 let duplicate = self
4403 .materializations
4404 .lock()
4405 .unwrap_or_else(|poisoned| poisoned.into_inner())
4406 .insert(id.clone(), ownership.clone());
4407 if duplicate.is_some() {
4408 return Err(failure(
4409 NodeFailureCode::BackendOperationFailed,
4410 "session environment materialization identity conflict",
4411 ));
4412 }
4413 if let Err(error) = self.persist_state_locked() {
4414 self.materializations
4415 .lock()
4416 .unwrap_or_else(|poisoned| poisoned.into_inner())
4417 .remove(&id);
4418 return Err(persistence_failure(error));
4419 }
4420 }
4421 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
4422 Ok(environment) => environment,
4423 Err(_) => {
4424 let transaction = self
4425 .state_transaction
4426 .lock()
4427 .unwrap_or_else(|poisoned| poisoned.into_inner());
4428 self.materializations
4429 .lock()
4430 .unwrap_or_else(|poisoned| poisoned.into_inner())
4431 .insert(id.clone(), ownership.clone());
4432 if self.persist_state_locked().is_err() {
4433 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4434 }
4435 drop(transaction);
4436 if ownership.state() == MaterializationState::CleanupRequired {
4437 let _ = self.cleanup_materialization(&id);
4438 }
4439 return Err(failure(
4440 materialization_failure_code,
4441 "session materialization failed",
4442 ));
4443 }
4444 };
4445 {
4446 let _transaction = self
4447 .state_transaction
4448 .lock()
4449 .unwrap_or_else(|poisoned| poisoned.into_inner());
4450 self.materializations
4451 .lock()
4452 .unwrap_or_else(|poisoned| poisoned.into_inner())
4453 .insert(id.clone(), ownership.clone());
4454 if let Err(error) = self.persist_state_locked() {
4455 drop(_transaction);
4456 let _ = self.cleanup_materialization(&id);
4457 return Err(persistence_failure(error));
4458 }
4459 }
4460 let guard = SessionMaterializationGuard {
4461 shared: self,
4462 id: Some(id),
4463 provider_home: Some(ownership.provider_home().to_path_buf()),
4464 };
4465 let bundle_arguments = match bundle {
4466 Some(receipt) => {
4467 let catalog = self.bundle_catalog.read()
4468 .unwrap_or_else(|poisoned| poisoned.into_inner());
4469 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4470 NodeFailureCode::UnknownBundle,
4471 "session bundle is unavailable on this node",
4472 ))?;
4473 if installed.receipt() != *receipt {
4474 return Err(failure(
4475 NodeFailureCode::BundleBindingMismatch,
4476 "session bundle revision or digest changed",
4477 ));
4478 }
4479 let layout = bundle_layout.ok_or_else(|| failure(
4480 NodeFailureCode::BundleBindingMismatch,
4481 "materialized bundle provider layout is unavailable",
4482 ))?;
4483 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
4484 failure(
4485 NodeFailureCode::BundleMaterializationFailed,
4486 "session bundle byte revalidation failed",
4487 )
4488 })?;
4489 Some(bundle_launch_arguments(
4490 layout,
4491 ownership.bundle_root(),
4492 ownership.provider_home(),
4493 ).map_err(|_| failure(
4494 NodeFailureCode::BundleBindingMismatch,
4495 "session bundle launch binding is invalid",
4496 ))?)
4497 }
4498 None => None,
4499 };
4500 if let Some(receipt) = context {
4501 materializer
4502 .revalidate_context(&ownership, receipt)
4503 .map_err(|_| failure(
4504 NodeFailureCode::ContextPackMaterializationFailed,
4505 "session context pack byte revalidation failed",
4506 ))?;
4507 }
4508 let transport = match mode {
4509 SessionMode::Pty => TransportKind::Pty,
4510 SessionMode::Inline => TransportKind::Pipe,
4511 SessionMode::Acp => TransportKind::Acp,
4512 };
4513 let overlay = if let Some(extra_args) = bundle_arguments {
4514 Some(PreparedNativeLaunchOverlay::Instance(
4515 NativeInstanceLaunchOverlay::new(
4516 provider.clone(),
4517 transport,
4518 if has_environment_overlay {
4519 environment
4520 } else {
4521 Vec::new()
4522 },
4523 extra_args,
4524 )
4525 .map_err(|_| {
4526 failure(
4527 NodeFailureCode::BundleBindingMismatch,
4528 "session bundle launch overlay is invalid",
4529 )
4530 })?,
4531 ))
4532 } else if has_environment_overlay {
4533 let context_only = environment_profile.is_none()
4534 && bundle.is_none()
4535 && context.is_some();
4536 let overlay = if context_only {
4537 NativeLaunchEnvironmentOverlay::new_context_root(
4538 provider.clone(),
4539 transport,
4540 environment,
4541 )
4542 .map_err(|_| {
4543 failure(
4544 NodeFailureCode::ContextPackMaterializationFailed,
4545 "context-only launch overlay is invalid",
4546 )
4547 })?
4548 } else {
4549 NativeLaunchEnvironmentOverlay::new(
4550 provider.clone(),
4551 transport,
4552 environment,
4553 )
4554 .map_err(|_| {
4555 failure(
4556 NodeFailureCode::BackendOperationFailed,
4557 "session environment overlay is invalid",
4558 )
4559 })?
4560 };
4561 Some(PreparedNativeLaunchOverlay::Environment(overlay))
4562 } else {
4563 None
4564 };
4565 Ok(Some((overlay, guard)))
4566 }
4567
4568 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
4569 let Some(mut ownership) = self
4570 .materializations
4571 .lock()
4572 .unwrap_or_else(|poisoned| poisoned.into_inner())
4573 .get(id)
4574 .cloned()
4575 else {
4576 return Ok(());
4577 };
4578 if ownership.state() != MaterializationState::CleanupRequired {
4579 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
4580 failure(
4581 NodeFailureCode::BackendOperationFailed,
4582 "session environment cleanup state is invalid",
4583 )
4584 })?;
4585 }
4586 {
4587 let _transaction = self
4588 .state_transaction
4589 .lock()
4590 .unwrap_or_else(|poisoned| poisoned.into_inner());
4591 let previous = self
4592 .materializations
4593 .lock()
4594 .unwrap_or_else(|poisoned| poisoned.into_inner())
4595 .insert(id.clone(), ownership.clone());
4596 if let Err(error) = self.persist_state_locked() {
4597 let mut materializations = self
4598 .materializations
4599 .lock()
4600 .unwrap_or_else(|poisoned| poisoned.into_inner());
4601 if let Some(previous) = previous {
4602 materializations.insert(id.clone(), previous);
4603 }
4604 return Err(persistence_failure(error));
4605 }
4606 }
4607 let cleanup = self
4608 .session_environment_materializer
4609 .as_ref()
4610 .ok_or(())
4611 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
4612 if cleanup.is_err() {
4613 let _ = ownership.mark_recovery_required(unix_time_ms());
4614 self.materializations
4615 .lock()
4616 .unwrap_or_else(|poisoned| poisoned.into_inner())
4617 .insert(id.clone(), ownership);
4618 if self.persist_state().is_err() {
4619 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4620 }
4621 return Err(failure(
4622 NodeFailureCode::BackendOperationFailed,
4623 "session environment cleanup requires recovery",
4624 ));
4625 }
4626 let _transaction = self
4627 .state_transaction
4628 .lock()
4629 .unwrap_or_else(|poisoned| poisoned.into_inner());
4630 self.materializations
4631 .lock()
4632 .unwrap_or_else(|poisoned| poisoned.into_inner())
4633 .remove(id);
4634 if let Err(error) = self.persist_state_locked() {
4635 self.materializations
4636 .lock()
4637 .unwrap_or_else(|poisoned| poisoned.into_inner())
4638 .insert(id.clone(), ownership);
4639 return Err(persistence_failure(error));
4640 }
4641 Ok(())
4642 }
4643
4644 fn resolve_record_materialization(
4645 &self,
4646 record_id: &SessionRecordId,
4647 provider: &AgentId,
4648 mode: SessionMode,
4649 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4650 bundle: Option<&ResolvedBundleReceipt>,
4651 context: Option<&ResolvedContextPackReceipt>,
4652 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
4653 let ownership = {
4654 let materializations = self
4655 .materializations
4656 .lock()
4657 .unwrap_or_else(|poisoned| poisoned.into_inner());
4658 let mut matches = materializations.values().filter(|record| {
4659 record.owner()
4660 == &MaterializationOwner::Record {
4661 record_id: record_id.clone(),
4662 }
4663 });
4664 let ownership = matches.next().cloned();
4665 if matches.next().is_some() {
4666 return Err(failure(
4667 NodeFailureCode::BackendOperationFailed,
4668 "managed session has conflicting environment ownership",
4669 ));
4670 }
4671 ownership
4672 };
4673 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
4674 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4675 failure(
4676 NodeFailureCode::ContextPackMaterializationFailed,
4677 "session context materializer is unavailable",
4678 )
4679 })?;
4680 let pack = materializer
4681 .revalidate_context(ownership, receipt)
4682 .map_err(|_| {
4683 self.mark_materialization_recovery_required(ownership.id());
4684 failure(
4685 NodeFailureCode::ContextPackMaterializationFailed,
4686 "managed session context pack revalidation failed",
4687 )
4688 })?;
4689 self.context_catalog
4690 .write()
4691 .unwrap_or_else(|poisoned| poisoned.into_inner())
4692 .insert(pack)
4693 .map_err(|_| failure(
4694 NodeFailureCode::ContextPackMaterializationFailed,
4695 "managed session context pack catalog restore failed",
4696 ))?;
4697 }
4698 let materialization = self.materialization_profile(
4699 provider,
4700 mode,
4701 environment_profile,
4702 bundle,
4703 context,
4704 )?;
4705 match (ownership, materialization) {
4706 (None, None) => Ok(None),
4707 (None, Some(_)) | (Some(_), None) => Err(failure(
4708 NodeFailureCode::BackendOperationFailed,
4709 "managed session materialization is unavailable",
4710 )),
4711 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
4712 if ownership.environment_profile() != environment_profile
4713 || ownership.bundle() != bundle
4714 || ownership.context() != context
4715 || ownership.state() != MaterializationState::Ready
4716 {
4717 self.mark_materialization_recovery_required(ownership.id());
4718 return Err(failure(
4719 NodeFailureCode::BackendOperationFailed,
4720 "managed session environment ownership requires recovery",
4721 ));
4722 }
4723 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4724 failure(
4725 NodeFailureCode::BackendOperationFailed,
4726 "session environment materializer is unavailable",
4727 )
4728 })?;
4729 if materializer.revalidate(&ownership).is_err() {
4730 self.mark_materialization_recovery_required(ownership.id());
4731 return Err(failure(
4732 NodeFailureCode::BackendOperationFailed,
4733 "managed session environment revalidation failed",
4734 ));
4735 }
4736 let bundle_arguments = if let Some(bundle_receipt) = bundle {
4737 let catalog = self.bundle_catalog.read()
4738 .unwrap_or_else(|poisoned| poisoned.into_inner());
4739 let Some(installed) = catalog.get(&bundle_receipt.id) else {
4740 return Err(failure(
4741 NodeFailureCode::UnknownBundle,
4742 "managed session bundle is unavailable",
4743 ));
4744 };
4745 let layout = bundle_layout.ok_or_else(|| failure(
4746 NodeFailureCode::BundleBindingMismatch,
4747 "record bundle provider layout is unavailable",
4748 ))?;
4749 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
4750 drop(catalog);
4751 self.mark_materialization_recovery_required(ownership.id());
4752 return Err(failure(
4753 NodeFailureCode::BundleMaterializationFailed,
4754 "managed session bundle revalidation failed",
4755 ));
4756 }
4757 Some(bundle_launch_arguments(
4758 layout,
4759 ownership.bundle_root(),
4760 ownership.provider_home(),
4761 ).map_err(|_| failure(
4762 NodeFailureCode::BundleBindingMismatch,
4763 "managed session bundle launch binding is invalid",
4764 ))?)
4765 } else {
4766 None
4767 };
4768 if !has_environment_materialization {
4769 let overlay = bundle_arguments.map(|extra_args| {
4770 NativeInstanceLaunchOverlay::new(
4771 provider.clone(),
4772 TransportKind::Pty,
4773 Vec::new(),
4774 extra_args,
4775 )
4776 .map(PreparedNativeLaunchOverlay::Instance)
4777 .map_err(|_| failure(
4778 NodeFailureCode::BundleBindingMismatch,
4779 "managed session bundle launch overlay is invalid",
4780 ))
4781 }).transpose()?;
4782 return Ok(Some((ownership.id().clone(), overlay)));
4783 }
4784 let environment = match materializer.resolve_environment(&ownership, &profile) {
4785 Ok(environment) => environment,
4786 Err(
4787 SessionEnvironmentMaterializeError::SecretUnavailable
4788 | SessionEnvironmentMaterializeError::SecretDenied
4789 | SessionEnvironmentMaterializeError::InvalidSecretValue,
4790 ) => {
4791 return Err(failure(
4792 NodeFailureCode::BackendOperationFailed,
4793 "managed session environment resolution failed",
4794 ));
4795 }
4796 Err(
4797 SessionEnvironmentMaterializeError::InvalidRoot
4798 | SessionEnvironmentMaterializeError::OwnershipMismatch
4799 | SessionEnvironmentMaterializeError::Filesystem(_)
4800 | SessionEnvironmentMaterializeError::Record(_),
4801 ) => {
4802 self.mark_materialization_recovery_required(ownership.id());
4803 return Err(failure(
4804 NodeFailureCode::BackendOperationFailed,
4805 "managed session environment resolution failed",
4806 ));
4807 }
4808 };
4809 let transport = match mode {
4810 SessionMode::Pty => TransportKind::Pty,
4811 SessionMode::Inline => TransportKind::Pipe,
4812 SessionMode::Acp => TransportKind::Acp,
4813 };
4814 let overlay = match bundle_arguments {
4815 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
4816 NativeInstanceLaunchOverlay::new(
4817 provider.clone(),
4818 transport,
4819 environment,
4820 extra_args,
4821 )
4822 .map_err(|_| failure(
4823 NodeFailureCode::BundleBindingMismatch,
4824 "managed session bundle launch overlay is invalid",
4825 ))?,
4826 ),
4827 None => PreparedNativeLaunchOverlay::Environment(
4828 NativeLaunchEnvironmentOverlay::new(
4829 provider.clone(),
4830 transport,
4831 environment,
4832 )
4833 .map_err(|_| {
4834 failure(
4835 NodeFailureCode::BackendOperationFailed,
4836 "managed session environment overlay is invalid",
4837 )
4838 })?,
4839 ),
4840 };
4841 Ok(Some((ownership.id().clone(), Some(overlay))))
4842 }
4843 }
4844 }
4845
4846 fn destroy_record_materialization(
4847 &self,
4848 record_id: &SessionRecordId,
4849 ) -> Result<bool, NodeFailure> {
4850 let ids = self
4851 .materializations
4852 .lock()
4853 .unwrap_or_else(|poisoned| poisoned.into_inner())
4854 .values()
4855 .filter_map(|record| {
4856 (record.owner()
4857 == &MaterializationOwner::Record {
4858 record_id: record_id.clone(),
4859 })
4860 .then(|| record.id().clone())
4861 })
4862 .collect::<Vec<_>>();
4863 if ids.len() > 1 {
4864 return Err(failure(
4865 NodeFailureCode::BackendOperationFailed,
4866 "managed session has conflicting environment ownership",
4867 ));
4868 }
4869 let Some(id) = ids.first().cloned() else {
4870 return Ok(false);
4871 };
4872 let (terminal_record, mut cleanup_ownership) = {
4873 let _transaction = self
4874 .state_transaction
4875 .lock()
4876 .unwrap_or_else(|poisoned| poisoned.into_inner());
4877 let original_record = self.record(record_id)?;
4878 let mut terminal_record = original_record.clone();
4879 terminal_record.provider_session = None;
4880 terminal_record.active_session = None;
4881 terminal_record.state = ManagedSessionState::Unavailable;
4882 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
4883 terminal_record.updated_at_unix_ms = unix_time_ms();
4884 let original_ownership = self
4885 .materializations
4886 .lock()
4887 .unwrap_or_else(|poisoned| poisoned.into_inner())
4888 .get(&id)
4889 .cloned()
4890 .ok_or_else(|| {
4891 failure(
4892 NodeFailureCode::BackendOperationFailed,
4893 "managed session environment ownership disappeared during cleanup",
4894 )
4895 })?;
4896 let mut cleanup_ownership = original_ownership.clone();
4897 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
4898 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
4899 failure(
4900 NodeFailureCode::BackendOperationFailed,
4901 "managed session environment cleanup state is invalid",
4902 )
4903 })?;
4904 }
4905 self.session_records
4906 .lock()
4907 .unwrap_or_else(|poisoned| poisoned.into_inner())
4908 .records
4909 .insert(record_id.clone(), terminal_record.clone());
4910 self.materializations
4911 .lock()
4912 .unwrap_or_else(|poisoned| poisoned.into_inner())
4913 .insert(id.clone(), cleanup_ownership.clone());
4914 if let Err(error) = self.persist_state_locked() {
4915 self.session_records
4916 .lock()
4917 .unwrap_or_else(|poisoned| poisoned.into_inner())
4918 .records
4919 .insert(record_id.clone(), original_record);
4920 self.materializations
4921 .lock()
4922 .unwrap_or_else(|poisoned| poisoned.into_inner())
4923 .insert(id.clone(), original_ownership);
4924 return Err(persistence_failure(error));
4925 }
4926 (terminal_record, cleanup_ownership)
4927 };
4928 let cleanup = self
4929 .session_environment_materializer
4930 .as_ref()
4931 .ok_or(())
4932 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
4933 if cleanup.is_err() {
4934 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
4935 self.materializations
4936 .lock()
4937 .unwrap_or_else(|poisoned| poisoned.into_inner())
4938 .insert(id.clone(), cleanup_ownership);
4939 if self.persist_state().is_err() {
4940 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4941 }
4942 return Err(failure(
4943 NodeFailureCode::BackendOperationFailed,
4944 "managed session environment cleanup requires recovery",
4945 ));
4946 }
4947 let _transaction = self
4948 .state_transaction
4949 .lock()
4950 .unwrap_or_else(|poisoned| poisoned.into_inner());
4951 self.remove_record_memory(record_id);
4952 self.materializations
4953 .lock()
4954 .unwrap_or_else(|poisoned| poisoned.into_inner())
4955 .remove(&id);
4956 if let Err(error) = self.persist_state_locked() {
4957 self.session_records
4958 .lock()
4959 .unwrap_or_else(|poisoned| poisoned.into_inner())
4960 .records
4961 .insert(record_id.clone(), terminal_record);
4962 self.materializations
4963 .lock()
4964 .unwrap_or_else(|poisoned| poisoned.into_inner())
4965 .insert(id, cleanup_ownership);
4966 return Err(persistence_failure(error));
4967 }
4968 Ok(true)
4969 }
4970
4971 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
4972 let changed = {
4973 let mut materializations = self
4974 .materializations
4975 .lock()
4976 .unwrap_or_else(|poisoned| poisoned.into_inner());
4977 let Some(ownership) = materializations.get_mut(id) else {
4978 return;
4979 };
4980 ownership.mark_recovery_required(unix_time_ms()).is_ok()
4981 };
4982 if changed && self.persist_state().is_err() {
4983 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4984 }
4985 }
4986
4987 fn reconcile_materializations(&self) {
4988 let records = self
4989 .materializations
4990 .lock()
4991 .unwrap_or_else(|poisoned| poisoned.into_inner())
4992 .values()
4993 .cloned()
4994 .collect::<Vec<_>>();
4995 for mut ownership in records {
4996 let result = match (ownership.state(), ownership.owner()) {
4997 (MaterializationState::Preparing, _)
4998 | (MaterializationState::CleanupRequired, _) => {
4999 self.cleanup_materialization(ownership.id())
5000 }
5001 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5002 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5003 self.materializations
5004 .lock()
5005 .unwrap_or_else(|poisoned| poisoned.into_inner())
5006 .insert(ownership.id().clone(), ownership.clone());
5007 self.persist_state().map_err(persistence_failure)
5008 } else {
5009 Err(failure(
5010 NodeFailureCode::BackendOperationFailed,
5011 "session environment reconciliation failed",
5012 ))
5013 }
5014 }
5015 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5016 .session_environment_materializer
5017 .as_ref()
5018 .ok_or_else(|| {
5019 failure(
5020 NodeFailureCode::BackendOperationFailed,
5021 "session environment materializer is unavailable",
5022 )
5023 })
5024 .and_then(|materializer| {
5025 materializer.revalidate(&ownership).map_err(|_| failure(
5026 NodeFailureCode::BackendOperationFailed,
5027 "session materialization revalidation failed",
5028 ))?;
5029 let record = self.record(record_id)?;
5030 if record.environment_profile.as_ref() != ownership.environment_profile()
5031 || record.bundle.as_ref() != ownership.bundle()
5032 || record.context.as_ref() != ownership.context()
5033 {
5034 return Err(failure(
5035 NodeFailureCode::BackendOperationFailed,
5036 "session materialization ownership changed after restart",
5037 ));
5038 }
5039 if let Some(receipt) = ownership.bundle() {
5040 let catalog = self.bundle_catalog.read()
5041 .unwrap_or_else(|poisoned| poisoned.into_inner());
5042 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5043 NodeFailureCode::UnknownBundle,
5044 "session bundle is unavailable after restart",
5045 ))?;
5046 if bundle.receipt() != *receipt {
5047 return Err(failure(
5048 NodeFailureCode::BundleBindingMismatch,
5049 "session bundle changed after restart",
5050 ));
5051 }
5052 let layout = self.bundle_layout(
5053 &record.provider,
5054 record.mode,
5055 record.environment_profile.as_ref(),
5056 bundle,
5057 )?;
5058 materializer
5059 .revalidate_bundle(&ownership, bundle, layout)
5060 .map_err(|_| failure(
5061 NodeFailureCode::BundleMaterializationFailed,
5062 "session bundle revalidation failed",
5063 ))?;
5064 }
5065 if let Some(receipt) = ownership.context() {
5066 let pack = materializer
5067 .revalidate_context(&ownership, receipt)
5068 .map_err(|_| {
5069 failure(
5070 NodeFailureCode::ContextPackMaterializationFailed,
5071 "session context revalidation failed",
5072 )
5073 })?;
5074 self.context_catalog
5075 .write()
5076 .unwrap_or_else(|poisoned| poisoned.into_inner())
5077 .insert(pack)
5078 .map_err(|_| {
5079 failure(
5080 NodeFailureCode::ContextPackMaterializationFailed,
5081 "session context catalog restore failed",
5082 )
5083 })?;
5084 }
5085 Ok(())
5086 }),
5087 (MaterializationState::RecoveryRequired, _) => Ok(()),
5088 };
5089 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5090 let _ = ownership.mark_recovery_required(unix_time_ms());
5091 self.materializations
5092 .lock()
5093 .unwrap_or_else(|poisoned| poisoned.into_inner())
5094 .insert(ownership.id().clone(), ownership);
5095 if self.persist_state().is_err() {
5096 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5097 }
5098 }
5099 }
5100 let materialized_profiles = self
5101 .environment_materialization_profiles
5102 .read()
5103 .unwrap_or_else(|poisoned| poisoned.into_inner())
5104 .keys()
5105 .cloned()
5106 .collect::<Vec<_>>();
5107 let ownership = self
5108 .materializations
5109 .lock()
5110 .unwrap_or_else(|poisoned| poisoned.into_inner())
5111 .values()
5112 .cloned()
5113 .collect::<Vec<_>>();
5114 let changed = {
5115 let mut session_records = self
5116 .session_records
5117 .lock()
5118 .unwrap_or_else(|poisoned| poisoned.into_inner());
5119 let mut changed = false;
5120 for record in session_records.records.values_mut() {
5121 let environment_profile = record.environment_profile.as_ref();
5122 if environment_profile.is_none()
5123 && record.bundle.is_none()
5124 && record.context.is_none()
5125 {
5126 continue;
5127 }
5128 if record.bundle.is_none()
5129 && environment_profile.is_some_and(|profile| {
5130 !materialized_profiles.contains(&profile.profile_id)
5131 })
5132 {
5133 continue;
5134 }
5135 let has_exact_ownership = ownership.iter().any(|candidate| {
5136 candidate.state() == MaterializationState::Ready
5137 && candidate.environment_profile() == environment_profile
5138 && candidate.bundle() == record.bundle.as_ref()
5139 && candidate.context() == record.context.as_ref()
5140 && candidate.owner()
5141 == &MaterializationOwner::Record {
5142 record_id: record.record_id.clone(),
5143 }
5144 });
5145 if !has_exact_ownership {
5146 record.provider_session = None;
5147 record.active_session = None;
5148 record.state = ManagedSessionState::Unavailable;
5149 record.last_error = Some(if record.bundle.is_some() {
5150 "bundle-unavailable".to_owned()
5151 } else if record.context.is_some() {
5152 "context-unavailable".to_owned()
5153 } else {
5154 "environment-profile-unavailable".to_owned()
5155 });
5156 record.updated_at_unix_ms = unix_time_ms();
5157 changed = true;
5158 }
5159 }
5160 changed
5161 };
5162 if changed && self.persist_state().is_err() {
5163 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5164 }
5165 }
5166
5167 fn replay_spawn_spec(
5168 &self,
5169 spec: &SpawnSpec,
5170 now: Instant,
5171 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5172 let mut cache = self
5173 .spawn_idempotency
5174 .lock()
5175 .unwrap_or_else(|poisoned| poisoned.into_inner());
5176 cache.entries.retain(|_, entry| entry.expires_at > now);
5177 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5178 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5179 return Err(failure(
5180 NodeFailureCode::SpawnIdempotencyConflict,
5181 "spawn idempotency key was reused across spawn kinds",
5182 ));
5183 };
5184 if existing != spec {
5185 return Err(failure(
5186 NodeFailureCode::SpawnIdempotencyConflict,
5187 "spawn idempotency key was reused with a different specification",
5188 ));
5189 }
5190 return Ok(Some(result.clone()));
5191 }
5192 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5193 return Err(failure(
5194 NodeFailureCode::SpawnIdempotencyCapacity,
5195 "spawn idempotency cache reached its bounded capacity",
5196 ));
5197 }
5198 Ok(None)
5199 }
5200
5201 fn remember_spawn_spec(
5202 &self,
5203 spec: SpawnSpec,
5204 result: Result<ResolvedSpawnReceipt, NodeFailure>,
5205 accepted_at: Instant,
5206 ) {
5207 self
5208 .spawn_idempotency
5209 .lock()
5210 .unwrap_or_else(|poisoned| poisoned.into_inner())
5211 .entries
5212 .insert(
5213 spec.idempotency_key.clone(),
5214 SpawnIdempotencyEntry {
5215 value: SpawnIdempotencyValue::Standard { spec, result },
5216 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5217 },
5218 );
5219 }
5220
5221 fn claim_harness_mcp_spawn(
5222 &self,
5223 _reservation_id: &HarnessMcpReservationId,
5224 _activation_digest: &HarnessMcpActivationDigest,
5225 spec: &SpawnSpec,
5226 now: Instant,
5227 ) -> Result<(), NodeFailure> {
5228 let mut cache = self.spawn_idempotency.lock()
5229 .unwrap_or_else(|poisoned| poisoned.into_inner());
5230 cache.entries.retain(|_, entry| entry.expires_at > now);
5231 if cache.entries.contains_key(&spec.idempotency_key) {
5232 return Err(failure(
5233 NodeFailureCode::SpawnIdempotencyConflict,
5234 "spawn idempotency key was reused across spawn kinds",
5235 ));
5236 }
5237 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5238 return Err(failure(
5239 NodeFailureCode::SpawnIdempotencyCapacity,
5240 "spawn idempotency cache reached its bounded capacity",
5241 ));
5242 }
5243 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5244 value: SpawnIdempotencyValue::HarnessMcp,
5245 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5246 });
5247 Ok(())
5248 }
5249
5250 fn replay_managed_spawn(
5251 &self,
5252 request: &ManagedWorktreeSpawnRequest,
5253 now: Instant,
5254 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5255 let mut cache = self.spawn_idempotency.lock()
5256 .unwrap_or_else(|poisoned| poisoned.into_inner());
5257 cache.entries.retain(|_, entry| entry.expires_at > now);
5258 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5259 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5260 return Err(failure(
5261 NodeFailureCode::SpawnIdempotencyConflict,
5262 "spawn idempotency key was reused across spawn kinds",
5263 ));
5264 };
5265 if existing != request {
5266 return Err(failure(
5267 NodeFailureCode::SpawnIdempotencyConflict,
5268 "spawn idempotency key was reused with a different managed specification",
5269 ));
5270 }
5271 return Ok(Some(result.clone()));
5272 }
5273 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5274 return Err(failure(
5275 NodeFailureCode::SpawnIdempotencyCapacity,
5276 "spawn idempotency cache reached its bounded capacity",
5277 ));
5278 }
5279 Ok(None)
5280 }
5281
5282 fn remember_managed_spawn(
5283 &self,
5284 request: ManagedWorktreeSpawnRequest,
5285 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5286 accepted_at: Instant,
5287 ) {
5288 self.spawn_idempotency.lock()
5289 .unwrap_or_else(|poisoned| poisoned.into_inner())
5290 .entries
5291 .insert(
5292 request.spawn_spec.idempotency_key.clone(),
5293 SpawnIdempotencyEntry {
5294 value: SpawnIdempotencyValue::Managed { request, result },
5295 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5296 },
5297 );
5298 }
5299
5300 fn remember_managed_spawn_attempt(
5301 &self,
5302 durable_v2_key: Option<&SpawnIdempotencyKey>,
5303 request: ManagedWorktreeSpawnRequest,
5304 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5305 accepted_at: Instant,
5306 ) {
5307 if durable_v2_key.is_none() {
5308 self.remember_managed_spawn(request, result, accepted_at);
5309 }
5310 }
5311
5312 fn acquire_durable_managed_spawn_v2(
5313 &self,
5314 request: &ManagedWorktreeSpawnRequestV2,
5315 request_digest: String,
5316 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5317 if self.state_path.is_none() {
5318 return Err(failure(
5319 NodeFailureCode::BackendOperationFailed,
5320 "managed worktree V2 requires durable node state",
5321 ));
5322 }
5323 let _transaction = self.state_transaction
5324 .lock()
5325 .unwrap_or_else(|poisoned| poisoned.into_inner());
5326 let key = request.spawn_spec.idempotency_key.clone();
5327 let mut previous = None;
5328 {
5329 let mut replays = self.managed_spawn_replays
5330 .lock()
5331 .unwrap_or_else(|poisoned| poisoned.into_inner());
5332 if let Some(record) = replays.get_mut(&key) {
5333 if record.request_digest != request_digest {
5334 return Err(failure(
5335 NodeFailureCode::SpawnIdempotencyConflict,
5336 "managed worktree V2 idempotency key was reused with a different request",
5337 ));
5338 }
5339 match &record.state {
5340 ManagedWorktreeSpawnReplayStateV10::Pending {
5341 owner_incarnation_id,
5342 lease_id: None,
5343 } if owner_incarnation_id != &self.incarnation_id => {
5344 previous = Some(record.clone());
5345 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
5346 owner_incarnation_id: self.incarnation_id,
5347 lease_id: None,
5348 };
5349 record.updated_at_unix_ms = unix_time_ms();
5350 }
5351 ManagedWorktreeSpawnReplayStateV10::Pending {
5352 lease_id: None,
5353 ..
5354 } => {
5355 return Err(failure(
5356 NodeFailureCode::BackendBusy,
5357 "managed worktree V2 reservation is already active",
5358 ));
5359 }
5360 ManagedWorktreeSpawnReplayStateV10::Pending {
5361 lease_id: Some(_),
5362 ..
5363 } => {
5364 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
5365 }
5366 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
5367 return Ok(DurableManagedSpawnReplayDecision::Committed(
5368 receipt.clone(),
5369 ));
5370 }
5371 }
5372 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
5373 return Err(failure(
5374 NodeFailureCode::SpawnIdempotencyCapacity,
5375 "durable managed worktree replay ledger reached its bounded capacity",
5376 ));
5377 } else {
5378 let now = unix_time_ms();
5379 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
5380 idempotency_key: key.clone(),
5381 request_digest,
5382 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
5383 profile_id: request.worktree_profile_id.clone(),
5384 expected_profile_revision: request.expected_profile_revision.clone(),
5385 state: ManagedWorktreeSpawnReplayStateV10::Pending {
5386 owner_incarnation_id: self.incarnation_id,
5387 lease_id: None,
5388 },
5389 created_at_unix_ms: now,
5390 updated_at_unix_ms: now,
5391 });
5392 }
5393 }
5394 if let Err(error) = self.persist_state_locked() {
5395 let mut replays = self.managed_spawn_replays
5396 .lock()
5397 .unwrap_or_else(|poisoned| poisoned.into_inner());
5398 match previous {
5399 Some(previous) => {
5400 replays.insert(key, previous);
5401 }
5402 None => {
5403 replays.remove(&key);
5404 }
5405 }
5406 return Err(persistence_failure(error));
5407 }
5408 Ok(DurableManagedSpawnReplayDecision::Reserved)
5409 }
5410
5411 fn link_durable_managed_spawn_v2_lease_locked(
5412 &self,
5413 key: &SpawnIdempotencyKey,
5414 lease_id: &ManagedWorktreeLeaseId,
5415 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
5416 let mut replays = self.managed_spawn_replays
5417 .lock()
5418 .unwrap_or_else(|poisoned| poisoned.into_inner());
5419 let record = replays.get_mut(key).ok_or_else(|| {
5420 failure(
5421 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5422 "managed worktree V2 reservation disappeared before allocation",
5423 )
5424 })?;
5425 let previous = record.clone();
5426 match &mut record.state {
5427 ManagedWorktreeSpawnReplayStateV10::Pending {
5428 lease_id: current,
5429 ..
5430 }
5431 if current.is_none() =>
5432 {
5433 *current = Some(lease_id.clone());
5434 record.updated_at_unix_ms = unix_time_ms();
5435 Ok(previous)
5436 }
5437 _ => Err(failure(
5438 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5439 "managed worktree V2 reservation is not allocation-ready",
5440 )),
5441 }
5442 }
5443
5444 fn restore_durable_managed_spawn_v2_locked(
5445 &self,
5446 key: &SpawnIdempotencyKey,
5447 previous: ManagedWorktreeSpawnReplayRecordV10,
5448 ) {
5449 self.managed_spawn_replays
5450 .lock()
5451 .unwrap_or_else(|poisoned| poisoned.into_inner())
5452 .insert(key.clone(), previous);
5453 }
5454
5455 fn commit_durable_managed_spawn_v2(
5456 &self,
5457 request: &ManagedWorktreeSpawnRequestV2,
5458 receipt: ManagedWorktreeSpawnReceipt,
5459 ) -> Result<(), NodeFailure> {
5460 let _transaction = self.state_transaction
5461 .lock()
5462 .unwrap_or_else(|poisoned| poisoned.into_inner());
5463 let key = &request.spawn_spec.idempotency_key;
5464 let previous = {
5465 let mut replays = self.managed_spawn_replays
5466 .lock()
5467 .unwrap_or_else(|poisoned| poisoned.into_inner());
5468 let record = replays.get_mut(key).ok_or_else(|| {
5469 failure(
5470 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5471 "managed worktree V2 reservation disappeared before commit",
5472 )
5473 })?;
5474 let previous = record.clone();
5475 match &record.state {
5476 ManagedWorktreeSpawnReplayStateV10::Pending {
5477 lease_id: Some(lease_id),
5478 ..
5479 } if lease_id == &receipt.lease.lease_id => {}
5480 _ => {
5481 return Err(failure(
5482 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5483 "managed worktree V2 reservation is not linked to the spawned lease",
5484 ));
5485 }
5486 }
5487 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
5488 record.updated_at_unix_ms = unix_time_ms();
5489 previous
5490 };
5491 if let Err(error) = self.persist_state_locked() {
5492 self.restore_durable_managed_spawn_v2_locked(key, previous);
5493 return Err(persistence_failure(error));
5494 }
5495 Ok(())
5496 }
5497
5498 fn remove_unallocated_durable_managed_spawn_v2(
5499 &self,
5500 request: &ManagedWorktreeSpawnRequestV2,
5501 ) -> Result<(), NodeFailure> {
5502 let _transaction = self.state_transaction
5503 .lock()
5504 .unwrap_or_else(|poisoned| poisoned.into_inner());
5505 let key = &request.spawn_spec.idempotency_key;
5506 let removed = {
5507 let mut replays = self.managed_spawn_replays
5508 .lock()
5509 .unwrap_or_else(|poisoned| poisoned.into_inner());
5510 match replays.get(key).map(|record| &record.state) {
5511 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
5512 lease_id: None,
5513 ..
5514 }) => {
5515 replays.remove(key)
5516 }
5517 _ => None,
5518 }
5519 };
5520 let Some(removed) = removed else {
5521 return Ok(());
5522 };
5523 if let Err(error) = self.persist_state_locked() {
5524 self.managed_spawn_replays
5525 .lock()
5526 .unwrap_or_else(|poisoned| poisoned.into_inner())
5527 .insert(key.clone(), removed);
5528 return Err(persistence_failure(error));
5529 }
5530 Ok(())
5531 }
5532
5533 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
5534 if spec.target.node_id != self.node_id {
5535 return Err(failure(
5536 NodeFailureCode::SpawnTargetMismatch,
5537 "spawn target does not match this node",
5538 ));
5539 }
5540 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
5541 failure(
5542 NodeFailureCode::UnknownSpawnProfile,
5543 "spawn profile is unavailable on this node",
5544 )
5545 })?;
5546 let resolved = spec.resolve(defaults).map_err(|error| match error {
5547 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
5548 NodeFailureCode::SpawnProfileRevisionMismatch,
5549 "spawn profile revision does not match the loaded profile",
5550 ),
5551 _ => failure(
5552 NodeFailureCode::InvalidRequest,
5553 "spawn specification could not be resolved",
5554 ),
5555 })?;
5556 if resolved.target.worktree_id.is_some() {
5557 self.require_worktree_service(&resolved.target.workspace_id)?;
5558 }
5559 let environment_profile = self.resolve_environment_profile(&resolved)?;
5560 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
5561 self.resolve_context(&resolved)?;
5562 Ok(resolved)
5563 }
5564
5565 async fn resolve_spawn_workspace(
5566 &self,
5567 resolved: &ResolvedSpawnSpec,
5568 deadline: Instant,
5569 ) -> Result<WorkspaceId, NodeFailure> {
5570 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
5571 return Ok(resolved.target.workspace_id.clone());
5572 };
5573 if self.managed_worktrees.lock()
5574 .unwrap_or_else(|poisoned| poisoned.into_inner())
5575 .lease_for_workspace(worktree_id)
5576 .is_some()
5577 {
5578 return Err(failure(
5579 NodeFailureCode::ManagedWorktreeOwnershipConflict,
5580 "managed worktree targets may only be spawned through their lease request",
5581 ));
5582 }
5583 self.require_worktree_service(&resolved.target.workspace_id)?;
5584 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
5585 let selected_root = self.workspace_root(worktree_id)?;
5586 let remaining = spawn_deadline_remaining(deadline)?;
5587 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
5588 .await
5589 .map_err(|_| {
5590 failure(
5591 NodeFailureCode::SpawnDeadlineExceeded,
5592 "Git worktree selection exceeded the spawn deadline",
5593 )
5594 })?
5595 .map_err(git_worktree_failure)?;
5596 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
5597 Ok(worktree_id.clone())
5598 }
5599
5600 async fn spawn_from_spec(
5601 &self,
5602 spec: SpawnSpec,
5603 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
5604 self.spawn_from_spec_with_proxy(spec, None).await
5605 }
5606
5607 async fn spawn_from_spec_with_proxy(
5608 &self,
5609 spec: SpawnSpec,
5610 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
5611 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
5612 let accepted_at = Instant::now();
5613 if harness_mcp.is_none() {
5614 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
5615 return replayed;
5616 }
5617 }
5618 let resolved = self.resolve_spawn_spec(&spec)?;
5619 let environment_profile = self.resolve_environment_profile(&resolved)?;
5620 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
5621 let context = self.resolve_context(&resolved)?;
5622 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
5623 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
5624 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
5625 let spawn_workspace_id = match spawn_workspace_id {
5626 Ok(workspace_id) => workspace_id,
5627 Err(error) => {
5628 let result = Err(error);
5629 if harness_mcp.is_none() {
5630 self.remember_spawn_spec(spec, result.clone(), accepted_at);
5631 }
5632 return result;
5633 }
5634 };
5635 if self.managed_worktrees.lock()
5636 .unwrap_or_else(|poisoned| poisoned.into_inner())
5637 .lease_for_workspace(&spawn_workspace_id)
5638 .is_some()
5639 {
5640 let result = Err(failure(
5641 NodeFailureCode::ManagedWorktreeOwnershipConflict,
5642 "managed worktree targets may only be spawned through their lease request",
5643 ));
5644 if harness_mcp.is_none() {
5645 self.remember_spawn_spec(spec, result.clone(), accepted_at);
5646 }
5647 return result;
5648 }
5649 let result = self
5650 .spawn_session_with_deadline(
5651 spawn_workspace_id,
5652 resolved.provider.clone(),
5653 resolved.mode,
5654 resolved.terminal_size,
5655 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
5656 environment_profile.clone(),
5657 bundle.clone(),
5658 context.clone(),
5659 None,
5660 None,
5661 SpawnRecordPolicy::Always,
5662 Some(deadline),
5663 &required_capabilities,
5664 harness_mcp,
5665 resolved.approval_level,
5666 resolved.network_allowlist.clone(),
5667 resolved.browser_profile_id.clone(),
5668 )
5669 .await
5670 .map(|(session, _runtime_policy)| {
5671 resolved.receipt_with_materialization(
5672 self.incarnation_id,
5673 session,
5674 environment_profile,
5675 bundle,
5676 context,
5677 )
5678 });
5679 if harness_mcp.is_none() {
5680 self.remember_spawn_spec(spec, result.clone(), accepted_at);
5681 }
5682 result
5683 }
5684
5685 async fn spawn_from_spec_with_harness_mcp(
5686 &self,
5687 reservation_id: HarnessMcpReservationId,
5688 activation_digest: HarnessMcpActivationDigest,
5689 spec: SpawnSpec,
5690 deadline_unix_ms: u64,
5691 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
5692 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
5693 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
5694 })?;
5695 if let Some(receipt) = registry
5696 .replay_spawn(&reservation_id, &activation_digest, &spec)
5697 .map_err(harness_mcp_failure)?
5698 {
5699 return Ok(receipt);
5700 }
5701 let resolved = self.resolve_spawn_spec(&spec)?;
5702 let prepared = registry
5703 .prepare_spawn(
5704 &reservation_id,
5705 &activation_digest,
5706 &spec,
5707 deadline_unix_ms,
5708 resolved.provider.clone(),
5709 )
5710 .map_err(harness_mcp_failure)?;
5711 self.claim_harness_mcp_spawn(
5712 &reservation_id,
5713 &activation_digest,
5714 &spec,
5715 Instant::now(),
5716 )?;
5717 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
5718 let mut receipt = match spawn {
5719 Ok(receipt) => receipt,
5720 Err(error) => {
5721 let _ = registry.abort(&reservation_id, &activation_digest);
5722 return Err(error);
5723 }
5724 };
5725 let record_id = self
5726 .session_bindings
5727 .lock()
5728 .unwrap_or_else(|poisoned| poisoned.into_inner())
5729 .get(&receipt.session.session.instance_id)
5730 .and_then(|binding| binding.record_id.clone());
5731 let record_id = match record_id {
5732 Some(record_id) => record_id,
5733 None => {
5734 let rollback = self.remove_session(&receipt.session).await;
5735 let _ = registry.abort(&reservation_id, &activation_digest);
5736 if let Err(rollback_error) = rollback {
5737 return Err(rollback_error);
5738 }
5739 return Err(failure(
5740 NodeFailureCode::BindingMismatch,
5741 "harness MCP spawn did not retain its exact managed record",
5742 ));
5743 }
5744 };
5745 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
5746 reservation_id: reservation_id.clone(),
5747 activation_digest: activation_digest.clone(),
5748 });
5749 if let Err(error) = registry.mark_spawned(
5750 &prepared,
5751 receipt.session.clone(),
5752 record_id,
5753 receipt.clone(),
5754 ) {
5755 let rollback = self.remove_session(&receipt.session).await;
5756 let _ = registry.abort(&reservation_id, &activation_digest);
5757 if let Err(rollback_error) = rollback {
5758 return Err(rollback_error);
5759 }
5760 return Err(harness_mcp_failure(error));
5761 }
5762 Ok(receipt)
5763 }
5764
5765 async fn spawn_managed_worktree(
5766 &self,
5767 request: ManagedWorktreeSpawnRequest,
5768 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
5769 self.spawn_managed_worktree_inner(request, None).await
5770 }
5771
5772 async fn spawn_managed_worktree_inner(
5773 &self,
5774 request: ManagedWorktreeSpawnRequest,
5775 durable_v2_key: Option<&SpawnIdempotencyKey>,
5776 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
5777 let accepted_at = Instant::now();
5778 if durable_v2_key.is_none() {
5779 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
5780 return replayed;
5781 }
5782 }
5783 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
5784 if request.spawn_spec.target.worktree_id.is_some() {
5785 let result = Err(failure(
5786 NodeFailureCode::InvalidRequest,
5787 "managed worktree spawn must not select a caller-provided worktree",
5788 ));
5789 self.remember_managed_spawn_attempt(
5790 durable_v2_key,
5791 request,
5792 result.clone(),
5793 accepted_at,
5794 );
5795 return result;
5796 }
5797 let environment_profile = self.resolve_environment_profile(&resolved)?;
5798 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
5799 let context = self.resolve_context(&resolved)?;
5800 let source_workspace_id = resolved.target.workspace_id.clone();
5801 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
5802 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
5803 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
5804 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
5805 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
5806 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
5807 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
5808 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
5809 };
5810 let runtime_policy = timeout(
5811 spawn_deadline_remaining(deadline)?,
5812 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
5813 ).await.map_err(|_| failure(
5814 NodeFailureCode::SpawnDeadlineExceeded,
5815 "provider admission exceeded the managed spawn deadline",
5816 ))??;
5817 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
5818 return Err(failure(
5819 NodeFailureCode::UnsupportedSpawnCapability,
5820 "provider runtime does not admit a required spawn capability",
5821 ));
5822 }
5823 self.ensure_binding_capacity()?;
5824 let source_root = self.workspace_root(&source_workspace_id)?;
5825 profile.validate_for_workspace(&source_root)
5826 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
5827 let base_timeout_ms = u64::try_from(
5828 spawn_deadline_remaining(deadline)?.as_millis(),
5829 ).unwrap_or(u64::MAX).max(1);
5830 let base_commit = resolve_base_commit_with_timeout(
5831 &source_root,
5832 profile.base(),
5833 base_timeout_ms,
5834 ).await.map_err(|error| {
5835 if Instant::now() >= deadline || error.message.contains("timed out") {
5836 failure(
5837 NodeFailureCode::SpawnDeadlineExceeded,
5838 "managed worktree base resolution exceeded the spawn deadline",
5839 )
5840 } else {
5841 managed_git_worktree_failure(error)
5842 }
5843 })?;
5844
5845 let lease = {
5846 let _transaction = self.state_transaction.lock()
5847 .unwrap_or_else(|poisoned| poisoned.into_inner());
5848 let previous = self.managed_worktrees.lock()
5849 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
5850 let lease = self.managed_worktrees.lock()
5851 .unwrap_or_else(|poisoned| poisoned.into_inner())
5852 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
5853 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
5854 let previous_replay = match durable_v2_key
5855 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
5856 .transpose()
5857 {
5858 Ok(previous) => previous,
5859 Err(error) => {
5860 *self.managed_worktrees.lock()
5861 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
5862 return Err(error);
5863 }
5864 };
5865 if let Err(error) = self.persist_state_locked() {
5866 *self.managed_worktrees.lock()
5867 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
5868 if let (Some(key), Some(previous_replay)) =
5869 (durable_v2_key, previous_replay)
5870 {
5871 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
5872 }
5873 return Err(persistence_failure(error));
5874 }
5875 lease
5876 };
5877 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
5878 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
5879 self.managed_worktrees.lock()
5880 .unwrap_or_else(|poisoned| poisoned.into_inner())
5881 .remove_unmutated(&lease.lease_id);
5882 if self.persist_state().is_err() {
5883 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5884 }
5885 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
5886 self.remember_managed_spawn_attempt(
5887 durable_v2_key,
5888 request,
5889 result.clone(),
5890 accepted_at,
5891 );
5892 return result;
5893 }
5894 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
5895 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
5896 Err(error) => {
5897 self.reconcile_failed_allocation(&lease, &source_root).await;
5898 let result = Err(error);
5899 self.remember_managed_spawn_attempt(
5900 durable_v2_key,
5901 request,
5902 result.clone(),
5903 accepted_at,
5904 );
5905 return result;
5906 }
5907 };
5908 let created = match create_git_worktree_with_timeout(
5909 &source_root,
5910 &lease.target_root,
5911 &lease.branch,
5912 Some(&base_commit),
5913 mutation_timeout_ms,
5914 ).await {
5915 Ok(created) => created,
5916 Err(error) => {
5917 self.reconcile_failed_allocation(&lease, &source_root).await;
5918 let result = Err(if Instant::now() >= deadline
5919 || error.message.contains("timed out")
5920 || error.message.contains("deadline")
5921 {
5922 failure(
5923 NodeFailureCode::SpawnDeadlineExceeded,
5924 "managed worktree creation exceeded the spawn deadline",
5925 )
5926 } else {
5927 managed_git_worktree_failure(error)
5928 });
5929 self.remember_managed_spawn_attempt(
5930 durable_v2_key,
5931 request,
5932 result.clone(),
5933 accepted_at,
5934 );
5935 return result;
5936 }
5937 };
5938 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
5939 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
5940 let result = Err(failure(
5941 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5942 "created worktree identity did not match its durable lease",
5943 ));
5944 self.remember_managed_spawn_attempt(
5945 durable_v2_key,
5946 request,
5947 result.clone(),
5948 accepted_at,
5949 );
5950 return result;
5951 }
5952 {
5953 let mut registry = self.managed_worktrees.lock()
5954 .unwrap_or_else(|poisoned| poisoned.into_inner());
5955 let current = registry.get_mut(&lease.lease_id)
5956 .expect("allocated managed worktree lease remains present");
5957 current.expected_head = Some(created.head.clone());
5958 current.state = ManagedWorktreeLeaseState::Ready;
5959 current.updated_at_unix_ms = unix_time_ms();
5960 }
5961 if let Err(error) = self.persist_state() {
5962 self.mark_managed_recovery_required(
5963 &lease.lease_id,
5964 ManagedWorktreeCleanupFailure::Backend,
5965 );
5966 let result = Err(persistence_failure(error));
5967 self.remember_managed_spawn_attempt(
5968 durable_v2_key,
5969 request,
5970 result.clone(),
5971 accepted_at,
5972 );
5973 return result;
5974 }
5975 let ready = self.managed_worktrees.lock()
5976 .unwrap_or_else(|poisoned| poisoned.into_inner())
5977 .get(&lease.lease_id)
5978 .expect("ready managed worktree lease remains present")
5979 .snapshot();
5980 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
5981 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
5982 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
5983 let result = Err(error);
5984 self.remember_managed_spawn_attempt(
5985 durable_v2_key,
5986 request,
5987 result.clone(),
5988 accepted_at,
5989 );
5990 return result;
5991 }
5992 let spawn = self.spawn_session_with_deadline(
5993 lease.workspace_id.clone(),
5994 resolved.provider.clone(),
5995 resolved.mode,
5996 resolved.terminal_size,
5997 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
5998 environment_profile.clone(),
5999 bundle.clone(),
6000 context.clone(),
6001 Some(lease.lease_id.clone()),
6002 Some(runtime_policy),
6003 SpawnRecordPolicy::Always,
6004 Some(deadline),
6005 &required_capabilities,
6006 None,
6007 resolved.approval_level,
6008 resolved.network_allowlist.clone(),
6009 resolved.browser_profile_id.clone(),
6010 ).await;
6011 let (session, effective_runtime_policy) = match spawn {
6012 Ok(spawned) => spawned,
6013 Err(error) => {
6014 if self
6015 .cleanup_managed_worktree(&lease.lease_id, true)
6016 .await
6017 .is_err()
6018 {
6019 self.mark_managed_recovery_required(
6020 &lease.lease_id,
6021 ManagedWorktreeCleanupFailure::Backend,
6022 );
6023 }
6024 let result = Err(error);
6025 self.remember_managed_spawn_attempt(
6026 durable_v2_key,
6027 request,
6028 result.clone(),
6029 accepted_at,
6030 );
6031 return result;
6032 }
6033 };
6034 let (record_id, raw_inventory_record_id) = {
6035 let mut bindings = self.session_bindings.lock()
6036 .unwrap_or_else(|poisoned| poisoned.into_inner());
6037 let binding = bindings.get_mut(&session.session.instance_id)
6038 .expect("successful spawn retains its session binding");
6039 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6040 managed_spawn_record_ownership(binding, effective_runtime_policy)
6041 };
6042 let bound = self.managed_worktrees.lock()
6043 .unwrap_or_else(|poisoned| poisoned.into_inner())
6044 .bind_session(
6045 &lease.lease_id,
6046 ManagedWorktreeSessionHolder {
6047 incarnation_id: self.incarnation_id,
6048 instance_id: session.session.instance_id,
6049 generation: session.session.generation,
6050 },
6051 record_id,
6052 unix_time_ms(),
6053 )
6054 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6055 let snapshot = match bound {
6056 Ok(snapshot) => snapshot,
6057 Err(error) => {
6058 if raw_inventory_record_id
6059 .as_ref()
6060 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6061 {
6062 self.mark_managed_recovery_required(
6063 &lease.lease_id,
6064 ManagedWorktreeCleanupFailure::Backend,
6065 );
6066 }
6067 let _ = self.remove_session(&session).await;
6068 self.mark_managed_recovery_required(
6069 &lease.lease_id,
6070 ManagedWorktreeCleanupFailure::Backend,
6071 );
6072 let result = Err(error);
6073 self.remember_managed_spawn_attempt(
6074 durable_v2_key,
6075 request,
6076 result.clone(),
6077 accepted_at,
6078 );
6079 return result;
6080 }
6081 };
6082 if let Err(error) = self.persist_state() {
6083 if raw_inventory_record_id
6084 .as_ref()
6085 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6086 {
6087 self.mark_managed_recovery_required(
6088 &lease.lease_id,
6089 ManagedWorktreeCleanupFailure::Backend,
6090 );
6091 }
6092 let _ = self.remove_session(&session).await;
6093 if self
6094 .cleanup_managed_worktree(&lease.lease_id, true)
6095 .await
6096 .is_err()
6097 {
6098 self.mark_managed_recovery_required(
6099 &lease.lease_id,
6100 ManagedWorktreeCleanupFailure::Backend,
6101 );
6102 }
6103 let result = Err(persistence_failure(error));
6104 self.remember_managed_spawn_attempt(
6105 durable_v2_key,
6106 request,
6107 result.clone(),
6108 accepted_at,
6109 );
6110 return result;
6111 }
6112 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6113 let receipt = managed_spawn_receipt(
6114 &resolved,
6115 self.incarnation_id,
6116 session,
6117 snapshot,
6118 environment_profile,
6119 bundle,
6120 context,
6121 );
6122 let result = Ok(receipt);
6123 self.remember_managed_spawn_attempt(
6124 durable_v2_key,
6125 request,
6126 result.clone(),
6127 accepted_at,
6128 );
6129 result
6130 }
6131
6132 async fn spawn_managed_worktree_v2(
6133 &self,
6134 request: ManagedWorktreeSpawnRequestV2,
6135 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6136 if request.spawn_spec.target.node_id != self.node_id {
6137 return Err(failure(
6138 NodeFailureCode::SpawnTargetMismatch,
6139 "spawn target does not match this node",
6140 ));
6141 }
6142 if request.spawn_spec.target.worktree_id.is_some() {
6143 return Err(failure(
6144 NodeFailureCode::InvalidRequest,
6145 "managed worktree spawn must not select a caller-provided worktree",
6146 ));
6147 }
6148 let request_digest = managed_spawn_request_digest_v2(&request)?;
6149 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6150 match replay {
6151 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6152 DurableManagedSpawnReplayDecision::PendingLinked => {
6153 return Err(failure(
6154 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6155 "managed worktree V2 reservation has an incomplete durable allocation",
6156 ));
6157 }
6158 DurableManagedSpawnReplayDecision::Reserved => {}
6159 }
6160 let validated = (|| {
6161 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6162 let profile = self.managed_profile(
6163 &resolved.target.workspace_id,
6164 &request.worktree_profile_id,
6165 )?;
6166 if profile.revision() != &request.expected_profile_revision {
6167 return Err(failure(
6168 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6169 "managed worktree profile revision does not match the loaded profile",
6170 ));
6171 }
6172 Ok(())
6173 })();
6174 match validated {
6175 Ok(()) => {}
6176 Err(error) => {
6177 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6178 return Err(error);
6179 }
6180 }
6181 let legacy = ManagedWorktreeSpawnRequest {
6182 spawn_spec: request.spawn_spec.clone(),
6183 worktree_profile_id: request.worktree_profile_id.clone(),
6184 };
6185 match self
6186 .spawn_managed_worktree_inner(
6187 legacy,
6188 Some(&request.spawn_spec.idempotency_key),
6189 )
6190 .await
6191 {
6192 Ok(receipt) => {
6193 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6194 Ok(receipt)
6195 }
6196 Err(error) => {
6197 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6198 Err(error)
6199 }
6200 }
6201 }
6202
6203 fn managed_profile(
6204 &self,
6205 workspace_id: &WorkspaceId,
6206 profile_id: &WorktreeProfileId,
6207 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6208 if self.worktree_service_modes.get(workspace_id).copied()
6209 != Some(WorktreeServiceMode::Managed)
6210 {
6211 return Err(failure(
6212 NodeFailureCode::UnsupportedSpawnCapability,
6213 "workspace does not enable managed worktrees",
6214 ));
6215 }
6216 self.managed_worktree_profiles.get(workspace_id)
6217 .and_then(|profiles| profiles.get(profile_id))
6218 .cloned()
6219 .ok_or_else(|| failure(
6220 NodeFailureCode::InvalidRequest,
6221 "managed worktree profile is unavailable for this workspace",
6222 ))
6223 }
6224
6225 async fn reconcile_failed_allocation(
6226 &self,
6227 lease: &ManagedWorktreeLeaseRecord,
6228 source_root: &str,
6229 ) {
6230 match list_git_worktrees(source_root).await {
6231 Ok(worktrees) => {
6232 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6233 None => {
6234 match std::fs::symlink_metadata(&lease.target_root) {
6235 Err(error) if error.kind() == io::ErrorKind::NotFound => {
6236 self.managed_worktrees.lock()
6237 .unwrap_or_else(|poisoned| poisoned.into_inner())
6238 .remove_unmutated(&lease.lease_id);
6239 let _ = self.persist_state();
6240 }
6241 _ => self.mark_managed_recovery_required(
6242 &lease.lease_id,
6243 ManagedWorktreeCleanupFailure::OwnershipConflict,
6244 ),
6245 }
6246 }
6247 Some(item) if exact_owned_worktree(
6248 lease,
6249 &item.path,
6250 item.branch.as_deref(),
6251 &item.head,
6252 ) => {
6253 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6254 }
6255 Some(_) => self.mark_managed_recovery_required(
6256 &lease.lease_id,
6257 ManagedWorktreeCleanupFailure::OwnershipConflict,
6258 ),
6259 }
6260 }
6261 Err(_) => self.mark_managed_recovery_required(
6262 &lease.lease_id,
6263 ManagedWorktreeCleanupFailure::Backend,
6264 ),
6265 }
6266 }
6267
6268 async fn reconcile_managed_worktrees(&self) {
6269 let leases = self.managed_worktrees.lock()
6270 .unwrap_or_else(|poisoned| poisoned.into_inner())
6271 .active_records()
6272 .cloned()
6273 .collect::<Vec<_>>();
6274 for lease in leases {
6275 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6276 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6277 _ => {
6278 self.mark_managed_recovery_required(
6279 &lease.lease_id,
6280 ManagedWorktreeCleanupFailure::OwnershipConflict,
6281 );
6282 continue;
6283 }
6284 };
6285 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6286 self.mark_managed_recovery_required(
6287 &lease.lease_id,
6288 ManagedWorktreeCleanupFailure::OwnershipConflict,
6289 );
6290 continue;
6291 }
6292 let source_root = match self.workspace_root(&lease.source_workspace_id) {
6293 Ok(root) => root,
6294 Err(_) => {
6295 self.mark_managed_recovery_required(
6296 &lease.lease_id,
6297 ManagedWorktreeCleanupFailure::OwnershipConflict,
6298 );
6299 continue;
6300 }
6301 };
6302 let worktrees = match list_git_worktrees(&source_root).await {
6303 Ok(worktrees) => worktrees,
6304 Err(_) => {
6305 self.mark_managed_recovery_required(
6306 &lease.lease_id,
6307 ManagedWorktreeCleanupFailure::Backend,
6308 );
6309 continue;
6310 }
6311 };
6312 let listed = worktrees.iter()
6313 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6314 let Some(listed) = listed else {
6315 match std::fs::symlink_metadata(&lease.target_root) {
6316 Err(error) if error.kind() == io::ErrorKind::NotFound
6317 && lease.state == ManagedWorktreeLeaseState::Allocating
6318 && !lease.has_holders() => {
6319 let removed = self.managed_worktrees.lock()
6320 .unwrap_or_else(|poisoned| poisoned.into_inner())
6321 .tombstone(&lease.lease_id, unix_time_ms());
6322 if self.persist_state().is_ok() && removed.is_some() {
6323 self.publish(NodeEvent::ManagedWorktreeRemoved {
6324 lease_id: lease.lease_id.clone(),
6325 });
6326 }
6327 }
6328 _ => self.mark_managed_recovery_required(
6329 &lease.lease_id,
6330 ManagedWorktreeCleanupFailure::OwnershipConflict,
6331 ),
6332 }
6333 continue;
6334 };
6335 if listed.is_main || listed.is_bare || !exact_owned_worktree(
6336 &lease,
6337 &listed.path,
6338 listed.branch.as_deref(),
6339 &listed.head,
6340 ) {
6341 self.mark_managed_recovery_required(
6342 &lease.lease_id,
6343 ManagedWorktreeCleanupFailure::OwnershipConflict,
6344 );
6345 continue;
6346 }
6347 if !self.workspaces.read()
6348 .unwrap_or_else(|poisoned| poisoned.into_inner())
6349 .contains_key(&lease.workspace_id)
6350 {
6351 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
6352 self.mark_managed_recovery_required(
6353 &lease.lease_id,
6354 ManagedWorktreeCleanupFailure::Backend,
6355 );
6356 continue;
6357 }
6358 }
6359 let snapshot = {
6360 let mut registry = self.managed_worktrees.lock()
6361 .unwrap_or_else(|poisoned| poisoned.into_inner());
6362 let current = registry.get_mut(&lease.lease_id)
6363 .expect("reconciled managed lease remains present");
6364 current.expected_head = Some(listed.head.clone());
6365 current.cleanup_failure = None;
6366 current.state = if current.has_holders() {
6367 ManagedWorktreeLeaseState::InUse
6368 } else if current.retention == ManagedWorktreeRetention::Retain {
6369 ManagedWorktreeLeaseState::Retained
6370 } else {
6371 ManagedWorktreeLeaseState::Ready
6372 };
6373 current.updated_at_unix_ms = unix_time_ms();
6374 current.snapshot()
6375 };
6376 if self.persist_state().is_err() {
6377 self.mark_managed_recovery_required(
6378 &lease.lease_id,
6379 ManagedWorktreeCleanupFailure::Backend,
6380 );
6381 continue;
6382 }
6383 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6384 if snapshot.state == ManagedWorktreeLeaseState::Ready {
6385 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
6386 }
6387 }
6388 }
6389
6390 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
6410 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
6411 .session_records
6412 .lock()
6413 .unwrap_or_else(|poisoned| poisoned.into_inner())
6414 .records
6415 .values()
6416 .filter(|record| {
6417 record.state == ManagedSessionState::Live
6418 && record.exported_context.is_none()
6419 && record.provider_session.is_some()
6420 })
6421 .filter_map(|record| {
6422 record
6423 .active_session
6424 .clone()
6425 .map(|session| (record.record_id.clone(), session))
6426 })
6427 .collect();
6428 for (record_id, session) in candidates {
6429 let shared = Arc::clone(self);
6430 tokio::spawn(async move {
6431 shared
6432 .reconcile_context_pack_export_for_record(&record_id, session)
6433 .await;
6434 });
6435 }
6436 }
6437
6438 async fn reconcile_context_pack_export_for_record(
6457 &self,
6458 record_id: &SessionRecordId,
6459 session: SessionAddress,
6460 ) {
6461 let Ok(record) = self.record(record_id) else {
6462 return;
6463 };
6464 if !matches!(
6465 record.state,
6466 ManagedSessionState::Live | ManagedSessionState::Dormant
6467 ) || record.exported_context.is_some()
6468 || record.provider_session.is_none()
6469 {
6470 return;
6471 }
6472 let Ok(receipt) = self
6479 .export_context_pack_for_session_record(&record.record_id, &session, true)
6480 .await
6481 else {
6482 return;
6483 };
6484 self.set_exported_context(&record.record_id, receipt);
6485 }
6486
6487 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
6492 let transaction = self
6493 .state_transaction
6494 .lock()
6495 .unwrap_or_else(|poisoned| poisoned.into_inner());
6496 let (previous, updated) = {
6497 let mut records = self
6498 .session_records
6499 .lock()
6500 .unwrap_or_else(|poisoned| poisoned.into_inner());
6501 let Some(record) = records.records.get_mut(record_id) else {
6502 return;
6503 };
6504 if record.exported_context.is_some() {
6505 return;
6506 }
6507 let previous = record.clone();
6508 record.exported_context = Some(receipt);
6509 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
6510 (previous, record.clone())
6511 };
6512 if self.persist_state_locked().is_err() {
6513 self.session_records
6514 .lock()
6515 .unwrap_or_else(|poisoned| poisoned.into_inner())
6516 .records
6517 .insert(record_id.clone(), previous);
6518 return;
6519 }
6520 drop(transaction);
6521 self.publish_record(updated);
6522 }
6523
6524 fn managed_record_export_target_for_instance(
6534 &self,
6535 instance_id: AgentInstanceId,
6536 generation: SessionGeneration,
6537 ) -> Option<(SessionRecordId, SessionAddress)> {
6538 let bindings = self
6539 .session_bindings
6540 .lock()
6541 .unwrap_or_else(|poisoned| poisoned.into_inner());
6542 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
6543 let record_id = binding.record_id.clone()?;
6544 Some((
6545 record_id,
6546 SessionAddress {
6547 workspace_id: binding.workspace_id.clone(),
6548 session: SessionKey { instance_id, generation },
6549 },
6550 ))
6551 }
6552
6553 fn mark_managed_recovery_required(
6554 &self,
6555 lease_id: &ManagedWorktreeLeaseId,
6556 failure_kind: ManagedWorktreeCleanupFailure,
6557 ) {
6558 let snapshot = {
6559 let mut registry = self.managed_worktrees.lock()
6560 .unwrap_or_else(|poisoned| poisoned.into_inner());
6561 let Some(lease) = registry.get_mut(lease_id) else { return };
6562 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
6563 lease.cleanup_failure = Some(failure_kind);
6564 lease.updated_at_unix_ms = unix_time_ms();
6565 lease.snapshot()
6566 };
6567 let _ = self.persist_state();
6568 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
6569 }
6570
6571 fn set_persistence_error(&self, error: Option<String>) {
6572 *self
6573 .persistence_error
6574 .write()
6575 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
6576 .map(|message| session_registry::sanitized_persistence_summary(&message));
6577 }
6578
6579 fn persist_state(&self) -> io::Result<()> {
6580 let _transaction = self
6581 .state_transaction
6582 .lock()
6583 .unwrap_or_else(|poisoned| poisoned.into_inner());
6584 self.persist_state_locked()
6585 }
6586
6587 fn persist_state_locked(&self) -> io::Result<()> {
6588 let workspaces = self
6589 .workspaces
6590 .read()
6591 .unwrap_or_else(|poisoned| poisoned.into_inner())
6592 .clone();
6593 let records = self
6594 .session_records
6595 .lock()
6596 .unwrap_or_else(|poisoned| poisoned.into_inner())
6597 .records
6598 .values()
6599 .cloned()
6600 .collect::<Vec<_>>();
6601 let mut managed = self.managed_worktrees
6602 .lock()
6603 .unwrap_or_else(|poisoned| poisoned.into_inner());
6604 managed.reattach_record_holders(
6605 &managed_worktree_record_holders(&records),
6606 unix_time_ms(),
6607 );
6608 let materializations = self
6609 .materializations
6610 .lock()
6611 .unwrap_or_else(|poisoned| poisoned.into_inner())
6612 .values()
6613 .cloned()
6614 .collect::<Vec<_>>();
6615 let managed_spawn_replays = self.managed_spawn_replays
6616 .lock()
6617 .unwrap_or_else(|poisoned| poisoned.into_inner())
6618 .values()
6619 .cloned()
6620 .collect::<Vec<_>>();
6621 let result = session_registry::save_v10(
6622 self.state_path.as_deref(),
6623 &self.node_id,
6624 &workspaces,
6625 &records,
6626 &managed.records(),
6627 &managed.tombstones(),
6628 &materializations,
6629 &managed_spawn_replays,
6630 );
6631 match &result {
6632 Ok(warning) => self.set_persistence_error(warning.clone()),
6633 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
6634 }
6635 result.map(|_| ())
6636 }
6637
6638 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
6639 let _mutation_guard = self.mutation_gate.lock().await;
6640 self.begin_shutdown_locked().await
6641 }
6642
6643 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
6644 if self.shutdown.swap(true, Ordering::AcqRel) {
6645 return Ok(());
6646 }
6647 if let Some(registry) = self.harness_mcp_registry.as_ref() {
6648 let instances = registry.shutdown();
6649 if let Some(control) = self.native_launch_profile_control.as_ref() {
6650 for instance_id in instances {
6651 control.clear_native_mcp_server_launch_overlay(instance_id);
6652 }
6653 }
6654 }
6655 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
6656 let mut first_error = None;
6657 for session in &self.handle.snapshot().sessions {
6658 let remaining = deadline.saturating_duration_since(Instant::now());
6659 if remaining.is_zero() {
6660 first_error.get_or_insert_with(|| {
6661 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
6662 });
6663 break;
6664 }
6665 if let Err(error) = self
6666 .dispatch_bounded(
6667 ControlCommand::Stop {
6668 instance_id: session.instance_id,
6669 force: true,
6670 },
6671 remaining,
6672 )
6673 .await
6674 {
6675 first_error.get_or_insert(error);
6676 }
6677 }
6678 self.shutdown_notify.notify_waiters();
6679 match first_error {
6680 Some(error) => Err(error),
6681 None => Ok(()),
6682 }
6683 }
6684
6685 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
6686 let bindings = self
6687 .session_bindings
6688 .lock()
6689 .unwrap_or_else(|poisoned| poisoned.into_inner());
6690 if bindings.len() >= CONTROL_SESSIONS_MAX {
6691 Err(failure(
6692 NodeFailureCode::BackendBusy,
6693 "node session-binding capacity is full; remove an existing session first",
6694 ))
6695 } else {
6696 Ok(())
6697 }
6698 }
6699
6700 fn ensure_workspace_environment_profile_compatible(
6707 &self,
6708 workspace_id: &WorkspaceId,
6709 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
6710 ) -> Result<(), NodeFailure> {
6711 let bindings = self
6712 .session_bindings
6713 .lock()
6714 .unwrap_or_else(|poisoned| poisoned.into_inner());
6715 for binding in bindings.values() {
6716 if &binding.workspace_id != workspace_id {
6717 continue;
6718 }
6719 if binding.environment_profile.as_ref() != environment_profile {
6720 return Err(failure(
6721 NodeFailureCode::WorkspaceBusy,
6722 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
6723 ));
6724 }
6725 }
6726 Ok(())
6727 }
6728
6729 #[cfg(test)]
6730 fn bind_session(
6731 &self,
6732 instance_id: AgentInstanceId,
6733 workspace_id: WorkspaceId,
6734 generation: SessionGeneration,
6735 ) {
6736 self.bind_session_with_policy(
6737 &SessionAddress {
6738 workspace_id,
6739 session: SessionKey { instance_id, generation },
6740 },
6741 ProviderRuntimePolicy::raw_pty(),
6742 None,
6743 );
6744 }
6745
6746 #[cfg(test)]
6747 fn bind_session_with_policy(
6748 &self,
6749 address: &SessionAddress,
6750 runtime_policy: ProviderRuntimePolicy,
6751 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6752 ) {
6753 self.bind_session_with_materialization(
6754 address,
6755 runtime_policy,
6756 environment_profile,
6757 None,
6758 None,
6759 None,
6760 );
6761 }
6762
6763 fn bind_session_with_materialization(
6764 &self,
6765 address: &SessionAddress,
6766 runtime_policy: ProviderRuntimePolicy,
6767 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6768 bundle: Option<ResolvedBundleReceipt>,
6769 context: Option<ResolvedContextPackReceipt>,
6770 materialization_id: Option<MaterializationId>,
6771 ) {
6772 let mut bindings = self
6773 .session_bindings
6774 .lock()
6775 .unwrap_or_else(|poisoned| poisoned.into_inner());
6776 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
6777 debug_assert!(!bindings.contains_key(&address.session.instance_id));
6778 bindings.insert(address.session.instance_id, SessionBinding {
6779 workspace_id: address.workspace_id.clone(),
6780 generation: address.session.generation,
6781 runtime_policy,
6782 pending_resume: None,
6783 record_id: None,
6784 managed_worktree_lease_id: None,
6785 environment_profile,
6786 bundle,
6787 context,
6788 materialization_id,
6789 });
6790 }
6791
6792 #[cfg(test)]
6793 fn bind_managed_session(
6794 &self,
6795 address: &SessionAddress,
6796 record_id: SessionRecordId,
6797 runtime_policy: ProviderRuntimePolicy,
6798 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6799 ) {
6800 self.bind_managed_session_with_materialization(
6801 address,
6802 record_id,
6803 runtime_policy,
6804 environment_profile,
6805 None,
6806 None,
6807 None,
6808 );
6809 }
6810
6811 fn bind_managed_session_with_materialization(
6812 &self,
6813 address: &SessionAddress,
6814 record_id: SessionRecordId,
6815 runtime_policy: ProviderRuntimePolicy,
6816 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6817 bundle: Option<ResolvedBundleReceipt>,
6818 context: Option<ResolvedContextPackReceipt>,
6819 materialization_id: Option<MaterializationId>,
6820 ) {
6821 let mut bindings = self
6822 .session_bindings
6823 .lock()
6824 .unwrap_or_else(|poisoned| poisoned.into_inner());
6825 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
6826 debug_assert!(!bindings.contains_key(&address.session.instance_id));
6827 bindings.insert(
6828 address.session.instance_id,
6829 SessionBinding {
6830 workspace_id: address.workspace_id.clone(),
6831 generation: address.session.generation,
6832 runtime_policy,
6833 pending_resume: None,
6834 record_id: Some(record_id),
6835 managed_worktree_lease_id: None,
6836 environment_profile,
6837 bundle,
6838 context,
6839 materialization_id,
6840 },
6841 );
6842 }
6843
6844 #[cfg(test)]
6845 fn bind_spawn_session(
6846 &self,
6847 address: &SessionAddress,
6848 provider: AgentId,
6849 mode: SessionMode,
6850 runtime_policy: ProviderRuntimePolicy,
6851 record_policy: SpawnRecordPolicy,
6852 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6853 ) -> Result<Option<SessionRecordId>, NodeFailure> {
6854 self.bind_spawn_session_with_materialization(
6855 address,
6856 provider,
6857 mode,
6858 runtime_policy,
6859 record_policy,
6860 environment_profile,
6861 None,
6862 None,
6863 None,
6864 )
6865 }
6866
6867 fn bind_spawn_session_with_materialization(
6868 &self,
6869 address: &SessionAddress,
6870 provider: AgentId,
6871 mode: SessionMode,
6872 runtime_policy: ProviderRuntimePolicy,
6873 record_policy: SpawnRecordPolicy,
6874 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6875 bundle: Option<ResolvedBundleReceipt>,
6876 context: Option<ResolvedContextPackReceipt>,
6877 materialization_id: Option<MaterializationId>,
6878 ) -> Result<Option<SessionRecordId>, NodeFailure> {
6879 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
6880 && !runtime_policy.provider_session_identity
6881 {
6882 self.bind_session_with_materialization(
6883 address,
6884 runtime_policy,
6885 environment_profile,
6886 bundle,
6887 context,
6888 materialization_id,
6889 );
6890 return Ok(None);
6891 }
6892 let record_owns_lifecycle = runtime_policy.provider_session_identity;
6893 let record = self.new_record(
6894 address,
6895 provider,
6896 mode,
6897 active_record_state(runtime_policy.provider_session_identity, false),
6898 None,
6899 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
6900 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
6901 record_owns_lifecycle.then(|| context.clone()).flatten(),
6902 )?;
6903 let record_id = record.record_id.clone();
6904 let transaction = self
6905 .state_transaction
6906 .lock()
6907 .unwrap_or_else(|poisoned| poisoned.into_inner());
6908 self.insert_record(record.clone())?;
6909 let previous_materialization = if record_owns_lifecycle {
6910 if let Some(materialization_id) = materialization_id.as_ref() {
6911 let mut materializations = self
6912 .materializations
6913 .lock()
6914 .unwrap_or_else(|poisoned| poisoned.into_inner());
6915 let Some(ownership) = materializations.get_mut(materialization_id) else {
6916 drop(materializations);
6917 self.remove_record_memory(&record_id);
6918 return Err(failure(
6919 NodeFailureCode::BackendOperationFailed,
6920 "session environment ownership disappeared before binding",
6921 ));
6922 };
6923 let previous = ownership.clone();
6924 if ownership
6925 .transfer_to_record(record_id.clone(), unix_time_ms())
6926 .is_err()
6927 {
6928 drop(materializations);
6929 self.remove_record_memory(&record_id);
6930 return Err(failure(
6931 NodeFailureCode::BackendOperationFailed,
6932 "session environment ownership transfer failed",
6933 ));
6934 }
6935 Some(previous)
6936 } else {
6937 None
6938 }
6939 } else {
6940 None
6941 };
6942 self.bind_managed_session_with_materialization(
6943 address,
6944 record_id.clone(),
6945 runtime_policy,
6946 environment_profile,
6947 bundle,
6948 context,
6949 materialization_id.clone(),
6950 );
6951 if let Err(error) = self.persist_state_locked() {
6952 self.remove_binding(address);
6953 self.remove_record_memory(&record_id);
6954 if let (Some(materialization_id), Some(previous)) =
6955 (materialization_id.as_ref(), previous_materialization)
6956 {
6957 self.materializations
6958 .lock()
6959 .unwrap_or_else(|poisoned| poisoned.into_inner())
6960 .insert(materialization_id.clone(), previous);
6961 }
6962 return Err(persistence_failure(error));
6963 }
6964 drop(transaction);
6965 self.publish_record(record);
6966 Ok(Some(record_id))
6967 }
6968
6969 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
6970 for _ in 0..8 {
6971 let bytes = random_nonce().map_err(|error| {
6972 failure(
6973 NodeFailureCode::BackendOperationFailed,
6974 &format!("session record identity generation failed: {error}"),
6975 )
6976 })?;
6977 let mut value = String::with_capacity(27);
6978 value.push_str("sr-");
6979 for byte in &bytes[..12] {
6980 use std::fmt::Write as _;
6981 let _ = write!(&mut value, "{byte:02x}");
6982 }
6983 let record_id = SessionRecordId::new(value).map_err(|error| {
6984 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
6985 })?;
6986 let records = self
6987 .session_records
6988 .lock()
6989 .unwrap_or_else(|poisoned| poisoned.into_inner());
6990 if !records.records.contains_key(&record_id) {
6991 return Ok(record_id);
6992 }
6993 }
6994 Err(failure(
6995 NodeFailureCode::BackendBusy,
6996 "could not allocate a unique session record ID",
6997 ))
6998 }
6999
7000 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7001 for _ in 0..8 {
7002 let bytes = random_nonce().map_err(|error| {
7003 failure(
7004 NodeFailureCode::BackendOperationFailed,
7005 &format!("task identity generation failed: {error}"),
7006 )
7007 })?;
7008 let mut nonce = [0_u8; 12];
7009 nonce.copy_from_slice(&bytes[..12]);
7010 let task_id = TaskId::from_nonce(nonce);
7011 let records = self
7012 .session_records
7013 .lock()
7014 .unwrap_or_else(|poisoned| poisoned.into_inner());
7015 if !records.records.values().any(|record| {
7016 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7017 == Some(&task_id)
7018 }) {
7019 return Ok(task_id);
7020 }
7021 }
7022 Err(failure(
7023 NodeFailureCode::BackendBusy,
7024 "could not allocate a unique task ID",
7025 ))
7026 }
7027
7028 fn default_record_name(&self, provider: &AgentId) -> String {
7029 let records = self
7030 .session_records
7031 .lock()
7032 .unwrap_or_else(|poisoned| poisoned.into_inner());
7033 let ordinal = records
7034 .records
7035 .values()
7036 .filter(|record| &record.provider == provider)
7037 .count()
7038 .saturating_add(1);
7039 format!("{} #{ordinal}", provider.as_str())
7040 }
7041
7042 fn new_record(
7043 &self,
7044 address: &SessionAddress,
7045 provider: AgentId,
7046 mode: SessionMode,
7047 state: ManagedSessionState,
7048 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7049 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7050 bundle: Option<ResolvedBundleReceipt>,
7051 context: Option<ResolvedContextPackReceipt>,
7052 ) -> Result<ManagedSessionRecord, NodeFailure> {
7053 let canonical_root = self.workspace_root(&address.workspace_id)?;
7054 let now = unix_time_ms();
7055 Ok(ManagedSessionRecord {
7056 record_id: self.allocate_record_id()?,
7057 display_name: self.default_record_name(&provider),
7058 provider,
7059 mode,
7060 state,
7061 workspace_id: address.workspace_id.clone(),
7062 canonical_root: opaque_windows_path(canonical_root),
7063 provider_session,
7064 active_session: Some(address.clone()),
7065 environment_profile,
7066 bundle,
7067 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7068 context,
7069 exported_context: None,
7070 task_binding: None,
7071 created_at_unix_ms: now,
7072 updated_at_unix_ms: now,
7073 last_error: None,
7074 })
7075 }
7076
7077 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7078 let mut records = self
7079 .session_records
7080 .lock()
7081 .unwrap_or_else(|poisoned| poisoned.into_inner());
7082 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7083 return Err(failure(
7084 NodeFailureCode::BackendBusy,
7085 "managed session record capacity is full; forget an inactive record first",
7086 ));
7087 }
7088 if let Some(identity) = record.provider_session.as_ref() {
7089 if records.records.values().any(|candidate| {
7090 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7091 session_registry::same_provider_session(
7092 &candidate.provider,
7093 candidate_identity,
7094 &record.provider,
7095 identity,
7096 )
7097 })
7098 }) {
7099 return Err(failure(
7100 NodeFailureCode::SessionRecordConflict,
7101 "provider session is already represented by another managed record",
7102 ));
7103 }
7104 }
7105 match records.records.entry(record.record_id.clone()) {
7106 std::collections::btree_map::Entry::Vacant(entry) => {
7107 entry.insert(record);
7108 }
7109 std::collections::btree_map::Entry::Occupied(_) => {
7110 return Err(failure(
7111 NodeFailureCode::SessionRecordConflict,
7112 "session record ID collision",
7113 ));
7114 }
7115 }
7116 Ok(())
7117 }
7118
7119 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7120 self.session_records
7121 .lock()
7122 .unwrap_or_else(|poisoned| poisoned.into_inner())
7123 .records
7124 .get(record_id)
7125 .cloned()
7126 .ok_or_else(|| {
7127 failure(
7128 NodeFailureCode::UnknownSessionRecord,
7129 "managed session record does not exist",
7130 )
7131 })
7132 }
7133
7134 fn revalidate_session_record_preview(
7135 &self,
7136 expected: &ManagedSessionRecord,
7137 identity: &ProviderSessionIdentity,
7138 ) -> Result<(), NodeFailure> {
7139 let current = self.record(&expected.record_id)?;
7140 let current_root = self.workspace_root(¤t.workspace_id)?;
7141 if current.provider != expected.provider
7142 || current.workspace_id != expected.workspace_id
7143 || current.canonical_root != expected.canonical_root
7144 || current.provider_session.as_ref() != Some(identity)
7145 || !platform::roots_equal(
7146 ¤t_root,
7147 windows_path_text(¤t.canonical_root),
7148 )
7149 {
7150 return Err(failure(
7151 NodeFailureCode::SessionRecordConflict,
7152 "managed session changed while its preview was loading",
7153 ));
7154 }
7155 Ok(())
7156 }
7157
7158 fn revalidate_session_record_context_export(
7159 &self,
7160 expected: &ManagedSessionRecord,
7161 identity: Option<&ProviderSessionIdentity>,
7162 session: &SessionAddress,
7163 allow_clean_detachment: bool,
7164 ) -> Result<(), NodeFailure> {
7165 let current = self.record(&expected.record_id)?;
7166 let current_root = self.workspace_root(¤t.workspace_id)?;
7167 let snapshot = self.internal_session_snapshot(session)?;
7168 let binding = self
7169 .session_bindings
7170 .lock()
7171 .unwrap_or_else(|poisoned| poisoned.into_inner())
7172 .get(&session.session.instance_id)
7173 .cloned()
7174 .ok_or_else(|| {
7175 failure(
7176 NodeFailureCode::SessionRecordConflict,
7177 "managed session runtime binding disappeared during context export",
7178 )
7179 })?;
7180 if !session_record_context_export_binding_is_exact(
7181 expected,
7182 ¤t,
7183 identity,
7184 session,
7185 &binding,
7186 &snapshot.agent_id,
7187 ¤t_root,
7188 allow_clean_detachment,
7189 ) || !session_record_context_export_source_is_usable(
7190 ¤t,
7191 &snapshot.status,
7192 allow_clean_detachment,
7193 ) {
7194 return Err(failure(
7195 NodeFailureCode::SessionRecordConflict,
7196 "managed session binding changed during context export",
7197 ));
7198 }
7199 Ok(())
7200 }
7201
7202 fn revalidate_loaded_history(
7203 &self,
7204 session: &SessionAddress,
7205 candidate_id: &str,
7206 loaded: &HistorySessionRecord,
7207 ) -> Result<(), NodeFailure> {
7208 let snapshot = self.internal_session_snapshot(session)?;
7209 if snapshot.history.pending.is_some()
7210 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7211 || snapshot.history.loaded.as_ref() != Some(loaded)
7212 {
7213 return Err(failure(
7214 NodeFailureCode::SessionRecordConflict,
7215 "managed session history changed during context export",
7216 ));
7217 }
7218 Ok(())
7219 }
7220
7221 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7222 self.session_records
7223 .lock()
7224 .unwrap_or_else(|poisoned| poisoned.into_inner())
7225 .records
7226 .remove(record_id)
7227 }
7228
7229 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7230 if self.destroy_record_materialization(record_id)? {
7231 self.publish(NodeEvent::SessionRecordRemoved {
7232 record_id: record_id.clone(),
7233 });
7234 return Ok(());
7235 }
7236 let transaction = self
7237 .state_transaction
7238 .lock()
7239 .unwrap_or_else(|poisoned| poisoned.into_inner());
7240 let Some(removed) = self.remove_record_memory(record_id) else {
7241 return Ok(());
7242 };
7243 if let Err(error) = self.persist_state_locked() {
7244 let _ = self.insert_record(removed);
7245 return Err(persistence_failure(error));
7246 }
7247 drop(transaction);
7248 self.publish(NodeEvent::SessionRecordRemoved {
7249 record_id: record_id.clone(),
7250 });
7251 Ok(())
7252 }
7253
7254 fn publish_record(&self, record: ManagedSessionRecord) {
7255 self.publish(NodeEvent::SessionRecordUpserted { record });
7256 }
7257
7258 fn mark_record_error(
7259 &self,
7260 record_id: &SessionRecordId,
7261 message: &str,
7262 ) -> Result<(), NodeFailure> {
7263 let transaction = self
7264 .state_transaction
7265 .lock()
7266 .unwrap_or_else(|poisoned| poisoned.into_inner());
7267 let previous = {
7268 let mut records = self
7269 .session_records
7270 .lock()
7271 .unwrap_or_else(|poisoned| poisoned.into_inner());
7272 let Some(record) = records.records.get_mut(record_id) else {
7273 return Ok(());
7274 };
7275 let previous = record.clone();
7276 record.state = ManagedSessionState::Unavailable;
7277 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7278 record.updated_at_unix_ms = unix_time_ms();
7279 previous
7280 };
7281 if let Err(error) = self.persist_state_locked() {
7282 self.session_records
7283 .lock()
7284 .unwrap_or_else(|poisoned| poisoned.into_inner())
7285 .records
7286 .insert(record_id.clone(), previous);
7287 return Err(persistence_failure(error));
7288 }
7289 let record = self.record(record_id)?;
7290 drop(transaction);
7291 self.publish_record(record);
7292 Ok(())
7293 }
7294
7295 fn rename_session_record(
7296 &self,
7297 record_id: &SessionRecordId,
7298 display_name: String,
7299 ) -> Result<ManagedSessionRecord, NodeFailure> {
7300 validate_display_name(&display_name)
7301 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7302 let _transaction = self
7303 .state_transaction
7304 .lock()
7305 .unwrap_or_else(|poisoned| poisoned.into_inner());
7306 let previous = {
7307 let mut records = self
7308 .session_records
7309 .lock()
7310 .unwrap_or_else(|poisoned| poisoned.into_inner());
7311 let record = records.records.get_mut(record_id).ok_or_else(|| {
7312 failure(
7313 NodeFailureCode::UnknownSessionRecord,
7314 "managed session record does not exist",
7315 )
7316 })?;
7317 let previous = record.clone();
7318 record.display_name = display_name;
7319 record.updated_at_unix_ms = unix_time_ms();
7320 previous
7321 };
7322 if let Err(error) = self.persist_state_locked() {
7323 self.session_records
7324 .lock()
7325 .unwrap_or_else(|poisoned| poisoned.into_inner())
7326 .records
7327 .insert(record_id.clone(), previous);
7328 return Err(persistence_failure(error));
7329 }
7330 let updated = self.record(record_id)?;
7331 self.publish_record(updated.clone());
7332 Ok(updated)
7333 }
7334
7335 fn set_session_task(
7336 &self,
7337 record_id: &SessionRecordId,
7338 expected_revision: u64,
7339 target: SessionTaskTargetV1,
7340 ) -> Result<ManagedSessionRecord, NodeFailure> {
7341 let transaction = self
7342 .state_transaction
7343 .lock()
7344 .unwrap_or_else(|poisoned| poisoned.into_inner());
7345 let current = self.record(record_id)?;
7346 let current_task_id = current
7347 .task_binding
7348 .as_ref()
7349 .and_then(|binding| binding.task_id.as_ref());
7350 let current_revision = current
7351 .task_binding
7352 .as_ref()
7353 .map_or(0, |binding| binding.revision);
7354 if expected_revision != current_revision {
7355 return Err(failure(
7356 NodeFailureCode::SessionRecordConflict,
7357 "managed session task binding revision changed",
7358 ));
7359 }
7360 let exact = match &target {
7361 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
7362 SessionTaskTargetV1::Clear => current_task_id.is_none(),
7363 SessionTaskTargetV1::New => false,
7364 };
7365 if exact {
7366 return Ok(current);
7367 }
7368 let revision = current_revision.checked_add(1).ok_or_else(|| {
7369 failure(
7370 NodeFailureCode::SessionRecordConflict,
7371 "managed session task binding revision is exhausted",
7372 )
7373 })?;
7374 let task_id = match target {
7375 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
7376 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
7377 SessionTaskTargetV1::Clear => None,
7378 };
7379 let changed_at_unix_ms = unix_time_ms()
7380 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
7381 failure(
7382 NodeFailureCode::SessionRecordConflict,
7383 "managed session record timestamp is exhausted",
7384 )
7385 })?);
7386 let updated = {
7387 let mut records = self
7388 .session_records
7389 .lock()
7390 .unwrap_or_else(|poisoned| poisoned.into_inner());
7391 let record = records.records.get_mut(record_id).ok_or_else(|| {
7392 failure(
7393 NodeFailureCode::UnknownSessionRecord,
7394 "managed session record does not exist",
7395 )
7396 })?;
7397 record.task_binding = Some(SessionTaskBindingV1 {
7398 revision,
7399 task_id,
7400 changed_at_unix_ms,
7401 });
7402 record.updated_at_unix_ms = changed_at_unix_ms;
7403 record.clone()
7404 };
7405 if let Err(error) = self.persist_state_locked() {
7406 self.session_records
7407 .lock()
7408 .unwrap_or_else(|poisoned| poisoned.into_inner())
7409 .records
7410 .insert(record_id.clone(), current);
7411 return Err(persistence_failure(error));
7412 }
7413 drop(transaction);
7414 self.publish_record(updated.clone());
7415 Ok(updated)
7416 }
7417
7418 fn index_provider_session(
7419 &self,
7420 workspace_id: WorkspaceId,
7421 provider: AgentId,
7422 identity: ProviderSessionIdentity,
7423 display_name: String,
7424 ) -> Result<ManagedSessionRecord, NodeFailure> {
7425 self.index_provider_session_with_policy(
7426 workspace_id,
7427 provider,
7428 identity,
7429 display_name,
7430 false,
7431 )
7432 }
7433
7434 fn index_provider_session_with_policy(
7435 &self,
7436 workspace_id: WorkspaceId,
7437 provider: AgentId,
7438 identity: ProviderSessionIdentity,
7439 display_name: String,
7440 allow_validated_transcript_path: bool,
7441 ) -> Result<ManagedSessionRecord, NodeFailure> {
7442 validate_display_name(&display_name)
7443 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7444 identity
7445 .validate()
7446 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7447 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
7448 return Err(failure(
7449 NodeFailureCode::InvalidRequest,
7450 "provider session reference index v1 does not accept transcript paths",
7451 ));
7452 }
7453 let canonical_root = self.workspace_root(&workspace_id)?;
7454 if !self.enabled_providers.contains(&provider) {
7455 return Err(failure(
7456 NodeFailureCode::InvalidRequest,
7457 "provider is not enabled on this node",
7458 ));
7459 }
7460
7461 let transaction = self
7462 .state_transaction
7463 .lock()
7464 .unwrap_or_else(|poisoned| poisoned.into_inner());
7465 {
7466 let records = self
7467 .session_records
7468 .lock()
7469 .unwrap_or_else(|poisoned| poisoned.into_inner());
7470 if let Some(existing) = records.records.values().find(|candidate| {
7471 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7472 candidate_identity.key == identity.key && candidate_identity.id == identity.id
7473 })
7474 }) {
7475 if existing.provider == provider && existing.workspace_id == workspace_id {
7476 return Ok(existing.clone());
7477 }
7478 return Err(failure(
7479 NodeFailureCode::SessionRecordConflict,
7480 "provider session reference conflicts with another provider or workspace",
7481 ));
7482 }
7483 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7484 return Err(failure(
7485 NodeFailureCode::BackendBusy,
7486 "managed session record capacity is full; forget an inactive record first",
7487 ));
7488 }
7489 }
7490
7491 let now = unix_time_ms();
7492 let record = ManagedSessionRecord {
7493 record_id: self.allocate_record_id()?,
7494 display_name,
7495 provider,
7496 mode: SessionMode::Pty,
7497 state: ManagedSessionState::Dormant,
7498 workspace_id,
7499 canonical_root: opaque_windows_path(canonical_root),
7500 provider_session: Some(identity),
7501 active_session: None,
7502 environment_profile: None,
7503 bundle: None,
7504 context_id: None,
7505 context: None,
7506 exported_context: None,
7507 task_binding: None,
7508 created_at_unix_ms: now,
7509 updated_at_unix_ms: now,
7510 last_error: None,
7511 };
7512 self.insert_record(record.clone())?;
7513 if let Err(error) = self.persist_state_locked() {
7514 self.remove_record_memory(&record.record_id);
7515 return Err(persistence_failure(error));
7516 }
7517 drop(transaction);
7518 self.publish_record(record.clone());
7519 Ok(record)
7520 }
7521
7522 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
7523 self.session_bindings
7524 .lock()
7525 .unwrap_or_else(|poisoned| poisoned.into_inner())
7526 .iter()
7527 .find_map(|(instance_id, binding)| {
7528 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
7529 workspace_id: binding.workspace_id.clone(),
7530 session: SessionKey {
7531 instance_id: *instance_id,
7532 generation: binding.generation,
7533 },
7534 })
7535 })
7536 }
7537
7538 async fn forget_session_record(
7539 &self,
7540 record_id: &SessionRecordId,
7541 ) -> Result<(), NodeFailure> {
7542 let record = self.record(record_id)?;
7543 if record.active_session.is_some()
7544 || matches!(
7545 record.state,
7546 ManagedSessionState::Live | ManagedSessionState::IdentityPending
7547 )
7548 {
7549 return Err(failure(
7550 NodeFailureCode::SessionRecordBusy,
7551 "stop the managed session before forgetting it",
7552 ));
7553 }
7554 if let Some(address) = self.bound_address_for_record(record_id) {
7555 self.remove_session(&address).await?;
7556 }
7557 self.remove_managed_session_record(record_id, &record.workspace_id).await
7558 }
7559
7560 async fn remove_managed_session_record(
7575 &self,
7576 record_id: &SessionRecordId,
7577 workspace_id: &WorkspaceId,
7578 ) -> Result<(), NodeFailure> {
7579 let managed_lease_id = self.managed_worktrees.lock()
7580 .unwrap_or_else(|poisoned| poisoned.into_inner())
7581 .lease_for_workspace(workspace_id);
7582 if !self.destroy_record_materialization(record_id)? {
7583 let transaction = self
7584 .state_transaction
7585 .lock()
7586 .unwrap_or_else(|poisoned| poisoned.into_inner());
7587 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
7588 failure(
7589 NodeFailureCode::UnknownSessionRecord,
7590 "managed session record does not exist",
7591 )
7592 })?;
7593 if let Err(error) = self.persist_state_locked() {
7594 let _ = self.insert_record(removed);
7595 return Err(persistence_failure(error));
7596 }
7597 drop(transaction);
7598 }
7599 self.publish(NodeEvent::SessionRecordRemoved {
7600 record_id: record_id.clone(),
7601 });
7602 if let Some(lease_id) = managed_lease_id {
7603 if let Some(snapshot) = self.managed_worktrees.lock()
7604 .unwrap_or_else(|poisoned| poisoned.into_inner())
7605 .get(&lease_id)
7606 .map(ManagedWorktreeLeaseRecord::snapshot)
7607 {
7608 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7609 }
7610 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
7611 }
7612 Ok(())
7613 }
7614
7615 async fn retire_unavailable_session_records(&self) {
7628 let policy = self.session_record_retention;
7629 let age_enabled = policy.age_ms > 0;
7630 let keep_enabled = policy.keep_per_workspace > 0;
7631 if !age_enabled && !keep_enabled {
7632 return;
7633 }
7634 let now = unix_time_ms();
7635 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
7636 self.session_records
7637 .lock()
7638 .unwrap_or_else(|poisoned| poisoned.into_inner())
7639 .records
7640 .values()
7641 .filter(|record| record.state == ManagedSessionState::Unavailable)
7642 .map(|record| {
7643 (
7644 record.record_id.clone(),
7645 record.workspace_id.clone(),
7646 record.provider.clone(),
7647 record.updated_at_unix_ms,
7648 )
7649 })
7650 .collect()
7651 };
7652 if candidates.is_empty() {
7653 return;
7654 }
7655 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
7656 BTreeMap::new();
7657 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
7658 by_workspace
7659 .entry(workspace_id)
7660 .or_default()
7661 .push((record_id, provider, updated_at_unix_ms));
7662 }
7663 let reason = if age_enabled && keep_enabled {
7664 "age+keep-n"
7665 } else if age_enabled {
7666 "age"
7667 } else {
7668 "keep-n"
7669 };
7670 let mut retired: u64 = 0;
7671 for (workspace_id, mut records) in by_workspace {
7672 records.sort_by(|left, right| right.2.cmp(&left.2));
7675 for (index, (record_id, provider, updated_at_unix_ms)) in
7676 records.into_iter().enumerate()
7677 {
7678 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
7679 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
7680 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
7681 let should_retire = if age_enabled && keep_enabled {
7682 age_hit && keep_hit
7683 } else {
7684 age_hit || keep_hit
7685 };
7686 if !should_retire {
7687 continue;
7688 }
7689 match self.remove_managed_session_record(&record_id, &workspace_id).await {
7690 Ok(()) => {
7691 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
7692 retired += 1;
7693 tracing::info!(
7694 node_id = %self.node_id,
7695 record_id = %record_id,
7696 workspace_id = %workspace_id,
7697 provider = %provider,
7698 age_ms = record_age_ms,
7699 reason,
7700 "managed session record retired"
7701 );
7702 }
7703 Err(error) => tracing::warn!(
7704 node_id = %self.node_id,
7705 record_id = %record_id,
7706 workspace_id = %workspace_id,
7707 code = ?error.code,
7708 cause = %error.message,
7709 "session record retention sweep failed to retire a record"
7710 ),
7711 }
7712 }
7713 }
7714 if retired > 0 {
7715 let remaining_unavailable = self
7716 .session_records
7717 .lock()
7718 .unwrap_or_else(|poisoned| poisoned.into_inner())
7719 .records
7720 .values()
7721 .filter(|record| record.state == ManagedSessionState::Unavailable)
7722 .count();
7723 tracing::info!(
7724 node_id = %self.node_id,
7725 retired,
7726 remaining_unavailable,
7727 "session record retention sweep completed"
7728 );
7729 }
7730 }
7731
7732 async fn resume_session_record(
7733 &self,
7734 record_id: &SessionRecordId,
7735 terminal_size: gate4agent_types::TerminalSize,
7736 initial_prompt: Option<String>,
7737 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
7738 let record = self.record(record_id)?;
7739 if record.active_session.is_some()
7740 || matches!(
7741 record.state,
7742 ManagedSessionState::Live | ManagedSessionState::IdentityPending
7743 )
7744 {
7745 return Err(failure(
7746 NodeFailureCode::SessionRecordBusy,
7747 "managed session already has a live runtime binding",
7748 ));
7749 }
7750 let identity = record.provider_session.clone().ok_or_else(|| {
7751 failure(
7752 NodeFailureCode::SessionRecordNotResumable,
7753 "managed session has no verified provider session identity",
7754 )
7755 })?;
7756 let working_directory = self.workspace_root(&record.workspace_id)?;
7757 if !platform::roots_equal(
7758 &working_directory,
7759 windows_path_text(&record.canonical_root),
7760 ) {
7761 return Err(failure(
7762 NodeFailureCode::SessionRecordConflict,
7763 "managed session workspace root changed; refusing to resume in another directory",
7764 ));
7765 }
7766 {
7767 let records = self
7768 .session_records
7769 .lock()
7770 .unwrap_or_else(|poisoned| poisoned.into_inner());
7771 if records.records.values().any(|candidate| {
7772 candidate.record_id != *record_id
7773 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7774 session_registry::same_provider_session(
7775 &candidate.provider,
7776 candidate_identity,
7777 &record.provider,
7778 &identity,
7779 )
7780 })
7781 && (candidate.active_session.is_some()
7782 || matches!(
7783 candidate.state,
7784 ManagedSessionState::Live | ManagedSessionState::IdentityPending
7785 ))
7786 }) {
7787 return Err(failure(
7788 NodeFailureCode::SessionRecordConflict,
7789 "provider session is already active through another managed record",
7790 ));
7791 }
7792 }
7793 let runtime_requirement = if initial_prompt.is_some() {
7794 ProviderRuntimeRequirement::ResumeWithPrompt
7795 } else {
7796 ProviderRuntimeRequirement::Resume
7797 };
7798 let runtime_policy = self.admit_provider_runtime(
7799 &record.provider,
7800 runtime_requirement,
7801 )
7802 .await?;
7803 let request = ResumeLaunchRequest {
7804 working_directory,
7805 terminal_size,
7806 initial_prompt,
7807 };
7808 request
7809 .validate()
7810 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7811 self.ensure_binding_capacity()?;
7812 let resolved_materialization = match self.resolve_record_materialization(
7813 record_id,
7814 &record.provider,
7815 record.mode,
7816 record.environment_profile.as_ref(),
7817 record.bundle.as_ref(),
7818 record.context.as_ref(),
7819 ) {
7820 Ok(materialization) => materialization,
7821 Err(error) => {
7822 let label = match error.code {
7823 NodeFailureCode::UnknownBundle => "bundle-unavailable",
7824 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
7825 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
7826 _ => "environment-profile-unavailable",
7827 };
7828 self.mark_record_error(record_id, label)?;
7829 return Err(error);
7830 }
7831 };
7832 let (materialization_id, environment_overlay) = match resolved_materialization {
7833 Some((id, overlay)) => (Some(id), overlay),
7834 None => (None, None),
7835 };
7836 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
7837 let mut environment_selection = match self.select_environment_profile(
7838 instance_id,
7839 &record.provider,
7840 record.mode,
7841 record.environment_profile.as_ref(),
7842 ) {
7843 Ok(selection) => selection,
7844 Err(error) => {
7845 self.mark_record_error(record_id, "environment-profile-unavailable")?;
7846 return Err(error);
7847 }
7848 };
7849 let mut instance_overlay = environment_overlay
7850 .map(|overlay| self.install_prepared_launch_overlay(instance_id, overlay))
7851 .transpose()?
7852 .flatten();
7853 if let Some(stale_address) = self.bound_address_for_record(record_id) {
7854 self.remove_session(&stale_address).await?;
7855 }
7856 let address = SessionAddress {
7857 workspace_id: record.workspace_id.clone(),
7858 session: SessionKey {
7859 instance_id,
7860 generation: SessionGeneration(1),
7861 },
7862 };
7863 let transaction = self
7864 .state_transaction
7865 .lock()
7866 .unwrap_or_else(|poisoned| poisoned.into_inner());
7867 let current = self.record(record_id)?;
7868 if current.active_session.is_some()
7869 || matches!(
7870 current.state,
7871 ManagedSessionState::Live | ManagedSessionState::IdentityPending
7872 )
7873 {
7874 return Err(failure(
7875 NodeFailureCode::SessionRecordBusy,
7876 "managed session became active while resume was being prepared",
7877 ));
7878 }
7879 self.bind_managed_session_with_materialization(
7880 &address,
7881 record_id.clone(),
7882 runtime_policy,
7883 record.environment_profile.clone(),
7884 record.bundle.clone(),
7885 record.context.clone(),
7886 materialization_id,
7887 );
7888 if let Some(selection) = environment_selection.take() {
7889 selection.retain();
7890 }
7891 if let Some(overlay) = instance_overlay.take() {
7892 overlay.retain();
7893 }
7894 drop(transaction);
7895
7896 let transport = match record.mode {
7897 SessionMode::Pty => TransportKind::Pty,
7898 SessionMode::Inline => TransportKind::Pipe,
7899 SessionMode::Acp => TransportKind::Acp,
7900 };
7901 let agent_id = record.provider.clone();
7902 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7903 if let Err(error) = self
7904 .dispatch_bounded(
7905 ControlCommand::Register {
7906 instance_id,
7907 agent_id,
7908 transport,
7909 },
7910 dispatch_timeout,
7911 )
7912 .await
7913 {
7914 if let Some(binding) = self.remove_binding(&address) {
7915 self.cleanup_session_owned_materialization(&address, &binding)?;
7916 }
7917 return Err(error);
7918 }
7919 let command = self.prepare_command(ControlCommand::Resume {
7920 instance_id,
7921 target: ResumeTarget::ProviderSession { identity },
7922 runtime_policy,
7923 request,
7924 });
7925 let started_after = self.current_sequence();
7926 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
7927 self.rollback_spawn(&address, dispatch_timeout).await?;
7928 return Err(error);
7929 }
7930 if let Err(error) = self.dispatch_envelope(command) {
7931 self.clear_armed_resume(&address);
7932 self.rollback_spawn(&address, dispatch_timeout).await?;
7933 return Err(error);
7934 }
7935 match self
7936 .wait_for_record_resume(&address, started_after)
7937 .await
7938 {
7939 Ok(settled_address) => {
7940 let managed_lease_id = {
7941 self.managed_worktrees.lock()
7942 .unwrap_or_else(|poisoned| poisoned.into_inner())
7943 .lease_for_workspace(&record.workspace_id)
7944 };
7945 if let Some(lease_id) = managed_lease_id {
7946 {
7947 let mut bindings = self.session_bindings.lock()
7948 .unwrap_or_else(|poisoned| poisoned.into_inner());
7949 let binding = bindings.get_mut(&settled_address.session.instance_id)
7950 .ok_or_else(|| failure(
7951 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7952 "resumed managed session lost its exact binding",
7953 ))?;
7954 if binding.generation != settled_address.session.generation {
7955 return Err(failure(
7956 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7957 "resumed managed session generation diverged",
7958 ));
7959 }
7960 binding.managed_worktree_lease_id = Some(lease_id.clone());
7961 }
7962 let snapshot = self.managed_worktrees.lock()
7963 .unwrap_or_else(|poisoned| poisoned.into_inner())
7964 .bind_session(
7965 &lease_id,
7966 ManagedWorktreeSessionHolder {
7967 incarnation_id: self.incarnation_id,
7968 instance_id: settled_address.session.instance_id,
7969 generation: settled_address.session.generation,
7970 },
7971 Some(record_id.clone()),
7972 unix_time_ms(),
7973 ).map_err(|message| failure(
7974 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7975 &message,
7976 ))?;
7977 if let Err(error) = self.persist_state() {
7978 let _ = self.remove_session(&settled_address).await;
7979 return Err(persistence_failure(error));
7980 }
7981 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7982 }
7983 Ok((self.record(record_id)?, settled_address))
7984 }
7985 Err(error) => {
7986 if matches!(
7987 error.code,
7988 NodeFailureCode::SessionRecordNotResumable
7989 | NodeFailureCode::BackendOperationFailed
7990 ) {
7991 if let Some(bound) = self.bound_address_for_record(record_id) {
7992 let _ = self.remove_session(&bound).await;
7993 }
7994 }
7995 Err(error)
7996 }
7997 }
7998 }
7999
8000 async fn wait_for_record_resume(
8001 &self,
8002 initial_address: &SessionAddress,
8003 after_sequence: u64,
8004 ) -> Result<SessionAddress, NodeFailure> {
8005 let deadline = Instant::now()
8006 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8007 let mut scan_after = after_sequence;
8008 loop {
8009 let events = self
8010 .history
8011 .lock()
8012 .unwrap_or_else(|poisoned| poisoned.into_inner())
8013 .events
8014 .iter()
8015 .filter(|event| event.sequence > scan_after)
8016 .cloned()
8017 .collect::<Vec<_>>();
8018 for envelope in events {
8019 scan_after = scan_after.max(envelope.sequence);
8020 let NodeEvent::Control { address, event } = envelope.event else {
8021 continue;
8022 };
8023 if address.session.instance_id != initial_address.session.instance_id {
8024 continue;
8025 }
8026 match event.event {
8027 ControlEventKind::Resumed { .. } => return Ok(address),
8028 ControlEventKind::ResumeDenied { reason } => {
8029 return Err(failure(
8030 NodeFailureCode::SessionRecordNotResumable,
8031 &reason,
8032 ));
8033 }
8034 ControlEventKind::ResumeFailed { message }
8035 | ControlEventKind::CommandRejected { message } => {
8036 return Err(failure(
8037 NodeFailureCode::BackendOperationFailed,
8038 &message,
8039 ));
8040 }
8041 _ => {}
8042 }
8043 }
8044 if Instant::now() >= deadline {
8045 return Err(failure(
8046 NodeFailureCode::BackendBusy,
8047 "managed resume did not settle before the bounded deadline",
8048 ));
8049 }
8050 sleep(Duration::from_millis(5)).await;
8051 }
8052 }
8053
8054 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8055 self.workspaces
8056 .read()
8057 .unwrap_or_else(|poisoned| poisoned.into_inner())
8058 .get(workspace_id)
8059 .cloned()
8060 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8061 }
8062
8063 fn workspace_roots(&self) -> Vec<PathBuf> {
8064 self.workspaces
8065 .read()
8066 .unwrap_or_else(|poisoned| poisoned.into_inner())
8067 .values()
8068 .map(PathBuf::from)
8069 .collect()
8070 }
8071
8072 async fn inspect_workspace(
8073 &self,
8074 workspace_id: WorkspaceId,
8075 ) -> Result<WorkspaceInspection, NodeFailure> {
8076 let _permit = self
8077 .inspection_slots
8078 .clone()
8079 .try_acquire_owned()
8080 .map_err(|_| {
8081 tracing::warn!(
8082 workspace_id = %workspace_id,
8083 "workspace inspection rejected: inspection capacity is busy",
8084 );
8085 failure(
8086 NodeFailureCode::BackendBusy,
8087 "workspace inspection capacity is busy",
8088 )
8089 })?;
8090 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8091 tracing::warn!(
8092 workspace_id = %workspace_id,
8093 code = ?error.code,
8094 "workspace inspection rejected: workspace is not registered",
8095 );
8096 error
8097 })?;
8098 let tree_root = canonical_root.clone();
8099 let started_at = Instant::now();
8100 let time_budget_ms = workspace_inspection_time_budget_ms();
8101 let entry_cap = workspace_inspection_entry_cap();
8102 let deadline = started_at + Duration::from_millis(time_budget_ms);
8103 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8104 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8105 })
8106 .await
8107 .map_err(|error| {
8108 tracing::warn!(
8109 workspace_id = %workspace_id,
8110 error = %error,
8111 "workspace inspection rejected: walk task failed",
8112 );
8113 failure(
8114 NodeFailureCode::BackendOperationFailed,
8115 &format!("workspace inspection task failed: {error}"),
8116 )
8117 })?;
8118 let (mut git, git_time_budget_exceeded) =
8119 inspect_git_workspace(&canonical_root, deadline).await;
8120 if !git.worktrees.is_empty() {
8121 let registered = self
8122 .workspaces
8123 .read()
8124 .unwrap_or_else(|poisoned| poisoned.into_inner())
8125 .iter()
8126 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8127 .collect::<Vec<_>>();
8128 for worktree in &mut git.worktrees {
8129 worktree.workspace_id = registered
8130 .iter()
8131 .find(|(_, root)| {
8132 worktree_paths_equal(root, windows_path_text(&worktree.path))
8133 })
8134 .map(|(workspace_id, _)| workspace_id.clone());
8135 }
8136 }
8137 if git.is_repository {
8138 git.managed_worktree = self
8139 .managed_worktrees
8140 .lock()
8141 .unwrap_or_else(|poisoned| poisoned.into_inner())
8142 .git_scope(&workspace_id, git.branch.as_deref());
8143 }
8144 let truncation = workspace_inspection_truncation(
8145 &walk_budget,
8146 git_time_budget_exceeded,
8147 started_at.elapsed(),
8148 );
8149 if let Some(truncation) = &truncation {
8150 tracing::info!(
8151 workspace_id = %workspace_id,
8152 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8153 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8154 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8155 entries_visited = truncation.entries_visited,
8156 elapsed_ms = truncation.elapsed_ms,
8157 "workspace inspection truncated by its inner budget",
8158 );
8159 }
8160 Ok(WorkspaceInspection {
8161 workspace_id,
8162 entries,
8163 tree_truncated,
8164 git,
8165 truncation,
8166 })
8167 }
8168
8169 async fn browse_host_directories(
8170 &self,
8171 directory: Option<OpaqueHostPath>,
8172 after: Option<OpaqueHostPath>,
8173 ) -> Result<HostDirectoryListing, NodeFailure> {
8174 let permit = self
8175 .inspection_slots
8176 .clone()
8177 .try_acquire_owned()
8178 .map_err(|_| {
8179 failure(
8180 NodeFailureCode::BackendBusy,
8181 "host directory browse capacity is busy",
8182 )
8183 })?;
8184 let task = tokio::task::spawn_blocking(move || {
8185 let _permit = permit;
8186 browse_host_directories(directory, after)
8187 });
8188 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8189 .await
8190 .map_err(|_| {
8191 failure(
8192 NodeFailureCode::HostDirectoryReadTimedOut,
8193 "host directory browse exceeded its bounded deadline",
8194 )
8195 })?
8196 .map_err(|_| {
8197 failure(
8198 NodeFailureCode::HostDirectoryReadFailed,
8199 "host directory browse task failed",
8200 )
8201 })?
8202 .map_err(host_directory_failure)
8203 }
8204
8205 async fn read_workspace_file(
8206 &self,
8207 workspace_id: WorkspaceId,
8208 path: RepositoryPath,
8209 ) -> Result<WorkspaceFileRead, NodeFailure> {
8210 let canonical_root = self.workspace_root(&workspace_id)?;
8211 let permit = self
8212 .inspection_slots
8213 .clone()
8214 .try_acquire_owned()
8215 .map_err(|_| {
8216 failure(
8217 NodeFailureCode::BackendBusy,
8218 "workspace file read capacity is busy",
8219 )
8220 })?;
8221 let path_to_read = path.clone();
8222 let task = tokio::task::spawn_blocking(move || {
8223 let _permit = permit;
8224 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8225 });
8226 let content = timeout(
8227 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8228 task,
8229 )
8230 .await
8231 .map_err(|_| {
8232 failure(
8233 NodeFailureCode::RepositoryFileReadTimedOut,
8234 "repository file read exceeded its bounded deadline",
8235 )
8236 })?
8237 .map_err(|_| {
8238 failure(
8239 NodeFailureCode::RepositoryFileReadFailed,
8240 "repository file read task failed",
8241 )
8242 })?
8243 .map_err(workspace_file_failure)?;
8244 let content = match content {
8245 WorkspaceFileBytes::Utf8(text) => {
8246 let revision = Some(workspace_file_revision(text.as_bytes()));
8247 return Ok(WorkspaceFileRead {
8248 workspace_id,
8249 path,
8250 content: WorkspaceFileContent::Utf8 {
8251 byte_len: u32::try_from(text.len())
8252 .expect("bounded workspace text length must fit u32"),
8253 text,
8254 },
8255 revision,
8256 });
8257 }
8258 WorkspaceFileBytes::NonUtf8 { byte_length } => {
8259 WorkspaceFileContent::NonUtf8 {
8260 byte_len: u32::try_from(byte_length)
8261 .expect("bounded workspace file length must fit u32"),
8262 }
8263 }
8264 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8265 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8266 },
8267 };
8268 Ok(WorkspaceFileRead {
8269 workspace_id,
8270 path,
8271 content,
8272 revision: None,
8273 })
8274 }
8275
8276 async fn write_workspace_file(
8277 &self,
8278 workspace_id: WorkspaceId,
8279 path: RepositoryPath,
8280 expected_revision: WorkspaceFileRevision,
8281 text: String,
8282 ) -> Result<WorkspaceFileRead, NodeFailure> {
8283 let canonical_root = self.workspace_root(&workspace_id)?;
8284 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8285 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8286 })?;
8287 let path_to_write = path.clone();
8288 let expected = expected_revision.as_str().to_owned();
8289 let text_to_write = text.clone();
8290 let task = tokio::task::spawn_blocking(move || {
8291 let _permit = permit;
8292 write_workspace_file_to_disk(
8293 Path::new(&canonical_root),
8294 &path_to_write,
8295 &expected,
8296 &text_to_write,
8297 )
8298 });
8299 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8300 .await
8301 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8302 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8303 .map_err(workspace_file_write_failure)?;
8304 Ok(WorkspaceFileRead {
8305 workspace_id,
8306 path,
8307 content: WorkspaceFileContent::Utf8 {
8308 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8309 text,
8310 },
8311 revision: Some(WorkspaceFileRevision::new(revision)
8312 .expect("workspace writer returns a valid SHA-256 revision")),
8313 })
8314 }
8315
8316 async fn create_workspace_file(
8317 &self,
8318 workspace_id: WorkspaceId,
8319 path: RepositoryPath,
8320 ) -> Result<WorkspaceFileRead, NodeFailure> {
8321 let canonical_root = self.workspace_root(&workspace_id)?;
8322 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8323 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8324 })?;
8325 let path_to_create = path.clone();
8326 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8327 let task_commit_state = Arc::clone(&commit_state);
8328 let task = tokio::task::spawn_blocking(move || {
8329 let _permit = permit;
8330 create_workspace_file_on_disk(
8331 Path::new(&canonical_root),
8332 &path_to_create,
8333 &task_commit_state,
8334 )
8335 });
8336 let revision = settle_workspace_entry_create(
8337 task,
8338 commit_state,
8339 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8340 "repository file create exceeded its bounded deadline",
8341 "repository file create task failed",
8342 )
8343 .await?;
8344 Ok(WorkspaceFileRead {
8345 workspace_id,
8346 path,
8347 content: WorkspaceFileContent::Utf8 {
8348 text: String::new(),
8349 byte_len: 0,
8350 },
8351 revision: Some(WorkspaceFileRevision::new(revision)
8352 .expect("workspace creator returns a valid SHA-256 revision")),
8353 })
8354 }
8355
8356 async fn create_workspace_directory(
8357 &self,
8358 workspace_id: WorkspaceId,
8359 path: RepositoryPath,
8360 ) -> Result<WorkspaceEntry, NodeFailure> {
8361 let canonical_root = self.workspace_root(&workspace_id)?;
8362 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8363 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8364 })?;
8365 let path_to_create = path.clone();
8366 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8367 let task_commit_state = Arc::clone(&commit_state);
8368 let task = tokio::task::spawn_blocking(move || {
8369 let _permit = permit;
8370 create_workspace_directory_on_disk(
8371 Path::new(&canonical_root),
8372 &path_to_create,
8373 &task_commit_state,
8374 )
8375 });
8376 settle_workspace_entry_create(
8377 task,
8378 commit_state,
8379 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8380 "repository directory create exceeded its bounded deadline",
8381 "repository directory create task failed",
8382 )
8383 .await?;
8384 Ok(WorkspaceEntry {
8385 relative_path: path,
8386 kind: WorkspaceEntryKind::Directory,
8387 })
8388 }
8389
8390 async fn read_git_history(
8391 &self,
8392 workspace_id: WorkspaceId,
8393 path: Option<RepositoryPath>,
8394 before: Option<GitObjectId>,
8395 limit: u16,
8396 ) -> Result<GitHistoryPage, NodeFailure> {
8397 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8398 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
8399 })?;
8400 let root = self.workspace_root(&workspace_id)?;
8401 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
8402 }
8403
8404 async fn read_git_diff(
8405 &self,
8406 workspace_id: WorkspaceId,
8407 request: GitDiffRequest,
8408 ) -> Result<GitDiff, NodeFailure> {
8409 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8410 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
8411 })?;
8412 let root = self.workspace_root(&workspace_id)?;
8413 read_git_diff_bounded(&root, request).await
8414 }
8415
8416 fn native_session_route_root(
8417 &self,
8418 route: &NativeSessionCatalogRoute,
8419 ) -> Result<Option<String>, NodeFailure> {
8420 route
8421 .validate()
8422 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8423 match route.scope {
8424 gate4agent_types::NativeSessionCatalogScope::Workspace => route
8425 .workspace_id
8426 .as_ref()
8427 .map(|workspace_id| self.workspace_root(workspace_id))
8428 .transpose(),
8429 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
8430 }
8431 }
8432
8433 fn project_native_session_entries(
8434 &self,
8435 route: &NativeSessionCatalogRoute,
8436 entries: Vec<ScopedNativeSessionCatalogEntry>,
8437 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
8438 let current_root = route
8439 .workspace_id
8440 .as_ref()
8441 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
8442 let records = self
8443 .session_records
8444 .lock()
8445 .unwrap_or_else(|poisoned| poisoned.into_inner());
8446 let mut seen_records = std::collections::HashSet::new();
8447 entries
8448 .into_iter()
8449 .map(|entry| {
8450 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
8451 let matches = records
8452 .records
8453 .values()
8454 .filter(|record| {
8455 record.provider == route.provider
8456 && record.workspace_id == *workspace_id
8457 && current_root.as_deref().is_some_and(|root| {
8458 platform::roots_equal(
8459 root,
8460 windows_path_text(&record.canonical_root),
8461 )
8462 })
8463 && record.provider_session.as_ref().is_some_and(|identity| {
8464 session_registry::same_provider_session(
8465 &record.provider,
8466 identity,
8467 &route.provider,
8468 &entry.provider_session,
8469 )
8470 })
8471 })
8472 .map(|record| record.record_id.clone())
8473 .collect::<Vec<_>>();
8474 (matches.len() == 1
8475 && seen_records.insert(matches[0].clone()))
8476 .then(|| matches[0].clone())
8477 });
8478 let metadata = entry.metadata;
8479 crate::protocol::NativeSessionCatalogEntry {
8480 selection_id: metadata.selection_id,
8481 title: metadata.title,
8482 modified_at_unix_ms: metadata.modified_at_unix_ms,
8483 model: metadata.model,
8484 message_count: metadata.message_count,
8485 completed_turn_count: metadata.completed_turn_count,
8486 external_group: entry.external_group,
8487 record_id,
8488 }
8489 })
8490 .collect()
8491 }
8492
8493 async fn catalog_native_sessions(
8494 &self,
8495 route: NativeSessionCatalogRoute,
8496 limit: u16,
8497 ) -> Result<(
8498 Vec<crate::protocol::NativeSessionCatalogEntry>,
8499 crate::protocol::NativeSessionCatalogSummary,
8500 ), NodeFailure> {
8501 let canonical_root = self.native_session_route_root(&route)?;
8502 let registered_workspace_roots = self.workspace_roots();
8503 let provider = route.provider.clone();
8504 let scope = route.scope;
8505 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8506 failure(
8507 NodeFailureCode::InvalidRequest,
8508 "native session catalog is not configured",
8509 )
8510 })?;
8511 let _permit = Arc::clone(&self.inspection_slots)
8512 .try_acquire_owned()
8513 .map_err(|_| {
8514 failure(
8515 NodeFailureCode::BackendBusy,
8516 "native session catalog capacity is busy",
8517 )
8518 })?;
8519 let page = run_native_session_catalog_operation(
8520 catalog,
8521 "native session catalog",
8522 move |catalog| {
8523 catalog.catalog_initial_for_scope(
8524 &provider,
8525 scope,
8526 canonical_root.as_deref().map(Path::new),
8527 ®istered_workspace_roots,
8528 limit,
8529 )
8530 },
8531 )
8532 .await?;
8533 let summary = crate::protocol::NativeSessionCatalogSummary {
8534 catalog_revision: page.revision,
8535 recent_cutoff_unix_ms: page.cutoff_unix_ms,
8536 recent_total_count: page.recent_total_count,
8537 older_total_count: page.older_total_count,
8538 recent_next_after_selection_id: page.next_after_selection_id,
8539 recent_has_more: page.remaining_count > 0,
8540 };
8541 Ok((self.project_native_session_entries(&route, page.entries), summary))
8542 }
8543
8544 async fn page_native_sessions(
8545 &self,
8546 route: NativeSessionCatalogRoute,
8547 window: crate::protocol::NativeSessionCatalogWindow,
8548 catalog_revision: u64,
8549 recent_cutoff_unix_ms: u64,
8550 after_selection_id: Option<String>,
8551 limit: u16,
8552 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
8553 let canonical_root = self.native_session_route_root(&route)?;
8554 let registered_workspace_roots = self.workspace_roots();
8555 let provider = route.provider.clone();
8556 let scope = route.scope;
8557 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8558 failure(
8559 NodeFailureCode::InvalidRequest,
8560 "native session catalog is not configured",
8561 )
8562 })?;
8563 let _permit = Arc::clone(&self.inspection_slots)
8564 .try_acquire_owned()
8565 .map_err(|_| {
8566 failure(
8567 NodeFailureCode::BackendBusy,
8568 "native session catalog capacity is busy",
8569 )
8570 })?;
8571 let page = run_native_session_catalog_operation(
8572 catalog,
8573 "native session catalog page",
8574 move |catalog| {
8575 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
8576 &provider,
8577 scope,
8578 canonical_root.as_deref().map(Path::new),
8579 ®istered_workspace_roots,
8580 window,
8581 catalog_revision,
8582 recent_cutoff_unix_ms,
8583 after_selection_id.as_deref(),
8584 limit,
8585 ))
8586 },
8587 )
8588 .await?;
8589 let page = page.map_err(|error| match error {
8590 NativeSessionCatalogError::StaleCatalog => failure(
8591 NodeFailureCode::StaleNativeSessionCatalog,
8592 "native session catalog revision or cursor is stale",
8593 ),
8594 _ => failure(
8595 NodeFailureCode::BackendOperationFailed,
8596 "native session catalog page failed",
8597 ),
8598 })?;
8599 let entries = self.project_native_session_entries(&route, page.entries);
8600 Ok(crate::protocol::NativeSessionCatalogPage {
8601 window: page.window,
8602 revision: page.revision,
8603 entries,
8604 next_after_selection_id: page.next_after_selection_id,
8605 remaining_count: page.remaining_count,
8606 has_more: page.remaining_count > 0,
8607 })
8608 }
8609
8610 async fn preview_native_session(
8611 &self,
8612 selection: NativeSessionSelection,
8613 message_limit: u16,
8614 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
8615 selection
8616 .validate()
8617 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8618 let canonical_root = self.native_session_route_root(&selection.route)?;
8619 let registered_workspace_roots = self.workspace_roots();
8620 let provider = selection.route.provider.clone();
8621 let scope = selection.route.scope;
8622 let catalog_revision = selection.catalog_revision;
8623 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
8624 let selection_id = selection.selection_id.clone();
8625 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8626 failure(
8627 NodeFailureCode::InvalidRequest,
8628 "native session preview is not configured",
8629 )
8630 })?;
8631 let _permit = Arc::clone(&self.inspection_slots)
8632 .try_acquire_owned()
8633 .map_err(|_| {
8634 failure(
8635 NodeFailureCode::BackendBusy,
8636 "native session preview capacity is busy",
8637 )
8638 })?;
8639 let preview = run_native_session_catalog_operation(
8640 catalog,
8641 "native session preview",
8642 move |catalog| {
8643 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
8644 &provider,
8645 scope,
8646 canonical_root.as_deref().map(Path::new),
8647 ®istered_workspace_roots,
8648 catalog_revision,
8649 recent_cutoff_unix_ms,
8650 &selection_id,
8651 message_limit,
8652 ))
8653 },
8654 )
8655 .await?;
8656 preview
8657 .map(Into::into)
8658 .map_err(native_session_preview_failure)
8659 }
8660
8661 async fn index_native_session(
8662 &self,
8663 selection: NativeSessionSelection,
8664 display_name: String,
8665 ) -> Result<ManagedSessionRecord, NodeFailure> {
8666 selection
8667 .validate()
8668 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8669 if selection.route.scope
8670 != gate4agent_types::NativeSessionCatalogScope::Workspace
8671 {
8672 return Err(failure(
8673 NodeFailureCode::WorkspaceRegistrationRequired,
8674 "register the external project as a workspace before indexing or resuming it",
8675 ));
8676 }
8677 let workspace_id = selection
8678 .route
8679 .workspace_id
8680 .clone()
8681 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
8682 let canonical_root = self
8683 .native_session_route_root(&selection.route)?
8684 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
8685 let registered_workspace_roots = self.workspace_roots();
8686 let provider = selection.route.provider.clone();
8687 let catalog_revision = selection.catalog_revision;
8688 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
8689 let selection_id = selection.selection_id.clone();
8690 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8691 failure(
8692 NodeFailureCode::InvalidRequest,
8693 "native session index is not configured",
8694 )
8695 })?;
8696 let _permit = Arc::clone(&self.inspection_slots)
8697 .try_acquire_owned()
8698 .map_err(|_| {
8699 failure(
8700 NodeFailureCode::BackendBusy,
8701 "native session index capacity is busy",
8702 )
8703 })?;
8704 let resolved = run_native_session_catalog_operation(
8705 catalog,
8706 "native session index resolution",
8707 move |catalog| {
8708 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
8709 &provider,
8710 gate4agent_types::NativeSessionCatalogScope::Workspace,
8711 Some(Path::new(&canonical_root)),
8712 ®istered_workspace_roots,
8713 catalog_revision,
8714 recent_cutoff_unix_ms,
8715 &selection_id,
8716 ))
8717 },
8718 )
8719 .await?
8720 .map_err(native_session_preview_failure)?;
8721 self.index_provider_session_with_policy(
8722 workspace_id,
8723 selection.route.provider,
8724 resolved.identity,
8725 display_name,
8726 true,
8727 )
8728 }
8729
8730 async fn preview_native_session_by_session_id(
8731 &self,
8732 workspace_id: WorkspaceId,
8733 provider: AgentId,
8734 session_id: String,
8735 message_limit: u16,
8736 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
8737 let canonical_root = self.workspace_root(&workspace_id)?;
8738 let registered_workspace_roots = self.workspace_roots();
8739 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8740 failure(
8741 NodeFailureCode::InvalidRequest,
8742 "native session preview is not configured",
8743 )
8744 })?;
8745 let _permit = Arc::clone(&self.inspection_slots)
8746 .try_acquire_owned()
8747 .map_err(|_| {
8748 failure(
8749 NodeFailureCode::BackendBusy,
8750 "native session preview capacity is busy",
8751 )
8752 })?;
8753 run_native_session_catalog_operation(
8754 catalog,
8755 "native session preview",
8756 move |catalog| {
8757 catalog.preview_session_id_for_workspace(
8758 &provider,
8759 Path::new(&canonical_root),
8760 ®istered_workspace_roots,
8761 &session_id,
8762 message_limit,
8763 )
8764 },
8765 )
8766 .await
8767 }
8768
8769 async fn register_workspace(
8770 &self,
8771 workspace_id: WorkspaceId,
8772 root: String,
8773 ) -> Result<WorkspaceSnapshot, NodeFailure> {
8774 validate_workspace_request_root(&root)?;
8775 let canonicalize_workspace_id = workspace_id.clone();
8776 let workspace = tokio::task::spawn_blocking(move || {
8777 WorkspaceConfig::new(canonicalize_workspace_id, root)
8778 })
8779 .await
8780 .map_err(|error| {
8781 failure(
8782 NodeFailureCode::BackendOperationFailed,
8783 &format!("workspace canonicalization task failed: {error}"),
8784 )
8785 })?
8786 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
8787 let transaction = self
8788 .state_transaction
8789 .lock()
8790 .unwrap_or_else(|poisoned| poisoned.into_inner());
8791 let mut workspaces = self
8792 .workspaces
8793 .write()
8794 .unwrap_or_else(|poisoned| poisoned.into_inner());
8795 if workspaces.contains_key(&workspace_id) {
8796 return Err(failure(
8797 NodeFailureCode::DuplicateWorkspaceId,
8798 "workspace ID is already registered",
8799 ));
8800 }
8801 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
8802 platform::roots_equal(existing_root, workspace.canonical_root())
8803 }) {
8804 return Err(failure(
8805 NodeFailureCode::DuplicateWorkspaceRoot,
8806 &format!("workspace root is already registered as '{existing_id}'"),
8807 ));
8808 }
8809 {
8810 let records = self
8811 .session_records
8812 .lock()
8813 .unwrap_or_else(|poisoned| poisoned.into_inner());
8814 if let Some(record) = records.records.values().find(|record| {
8815 record.workspace_id == workspace_id
8816 && !record
8817 .canonical_root
8818 .as_utf8()
8819 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
8820 }) {
8821 return Err(failure(
8822 NodeFailureCode::SessionRecordConflict,
8823 &format!(
8824 "workspace ID is retained by session record '{}' at another root",
8825 record.record_id,
8826 ),
8827 ));
8828 }
8829 }
8830 let snapshot = WorkspaceSnapshot {
8831 workspace_id: workspace_id.clone(),
8832 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
8833 sessions: Vec::new(),
8834 worktree_service_mode: Some(WorktreeServiceMode::Manual),
8835 managed_worktree_profiles: Some(WorktreeProfileInventory {
8836 profiles: Vec::new(),
8837 }),
8838 };
8839 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
8840 drop(workspaces);
8841 let previous_records = {
8842 let mut records = self
8843 .session_records
8844 .lock()
8845 .unwrap_or_else(|poisoned| poisoned.into_inner());
8846 let previous = records.records.clone();
8847 for record in records.records.values_mut().filter(|record| {
8848 record.workspace_id == workspace_id
8849 && record
8850 .canonical_root
8851 .as_utf8()
8852 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
8853 }) {
8854 if record.active_session.is_none() {
8855 record.state = detached_record_state(record);
8856 }
8857 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
8858 record.last_error = None;
8859 }
8860 record.updated_at_unix_ms = unix_time_ms();
8861 }
8862 previous
8863 };
8864 if let Err(error) = self.persist_state_locked() {
8865 self.workspaces
8866 .write()
8867 .unwrap_or_else(|poisoned| poisoned.into_inner())
8868 .remove(&workspace_id);
8869 self.session_records
8870 .lock()
8871 .unwrap_or_else(|poisoned| poisoned.into_inner())
8872 .records = previous_records;
8873 return Err(persistence_failure(error));
8874 }
8875 let restored = self
8876 .session_records
8877 .lock()
8878 .unwrap_or_else(|poisoned| poisoned.into_inner())
8879 .records
8880 .values()
8881 .filter(|record| record.workspace_id == workspace_id)
8882 .cloned()
8883 .collect::<Vec<_>>();
8884 drop(transaction);
8885 self.publish(NodeEvent::WorkspaceAdded {
8886 workspace: snapshot.clone(),
8887 });
8888 for record in restored {
8889 self.publish_record(record);
8890 }
8891 Ok(snapshot)
8892 }
8893
8894 async fn create_standalone_workspace(
8895 &self,
8896 workspace_id: WorkspaceId,
8897 root: String,
8898 initial_branch: Option<String>,
8899 ) -> Result<WorkspaceSnapshot, NodeFailure> {
8900 validate_workspace_request_root(&root)?;
8901 validate_git_revision("initial branch", initial_branch.as_deref())?;
8902 let preflight_workspace_id = workspace_id.clone();
8903 let preflight_root = root.clone();
8904 let canonical_candidate = tokio::task::spawn_blocking(move || {
8905 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
8906 })
8907 .await
8908 .map_err(|_| failure(
8909 NodeFailureCode::BackendOperationFailed,
8910 "standalone workspace root preflight task failed",
8911 ))?
8912 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
8913 {
8914 let workspaces = self
8915 .workspaces
8916 .read()
8917 .unwrap_or_else(|poisoned| poisoned.into_inner());
8918 if workspaces.contains_key(&workspace_id) {
8919 return Err(failure(
8920 NodeFailureCode::DuplicateWorkspaceId,
8921 "workspace ID is already registered",
8922 ));
8923 }
8924 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
8925 platform::roots_equal(existing_root, &canonical_candidate)
8926 }) {
8927 return Err(failure(
8928 NodeFailureCode::DuplicateWorkspaceRoot,
8929 &format!("workspace root is already registered as '{existing_id}'"),
8930 ));
8931 }
8932 }
8933 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
8934 .await
8935 .map_err(standalone_workspace_failure)?;
8936 prepared
8937 .verify_for_registration()
8938 .await
8939 .map_err(standalone_workspace_failure)?;
8940 match self
8941 .register_workspace(workspace_id, prepared.root().to_owned())
8942 .await
8943 {
8944 Ok(workspace) => Ok(workspace),
8945 Err(registration_error) => {
8946 if prepared.compensate_registration_failure().await {
8947 Err(registration_error)
8948 } else {
8949 Err(failure(
8950 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
8951 "standalone workspace initialization succeeded but registration failed; recovery is required",
8952 ))
8953 }
8954 }
8955 }
8956 }
8957
8958 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
8959 let bindings = self
8960 .session_bindings
8961 .lock()
8962 .unwrap_or_else(|poisoned| poisoned.into_inner());
8963 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
8964 return Err(failure(
8965 NodeFailureCode::WorkspaceBusy,
8966 "workspace retains a bound session; remove every session before unregistering it",
8967 ));
8968 }
8969 drop(bindings);
8970 let transaction = self
8971 .state_transaction
8972 .lock()
8973 .unwrap_or_else(|poisoned| poisoned.into_inner());
8974 let mut workspaces = self
8975 .workspaces
8976 .write()
8977 .unwrap_or_else(|poisoned| poisoned.into_inner());
8978 if !workspaces.contains_key(workspace_id) {
8979 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
8980 }
8981 if workspaces.len() == 1 {
8982 return Err(failure(
8983 NodeFailureCode::LastWorkspace,
8984 "node must retain at least one workspace",
8985 ));
8986 }
8987 let removed_root = workspaces
8988 .remove(workspace_id)
8989 .expect("workspace presence was validated");
8990 drop(workspaces);
8991 let previous_records = {
8992 let mut records = self
8993 .session_records
8994 .lock()
8995 .unwrap_or_else(|poisoned| poisoned.into_inner());
8996 let previous = records.records.clone();
8997 for record in records
8998 .records
8999 .values_mut()
9000 .filter(|record| &record.workspace_id == workspace_id)
9001 {
9002 record.active_session = None;
9003 record.state = ManagedSessionState::Unavailable;
9004 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9005 record.updated_at_unix_ms = unix_time_ms();
9006 }
9007 previous
9008 };
9009 if let Err(error) = self.persist_state_locked() {
9010 self.workspaces
9011 .write()
9012 .unwrap_or_else(|poisoned| poisoned.into_inner())
9013 .insert(workspace_id.clone(), removed_root);
9014 self.session_records
9015 .lock()
9016 .unwrap_or_else(|poisoned| poisoned.into_inner())
9017 .records = previous_records;
9018 return Err(persistence_failure(error));
9019 }
9020 let unavailable = self
9021 .session_records
9022 .lock()
9023 .unwrap_or_else(|poisoned| poisoned.into_inner())
9024 .records
9025 .values()
9026 .filter(|record| &record.workspace_id == workspace_id)
9027 .cloned()
9028 .collect::<Vec<_>>();
9029 drop(transaction);
9030 self.publish(NodeEvent::WorkspaceRemoved {
9031 workspace_id: workspace_id.clone(),
9032 });
9033 for record in unavailable {
9034 self.publish_record(record);
9035 }
9036 Ok(())
9037 }
9038
9039 async fn create_worktree(
9040 &self,
9041 source_workspace_id: WorkspaceId,
9042 workspace_id: WorkspaceId,
9043 target_root: String,
9044 branch: String,
9045 base: Option<String>,
9046 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9047 self.require_worktree_service(&source_workspace_id)?;
9048 validate_workspace_request_root(&target_root)?;
9049 validate_git_revision("worktree base", base.as_deref())?;
9050 let source_root = self.workspace_root(&source_workspace_id)?;
9051 {
9052 let workspaces = self
9053 .workspaces
9054 .read()
9055 .unwrap_or_else(|poisoned| poisoned.into_inner());
9056 if workspaces.contains_key(&workspace_id) {
9057 return Err(failure(
9058 NodeFailureCode::DuplicateWorkspaceId,
9059 "workspace ID is already registered",
9060 ));
9061 }
9062 if let Some((existing_id, _)) = workspaces
9063 .iter()
9064 .find(|(_, root)| worktree_paths_equal(root, &target_root))
9065 {
9066 return Err(failure(
9067 NodeFailureCode::DuplicateWorkspaceRoot,
9068 &format!("workspace root is already registered as '{existing_id}'"),
9069 ));
9070 }
9071 }
9072 let mut worktree = create_git_worktree(
9073 &source_root,
9074 &target_root,
9075 &branch,
9076 base.as_deref(),
9077 )
9078 .await
9079 .map_err(git_worktree_failure)?;
9080 let workspace = match self
9081 .register_workspace(workspace_id, worktree.path.clone())
9082 .await
9083 {
9084 Ok(workspace) => workspace,
9085 Err(registration_error) => {
9086 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9087 return Err(failure(
9088 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9089 "worktree registration failed and compensation requires recovery",
9090 ));
9091 }
9092 return Err(failure(
9093 registration_error.code,
9094 ®istration_error.message,
9095 ));
9096 }
9097 };
9098 worktree.workspace_id = Some(workspace.workspace_id.clone());
9099 Ok((protocol_worktree(worktree), workspace))
9100 }
9101
9102 async fn remove_worktree(
9103 &self,
9104 source_workspace_id: WorkspaceId,
9105 target_root: String,
9106 ) -> Result<Option<WorkspaceId>, NodeFailure> {
9107 self.require_worktree_service(&source_workspace_id)?;
9108 validate_workspace_request_root(&target_root)?;
9109 let source_root = self.workspace_root(&source_workspace_id)?;
9110 let target_root = normalize_worktree_removal_target(&target_root)
9111 .map_err(git_worktree_failure)?;
9112 let target = list_git_worktrees(&source_root)
9113 .await
9114 .map_err(git_worktree_failure)?
9115 .into_iter()
9116 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9117 .ok_or_else(|| {
9118 failure(
9119 NodeFailureCode::WorktreeProtected,
9120 "refusing to remove a path that is not in Git's worktree listing",
9121 )
9122 })?;
9123 let registered_workspace_id = {
9124 let workspaces = self
9125 .workspaces
9126 .read()
9127 .unwrap_or_else(|poisoned| poisoned.into_inner());
9128 let matched = workspaces
9129 .iter()
9130 .find(|(_, root)| worktree_paths_equal(root, &target.path))
9131 .map(|(workspace_id, _)| workspace_id.clone());
9132 if matched.is_some() && workspaces.len() == 1 {
9133 return Err(failure(
9134 NodeFailureCode::LastWorkspace,
9135 "node must retain at least one workspace",
9136 ));
9137 }
9138 matched
9139 };
9140 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9141 let bindings = self
9142 .session_bindings
9143 .lock()
9144 .unwrap_or_else(|poisoned| poisoned.into_inner());
9145 if bindings
9146 .values()
9147 .any(|binding| &binding.workspace_id == workspace_id)
9148 {
9149 return Err(failure(
9150 NodeFailureCode::WorkspaceBusy,
9151 "worktree workspace retains a bound session; remove every session first",
9152 ));
9153 }
9154 }
9155 remove_git_worktree(&source_root, &target.path)
9156 .await
9157 .map_err(git_worktree_failure)?;
9158 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9159 self.unregister_workspace(workspace_id)
9160 .map_err(|error| failure(error.code, &error.message))?;
9161 }
9162 Ok(registered_workspace_id)
9163 }
9164
9165 async fn cleanup_managed_worktree(
9166 &self,
9167 lease_id: &ManagedWorktreeLeaseId,
9168 explicit: bool,
9169 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9170 let lease = self.managed_worktrees.lock()
9171 .unwrap_or_else(|poisoned| poisoned.into_inner())
9172 .get(lease_id)
9173 .cloned()
9174 .ok_or_else(|| failure(
9175 NodeFailureCode::UnknownManagedWorktreeLease,
9176 "managed worktree lease does not exist",
9177 ))?;
9178 if lease.state == ManagedWorktreeLeaseState::Removed {
9179 return Ok(lease.snapshot());
9180 }
9181 if lease.has_holders() {
9182 return Err(failure(
9183 NodeFailureCode::ManagedWorktreeBusy,
9184 "managed worktree lease still has a session or durable-record holder",
9185 ));
9186 }
9187 if self
9188 .materializations
9189 .lock()
9190 .unwrap_or_else(|poisoned| poisoned.into_inner())
9191 .values()
9192 .any(|record| record.managed_lease_id() == Some(lease_id))
9193 {
9194 return Err(failure(
9195 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9196 "managed worktree cleanup is blocked by session environment ownership",
9197 ));
9198 }
9199 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9200 let snapshot = {
9201 let mut registry = self.managed_worktrees.lock()
9202 .unwrap_or_else(|poisoned| poisoned.into_inner());
9203 let current = registry.get_mut(lease_id)
9204 .expect("managed worktree lease remains present");
9205 current.state = ManagedWorktreeLeaseState::Retained;
9206 current.cleanup_failure = None;
9207 current.updated_at_unix_ms = unix_time_ms();
9208 current.snapshot()
9209 };
9210 if self.persist_state().is_err() {
9211 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9212 }
9213 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9214 return Ok(snapshot);
9215 }
9216 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9217 if profile.revision() != &lease.profile_revision {
9218 self.mark_managed_recovery_required(
9219 lease_id,
9220 ManagedWorktreeCleanupFailure::OwnershipConflict,
9221 );
9222 return Err(failure(
9223 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9224 "managed worktree profile revision changed",
9225 ));
9226 }
9227 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9228 self.mark_managed_recovery_required(
9229 lease_id,
9230 ManagedWorktreeCleanupFailure::OwnershipConflict,
9231 );
9232 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9233 })?;
9234 let source_root = self.workspace_root(&lease.source_workspace_id)?;
9235 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9236 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9237 managed_git_worktree_failure(error)
9238 })?;
9239 let target = listed.iter()
9240 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9241 if let Some(target) = target {
9242 if target.is_main || target.is_bare || !exact_owned_worktree(
9243 &lease,
9244 &target.path,
9245 target.branch.as_deref(),
9246 &target.head,
9247 ) {
9248 self.mark_managed_recovery_required(
9249 lease_id,
9250 ManagedWorktreeCleanupFailure::OwnershipConflict,
9251 );
9252 return Err(failure(
9253 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9254 "Git worktree identity no longer matches the managed lease",
9255 ));
9256 }
9257 if target.prunable {
9258 self.mark_managed_cleanup_blocked(
9259 lease_id,
9260 ManagedWorktreeCleanupFailure::Prunable,
9261 );
9262 return Err(failure(
9263 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9264 "managed worktree is prunable and requires recovery",
9265 ));
9266 }
9267 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9268 let failure_kind = match error.kind {
9269 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9270 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9271 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9272 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9273 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9274 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9275 };
9276 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9277 self.mark_managed_recovery_required(lease_id, failure_kind);
9278 } else {
9279 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9280 }
9281 return Err(managed_git_worktree_failure(error));
9282 }
9283 } else {
9284 match std::fs::symlink_metadata(&lease.target_root) {
9285 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9286 Ok(_) => {
9287 self.mark_managed_recovery_required(
9288 lease_id,
9289 ManagedWorktreeCleanupFailure::OwnershipConflict,
9290 );
9291 return Err(failure(
9292 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9293 "managed target exists but is not owned by Git",
9294 ));
9295 }
9296 Err(_) => {
9297 self.mark_managed_cleanup_blocked(
9298 lease_id,
9299 ManagedWorktreeCleanupFailure::Backend,
9300 );
9301 return Err(failure(
9302 NodeFailureCode::BackendOperationFailed,
9303 "managed target identity could not be inspected",
9304 ));
9305 }
9306 }
9307 }
9308 let registered = self.workspaces.read()
9309 .unwrap_or_else(|poisoned| poisoned.into_inner())
9310 .contains_key(&lease.workspace_id);
9311 if registered {
9312 self.unregister_workspace(&lease.workspace_id)?;
9313 }
9314 let removed = {
9315 self.managed_worktrees.lock()
9316 .unwrap_or_else(|poisoned| poisoned.into_inner())
9317 .tombstone(lease_id, unix_time_ms())
9318 .expect("managed worktree lease remains present during cleanup")
9319 };
9320 self.persist_state().map_err(persistence_failure)?;
9321 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9322 Ok(removed.snapshot())
9323 }
9324
9325 fn mark_managed_cleanup_blocked(
9326 &self,
9327 lease_id: &ManagedWorktreeLeaseId,
9328 failure_kind: ManagedWorktreeCleanupFailure,
9329 ) {
9330 let snapshot = {
9331 let mut registry = self.managed_worktrees.lock()
9332 .unwrap_or_else(|poisoned| poisoned.into_inner());
9333 let Some(lease) = registry.get_mut(lease_id) else { return };
9334 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9335 lease.cleanup_failure = Some(failure_kind);
9336 lease.updated_at_unix_ms = unix_time_ms();
9337 lease.snapshot()
9338 };
9339 let _ = self.persist_state();
9340 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9341 }
9342
9343 fn require_worktree_service(
9344 &self,
9345 source_workspace_id: &WorkspaceId,
9346 ) -> Result<(), NodeFailure> {
9347 if self.managed_worktrees.lock()
9348 .unwrap_or_else(|poisoned| poisoned.into_inner())
9349 .lease_for_workspace(source_workspace_id)
9350 .is_some()
9351 {
9352 return Err(failure(
9353 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9354 "managed worktree targets are internal-only workspaces",
9355 ));
9356 }
9357 match self
9358 .worktree_service_modes
9359 .get(source_workspace_id)
9360 .copied()
9361 .unwrap_or(WorktreeServiceMode::Manual)
9362 {
9363 WorktreeServiceMode::Manual => Ok(()),
9364 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
9365 NodeFailureCode::UnsupportedSpawnCapability,
9366 "Git worktree service mode is unavailable in this node build",
9367 )),
9368 }
9369 }
9370
9371 fn managed_reservation_conflict(
9372 &self,
9373 workspace_id: Option<&WorkspaceId>,
9374 root: Option<&str>,
9375 ) -> bool {
9376 self.managed_worktrees.lock()
9377 .unwrap_or_else(|poisoned| poisoned.into_inner())
9378 .active_records()
9379 .any(|lease| {
9380 workspace_id.is_some_and(|workspace_id| {
9381 workspace_id == &lease.source_workspace_id
9382 || workspace_id == &lease.workspace_id
9383 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
9384 })
9385 }
9386
9387 fn reject_managed_reservation(
9388 &self,
9389 workspace_id: Option<&WorkspaceId>,
9390 root: Option<&str>,
9391 ) -> Result<(), NodeFailure> {
9392 if self.managed_reservation_conflict(workspace_id, root) {
9393 Err(failure(
9394 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9395 "managed worktree ownership may only be mutated through its lease cleanup",
9396 ))
9397 } else {
9398 Ok(())
9399 }
9400 }
9401
9402 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
9403 self.validate_address(address)?;
9404 self.workspace_root(&address.workspace_id)
9405 }
9406
9407 fn require_session_runtime_policy(
9441 &self,
9442 address: &SessionAddress,
9443 requirement: ProviderRuntimeRequirement,
9444 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
9445 let runtime_policy = {
9446 let bindings = self
9447 .session_bindings
9448 .lock()
9449 .unwrap_or_else(|poisoned| poisoned.into_inner());
9450 let binding = bindings
9451 .get(&address.session.instance_id)
9452 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
9453 if binding.workspace_id != address.workspace_id {
9454 return Err(failure(
9455 NodeFailureCode::SessionWorkspaceMismatch,
9456 "session belongs to another workspace",
9457 ));
9458 }
9459 if binding.generation != address.session.generation {
9460 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
9461 }
9462 binding.runtime_policy
9463 };
9464 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
9465 let snapshot = self.internal_session_snapshot(address)?;
9466 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
9467 && snapshot.provider.lead_activity != ProviderActivity::Idle
9468 {
9469 return Err(failure(
9470 NodeFailureCode::TurnInFlight,
9471 "session already has a turn in flight; wait for it to complete before sending another prompt",
9472 ));
9473 }
9474 }
9475 require_policy(runtime_policy, false, false, requirement)
9476 .map(|()| runtime_policy)
9477 .map_err(|_| failure(
9478 NodeFailureCode::UnsupportedCapability,
9479 "session runtime policy does not admit this semantic operation",
9480 ))
9481 }
9482
9483 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
9484 let mut bindings = self
9485 .session_bindings
9486 .lock()
9487 .unwrap_or_else(|poisoned| poisoned.into_inner());
9488 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
9489 binding.workspace_id == address.workspace_id
9490 && binding.generation == address.session.generation
9491 }) {
9492 let removed = bindings.remove(&address.session.instance_id);
9493 #[cfg(feature = "dig2-station-probe")]
9496 {
9497 crate::dig2_station_lease::release_lease_for_holder(
9498 &self.browser_station_leases,
9499 address.session.instance_id,
9500 );
9501 }
9502 self.clear_terminal_frame_watermark(address);
9503 if let Some(control) = self.native_launch_profile_control.as_ref() {
9504 control.clear_native_mcp_server_launch_overlay(
9505 address.session.instance_id,
9506 );
9507 }
9508 if let Some(registry) = self.harness_mcp_registry.as_ref() {
9509 registry.revoke_session(address);
9510 }
9511 if removed
9512 .as_ref()
9513 .is_some_and(|binding| {
9514 binding.environment_profile.is_some()
9515 || binding.bundle.is_some()
9516 || binding.context.is_some()
9517 })
9518 {
9519 if let Some(control) = self.native_launch_profile_control.as_ref() {
9520 control.clear_native_launch_profile_selection(
9521 address.session.instance_id,
9522 );
9523 control.clear_native_instance_launch_overlay(
9524 address.session.instance_id,
9525 );
9526 }
9527 }
9528 return removed;
9529 }
9530 None
9531 }
9532
9533 fn cleanup_session_owned_materialization(
9534 &self,
9535 address: &SessionAddress,
9536 binding: &SessionBinding,
9537 ) -> Result<(), NodeFailure> {
9538 let Some(id) = binding.materialization_id.as_ref() else {
9539 return Ok(());
9540 };
9541 let owner = self
9542 .materializations
9543 .lock()
9544 .unwrap_or_else(|poisoned| poisoned.into_inner())
9545 .get(id)
9546 .map(|record| record.owner().clone());
9547 match owner {
9548 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
9549 Some(MaterializationOwner::Session {
9550 incarnation_id,
9551 instance_id,
9552 generation,
9553 }) if incarnation_id == self.incarnation_id
9554 && instance_id == address.session.instance_id
9555 && generation == address.session.generation =>
9556 {
9557 self.cleanup_materialization(id)
9558 }
9559 Some(MaterializationOwner::Session { .. }) => Err(failure(
9560 NodeFailureCode::BackendOperationFailed,
9561 "session environment ownership requires recovery",
9562 )),
9563 }
9564 }
9565
9566 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
9567 let mut watermarks = self
9568 .terminal_frame_watermarks
9569 .lock()
9570 .unwrap_or_else(|poisoned| poisoned.into_inner());
9571 if watermarks
9572 .get(&address.session.instance_id)
9573 .is_some_and(|(current, _)| current == address)
9574 {
9575 watermarks.remove(&address.session.instance_id);
9576 }
9577 }
9578
9579 #[cfg(test)]
9580 fn address_for(
9581 &self,
9582 instance_id: AgentInstanceId,
9583 generation: SessionGeneration,
9584 ) -> Option<SessionAddress> {
9585 let binding = self.session_bindings
9586 .lock()
9587 .unwrap_or_else(|poisoned| poisoned.into_inner())
9588 .get(&instance_id)
9589 .cloned()?;
9590 (binding.generation == generation).then_some(SessionAddress {
9591 workspace_id: binding.workspace_id,
9592 session: SessionKey { instance_id, generation },
9593 })
9594 }
9595
9596 fn arm_resume(
9597 &self,
9598 address: &SessionAddress,
9599 command_id: CommandId,
9600 runtime_policy: ProviderRuntimePolicy,
9601 ) -> Result<(), NodeFailure> {
9602 let mut bindings = self
9603 .session_bindings
9604 .lock()
9605 .unwrap_or_else(|poisoned| poisoned.into_inner());
9606 let binding = bindings
9607 .get_mut(&address.session.instance_id)
9608 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
9609 if binding.workspace_id != address.workspace_id {
9610 return Err(failure(
9611 NodeFailureCode::SessionWorkspaceMismatch,
9612 "session belongs to another workspace",
9613 ));
9614 }
9615 if binding.generation != address.session.generation {
9616 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
9617 }
9618 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
9619 Ok(())
9620 }
9621
9622 fn clear_armed_resume(&self, address: &SessionAddress) {
9623 let mut bindings = self
9624 .session_bindings
9625 .lock()
9626 .unwrap_or_else(|poisoned| poisoned.into_inner());
9627 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
9628 if binding.workspace_id == address.workspace_id
9629 && binding.generation == address.session.generation
9630 {
9631 binding.pending_resume = None;
9632 }
9633 }
9634 }
9635
9636 fn publish_control(&self, event: ControlEvent) {
9637 let revokes_harness_mcp = matches!(
9638 &event.event,
9639 ControlEventKind::Exited { .. }
9640 | ControlEventKind::Failed { .. }
9641 | ControlEventKind::Removed
9642 );
9643 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
9644 let mut bindings = self
9645 .session_bindings
9646 .lock()
9647 .unwrap_or_else(|poisoned| poisoned.into_inner());
9648 let Some(binding) = bindings.get_mut(&event.instance_id) else {
9649 return;
9650 };
9651 let previous_address = SessionAddress {
9652 workspace_id: binding.workspace_id.clone(),
9653 session: SessionKey {
9654 instance_id: event.instance_id,
9655 generation: binding.generation,
9656 },
9657 };
9658 let mut resume_command_rejected = false;
9659 if binding.generation == event.generation {
9660 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
9661 let rejected_resume = matches!(
9662 &event.event,
9663 ControlEventKind::CommandRejected { .. }
9664 ) && event.command_id == Some(pending_command_id);
9665 let resume_failed = matches!(
9666 &event.event,
9667 ControlEventKind::ResumeDenied { .. }
9668 | ControlEventKind::ResumeFailed { .. }
9669 );
9670 if pending_generation == binding.generation
9671 && (rejected_resume || resume_failed)
9672 {
9673 resume_command_rejected = rejected_resume;
9674 binding.pending_resume = None;
9675 }
9676 }
9677 } else {
9678 let expected = binding.generation.0.checked_add(1);
9679 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
9680 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
9681 generation == binding.generation
9682 })
9683 || expected != Some(event.generation.0)
9684 || !authorized
9685 {
9686 return;
9687 }
9688 let (_, _, runtime_policy) = binding
9689 .pending_resume
9690 .expect("authorized resume retains its pending runtime policy");
9691 binding.generation = event.generation;
9692 binding.runtime_policy = runtime_policy;
9693 binding.pending_resume = None;
9694 }
9695 (
9696 SessionAddress {
9697 workspace_id: binding.workspace_id.clone(),
9698 session: SessionKey {
9699 instance_id: event.instance_id,
9700 generation: event.generation,
9701 },
9702 },
9703 (previous_address.session.generation != event.generation)
9704 .then_some(previous_address),
9705 binding.record_id.clone(),
9706 binding.runtime_policy.provider_session_identity,
9707 resume_command_rejected,
9708 )
9709 };
9710 if let Some(previous_address) = previous_address.as_ref() {
9711 self.clear_terminal_frame_watermark(previous_address);
9712 }
9713 if let Some(record_id) = record_id {
9714 self.reconcile_managed_record(
9715 &record_id,
9716 &address,
9717 &event,
9718 identity_admitted,
9719 resume_command_rejected,
9720 );
9721 }
9722 match &event.event {
9730 ControlEventKind::Running { process_id } => {
9731 tracing::info!(
9732 node_id = %self.node_id,
9733 workspace_id = %address.workspace_id,
9734 session = ?address.session,
9735 pid = ?process_id,
9736 "session process is running",
9737 );
9738 }
9739 ControlEventKind::Exited { exit_code, forced } => {
9740 tracing::info!(
9741 node_id = %self.node_id,
9742 workspace_id = %address.workspace_id,
9743 session = ?address.session,
9744 exit_code = ?exit_code,
9745 forced = forced,
9746 "session process exited",
9747 );
9748 }
9749 ControlEventKind::Failed { message } => {
9750 tracing::warn!(
9751 node_id = %self.node_id,
9752 workspace_id = %address.workspace_id,
9753 session = ?address.session,
9754 cause = %message,
9755 "session failed",
9756 );
9757 }
9758 ControlEventKind::CommandRejected { message } => {
9759 tracing::warn!(
9760 node_id = %self.node_id,
9761 workspace_id = %address.workspace_id,
9762 session = ?address.session,
9763 cause = %message,
9764 "session command rejected",
9765 );
9766 }
9767 ControlEventKind::Removed => {
9768 tracing::info!(
9769 node_id = %self.node_id,
9770 workspace_id = %address.workspace_id,
9771 session = ?address.session,
9772 "session removed",
9773 );
9774 }
9775 ControlEventKind::ResumeAuthorized { session: provider_session } => {
9776 tracing::info!(
9777 node_id = %self.node_id,
9778 workspace_id = %address.workspace_id,
9779 session = ?address.session,
9780 provider_session = ?provider_session,
9781 "session resume authorized",
9782 );
9783 }
9784 ControlEventKind::Resumed { session: provider_session, process_id } => {
9785 tracing::info!(
9786 node_id = %self.node_id,
9787 workspace_id = %address.workspace_id,
9788 session = ?address.session,
9789 provider_session = ?provider_session,
9790 pid = ?process_id,
9791 "session resumed",
9792 );
9793 }
9794 ControlEventKind::ResumeDenied { reason } => {
9795 tracing::warn!(
9796 node_id = %self.node_id,
9797 workspace_id = %address.workspace_id,
9798 session = ?address.session,
9799 cause = %reason,
9800 "session resume denied",
9801 );
9802 }
9803 ControlEventKind::ResumeFailed { message } => {
9804 tracing::warn!(
9805 node_id = %self.node_id,
9806 workspace_id = %address.workspace_id,
9807 session = ?address.session,
9808 cause = %message,
9809 "session resume failed",
9810 );
9811 }
9812 _ => {}
9813 }
9814 let stream_chunk = agent_stream_chunk(&event);
9815 self.publish(NodeEvent::Control {
9816 address: address.clone(),
9817 event,
9818 });
9819 if let Some(chunk) = stream_chunk {
9820 self.publish_agent_stream_chunk(address.clone(), chunk);
9821 }
9822 if revokes_harness_mcp {
9823 if let Some(registry) = self.harness_mcp_registry.as_ref() {
9824 registry.revoke_session(&address);
9825 }
9826 if let Some(control) = self.native_launch_profile_control.as_ref() {
9827 control.clear_native_mcp_server_launch_overlay(
9828 address.session.instance_id,
9829 );
9830 }
9831 }
9832 }
9833
9834 fn reconcile_managed_record(
9835 &self,
9836 record_id: &SessionRecordId,
9837 address: &SessionAddress,
9838 event: &ControlEvent,
9839 identity_admitted: bool,
9840 resume_command_rejected: bool,
9841 ) {
9842 let _transaction = self
9843 .state_transaction
9844 .lock()
9845 .unwrap_or_else(|poisoned| poisoned.into_inner());
9846 let observed_identity = match &event.event {
9847 ControlEventKind::ProviderEvent {
9848 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
9849 ..
9850 } if identity_admitted => Some(identity.clone()),
9851 _ => None,
9852 };
9853 let identity_was_observed = observed_identity.is_some();
9854 let replacement_id = observed_identity
9855 .as_ref()
9856 .and_then(|_| self.allocate_record_id().ok());
9857 let mut upserts = Vec::new();
9858 let mut removals = Vec::new();
9859 let mut rebind = None;
9860 let original_records;
9861 let original_materializations = self
9862 .materializations
9863 .lock()
9864 .unwrap_or_else(|poisoned| poisoned.into_inner())
9865 .clone();
9866 {
9867 let mut records = self
9868 .session_records
9869 .lock()
9870 .unwrap_or_else(|poisoned| poisoned.into_inner());
9871 original_records = records.records.clone();
9872 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
9873 return;
9874 };
9875 if let Some(identity) = observed_identity {
9876 let existing_id = records
9877 .records
9878 .values()
9879 .find(|record| {
9880 record.record_id != *record_id
9881 && record.provider_session.as_ref().is_some_and(|record_identity| {
9882 session_registry::same_provider_session(
9883 &record.provider,
9884 record_identity,
9885 ¤t_snapshot.provider,
9886 &identity,
9887 )
9888 })
9889 })
9890 .map(|record| record.record_id.clone());
9891 if let Some(existing_id) = existing_id {
9892 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
9893 record.mode == current_snapshot.mode
9894 && record.workspace_id == current_snapshot.workspace_id
9895 && record.environment_profile == current_snapshot.environment_profile
9896 && record.bundle == current_snapshot.bundle
9897 && record.context_id == current_snapshot.context_id
9898 && record.context == current_snapshot.context
9899 && record
9900 .canonical_root
9901 .as_utf8()
9902 .zip(current_snapshot.canonical_root.as_utf8())
9903 .is_some_and(|(left, right)| platform::roots_equal(left, right))
9904 });
9905 if !scope_matches {
9906 if let Some(current) = records.records.get_mut(record_id) {
9907 current.state = ManagedSessionState::Unavailable;
9908 current.last_error =
9909 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
9910 current.updated_at_unix_ms = unix_time_ms();
9911 upserts.push(current.clone());
9912 }
9913 } else {
9914 let existing_is_active = records
9915 .records
9916 .get(&existing_id)
9917 .is_some_and(|record| record.active_session.is_some());
9918 if existing_is_active {
9919 if let Some(current) = records.records.get_mut(record_id) {
9920 current.state = ManagedSessionState::Unavailable;
9921 current.last_error =
9922 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
9923 current.updated_at_unix_ms = unix_time_ms();
9924 upserts.push(current.clone());
9925 }
9926 } else {
9927 if current_snapshot.provider_session.is_some() {
9928 if let Some(current) = records.records.get_mut(record_id) {
9929 current.active_session = None;
9930 current.state = ManagedSessionState::Dormant;
9931 current.updated_at_unix_ms = unix_time_ms();
9932 upserts.push(current.clone());
9933 }
9934 } else {
9935 records.records.remove(record_id);
9936 removals.push(record_id.clone());
9937 }
9938 if let Some(existing) = records.records.get_mut(&existing_id) {
9939 existing.provider_session = Some(identity.clone());
9940 existing.active_session = Some(address.clone());
9941 existing.state = ManagedSessionState::Live;
9942 existing.updated_at_unix_ms = unix_time_ms();
9943 existing.last_error = None;
9944 upserts.push(existing.clone());
9945 }
9946 rebind = Some(existing_id);
9947 }
9948 }
9949 } else if current_snapshot
9950 .provider_session
9951 .as_ref()
9952 .is_some_and(|current| {
9953 !session_registry::same_provider_session(
9954 ¤t_snapshot.provider,
9955 current,
9956 ¤t_snapshot.provider,
9957 &identity,
9958 )
9959 })
9960 {
9961 if let Some(current) = records.records.get_mut(record_id) {
9962 current.active_session = None;
9963 if current.bundle.is_some() || current.context.is_some() {
9964 let had_bundle = current.bundle.is_some();
9965 current.provider_session = None;
9966 current.bundle = None;
9967 current.context_id = None;
9968 current.context = None;
9969 current.state = ManagedSessionState::Unavailable;
9970 current.last_error = Some(if had_bundle {
9971 "bundle-unavailable".to_owned()
9972 } else {
9973 "context-unavailable".to_owned()
9974 });
9975 } else {
9976 current.state = ManagedSessionState::Dormant;
9977 }
9978 current.updated_at_unix_ms = unix_time_ms();
9979 upserts.push(current.clone());
9980 }
9981 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
9982 if let Some(current) = records.records.get_mut(record_id) {
9983 current.state = ManagedSessionState::Unavailable;
9984 current.last_error =
9985 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
9986 current.updated_at_unix_ms = unix_time_ms();
9987 upserts.push(current.clone());
9988 }
9989 } else if let Some(new_record_id) = replacement_id {
9990 let now = unix_time_ms();
9991 let replacement = ManagedSessionRecord {
9992 record_id: new_record_id.clone(),
9993 display_name: format!(
9994 "{} #{}",
9995 current_snapshot.provider.as_str(),
9996 records
9997 .records
9998 .values()
9999 .filter(|record| {
10000 record.provider == current_snapshot.provider
10001 })
10002 .count()
10003 .saturating_add(1),
10004 ),
10005 provider: current_snapshot.provider.clone(),
10006 mode: current_snapshot.mode,
10007 state: ManagedSessionState::Live,
10008 workspace_id: current_snapshot.workspace_id,
10009 canonical_root: current_snapshot.canonical_root,
10010 provider_session: Some(identity),
10011 active_session: Some(address.clone()),
10012 environment_profile: current_snapshot.environment_profile,
10013 bundle: current_snapshot.bundle,
10014 context_id: current_snapshot.context_id,
10015 context: current_snapshot.context,
10016 exported_context: None,
10022 task_binding: current_snapshot.task_binding,
10023 created_at_unix_ms: now,
10024 updated_at_unix_ms: now,
10025 last_error: None,
10026 };
10027 records
10028 .records
10029 .insert(new_record_id.clone(), replacement.clone());
10030 upserts.push(replacement);
10031 rebind = Some(new_record_id);
10032 } else if let Some(current) = records.records.get_mut(record_id) {
10033 current.state = ManagedSessionState::Unavailable;
10034 current.last_error =
10035 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10036 upserts.push(current.clone());
10037 }
10038 } else if let Some(current) = records.records.get_mut(record_id) {
10039 current.provider_session = Some(identity);
10040 current.active_session = Some(address.clone());
10041 current.state = ManagedSessionState::Live;
10042 current.updated_at_unix_ms = unix_time_ms();
10043 current.last_error = None;
10044 upserts.push(current.clone());
10045 }
10046 } else if let Some(current) = records.records.get_mut(record_id) {
10047 if resume_command_rejected {
10048 current.active_session = None;
10049 current.state = detached_record_state(current);
10050 current.updated_at_unix_ms = unix_time_ms();
10051 current.last_error =
10052 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10053 upserts.push(current.clone());
10054 } else {
10055 match &event.event {
10056 ControlEventKind::Running { .. }
10057 | ControlEventKind::ResumeAuthorized { .. }
10058 | ControlEventKind::Resumed { .. } => {
10059 current.active_session = Some(address.clone());
10060 current.state = active_record_state(
10061 identity_admitted,
10062 current.provider_session.is_some(),
10063 );
10064 current.updated_at_unix_ms = unix_time_ms();
10065 current.last_error = None;
10066 upserts.push(current.clone());
10067 }
10068 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10069 current.active_session = None;
10070 current.state = detached_record_state(current);
10071 current.updated_at_unix_ms = unix_time_ms();
10072 upserts.push(current.clone());
10073 }
10074 ControlEventKind::Failed { message }
10075 | ControlEventKind::ResumeFailed { message } => {
10076 current.active_session = None;
10077 current.state = detached_record_state(current);
10078 current.updated_at_unix_ms = unix_time_ms();
10079 current.last_error = Some(
10080 session_registry::sanitized_record_error_summary(message),
10081 );
10082 upserts.push(current.clone());
10083 }
10084 ControlEventKind::ResumeDenied { reason } => {
10085 current.active_session = None;
10086 current.state = detached_record_state(current);
10087 current.updated_at_unix_ms = unix_time_ms();
10088 current.last_error = Some(
10089 session_registry::sanitized_record_error_summary(reason),
10090 );
10091 upserts.push(current.clone());
10092 }
10093 _ => {}
10094 }
10095 }
10096 }
10097 }
10098 let materialization_owner_record = identity_was_observed.then(|| {
10099 rebind.clone().unwrap_or_else(|| record_id.clone())
10100 }).and_then(|candidate| {
10101 self.session_records
10102 .lock()
10103 .unwrap_or_else(|poisoned| poisoned.into_inner())
10104 .records
10105 .get(&candidate)
10106 .filter(|record| {
10107 record.provider_session.is_some()
10108 && record.active_session.as_ref() == Some(address)
10109 })
10110 .map(|_| candidate)
10111 });
10112 if let Some(owner_record_id) = materialization_owner_record {
10113 let materialization_id = self
10114 .session_bindings
10115 .lock()
10116 .unwrap_or_else(|poisoned| poisoned.into_inner())
10117 .get(&address.session.instance_id)
10118 .and_then(|binding| binding.materialization_id.clone());
10119 if let Some(materialization_id) = materialization_id {
10120 let mut transfer_failed = false;
10121 {
10122 let mut materializations = self
10123 .materializations
10124 .lock()
10125 .unwrap_or_else(|poisoned| poisoned.into_inner());
10126 if let Some(ownership) = materializations.get_mut(&materialization_id) {
10127 match ownership.owner() {
10128 MaterializationOwner::Session {
10129 incarnation_id,
10130 instance_id,
10131 generation,
10132 } if *incarnation_id == self.incarnation_id
10133 && *instance_id == address.session.instance_id
10134 && *generation == address.session.generation =>
10135 {
10136 transfer_failed = ownership
10137 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10138 .is_err();
10139 }
10140 MaterializationOwner::Record { record_id }
10141 if record_id == &owner_record_id => {}
10142 MaterializationOwner::Record { record_id } => {
10143 let expected = record_id.clone();
10144 transfer_failed = ownership
10145 .transfer_record_owner(
10146 &expected,
10147 owner_record_id.clone(),
10148 unix_time_ms(),
10149 )
10150 .is_err();
10151 }
10152 _ => transfer_failed = true,
10153 }
10154 if transfer_failed {
10155 let _ = ownership.mark_recovery_required(unix_time_ms());
10156 }
10157 } else {
10158 transfer_failed = true;
10159 }
10160 }
10161 if transfer_failed {
10162 if let Some(record) = self
10163 .session_records
10164 .lock()
10165 .unwrap_or_else(|poisoned| poisoned.into_inner())
10166 .records
10167 .get_mut(&owner_record_id)
10168 {
10169 record.state = ManagedSessionState::Unavailable;
10170 record.last_error = Some("environment-profile-unavailable".to_owned());
10171 record.updated_at_unix_ms = unix_time_ms();
10172 upserts.push(record.clone());
10173 }
10174 }
10175 }
10176 }
10177 if let Some(new_record_id) = rebind.clone() {
10178 if let Some(binding) = self
10179 .session_bindings
10180 .lock()
10181 .unwrap_or_else(|poisoned| poisoned.into_inner())
10182 .get_mut(&address.session.instance_id)
10183 {
10184 binding.record_id = Some(new_record_id);
10185 }
10186 }
10187 if upserts.is_empty() && removals.is_empty() {
10188 return;
10189 }
10190 if self.persist_state_locked().is_err() {
10191 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10192 let mut records = self
10193 .session_records
10194 .lock()
10195 .unwrap_or_else(|poisoned| poisoned.into_inner());
10196 records.records = original_records;
10197 *self
10198 .materializations
10199 .lock()
10200 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10201 if let Some(current) = records.records.get_mut(record_id) {
10202 current.state = ManagedSessionState::Unavailable;
10203 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10204 current.updated_at_unix_ms = unix_time_ms();
10205 upserts = vec![current.clone()];
10206 } else {
10207 upserts.clear();
10208 }
10209 removals.clear();
10210 drop(records);
10211 if let Some(binding) = self
10212 .session_bindings
10213 .lock()
10214 .unwrap_or_else(|poisoned| poisoned.into_inner())
10215 .get_mut(&address.session.instance_id)
10216 {
10217 binding.record_id = Some(record_id.clone());
10218 }
10219 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10220 }
10221 for record in upserts {
10222 self.publish_record(record);
10223 }
10224 for record_id in removals {
10225 self.publish(NodeEvent::SessionRecordRemoved { record_id });
10226 }
10227 }
10228
10229 fn snapshot(&self) -> NodeSnapshot {
10230 let control = self.handle.snapshot();
10231 let bindings = self
10232 .session_bindings
10233 .lock()
10234 .unwrap_or_else(|poisoned| poisoned.into_inner())
10235 .clone();
10236 let workspace_roots = self
10237 .workspaces
10238 .read()
10239 .unwrap_or_else(|poisoned| poisoned.into_inner())
10240 .clone();
10241 let environment_profiles = self
10242 .environment_profiles
10243 .read()
10244 .unwrap_or_else(|poisoned| poisoned.into_inner());
10245 let mut workspaces = workspace_roots
10246 .iter()
10247 .map(|(workspace_id, canonical_root)| {
10248 (
10249 workspace_id.clone(),
10250 WorkspaceSnapshot {
10251 workspace_id: workspace_id.clone(),
10252 canonical_root: opaque_windows_path(canonical_root.clone()),
10253 sessions: Vec::new(),
10254 worktree_service_mode: Some(
10255 self.worktree_service_modes
10256 .get(workspace_id)
10257 .copied()
10258 .unwrap_or(WorktreeServiceMode::Manual),
10259 ),
10260 managed_worktree_profiles: Some(WorktreeProfileInventory {
10261 profiles: self
10262 .managed_worktree_profiles
10263 .get(workspace_id)
10264 .into_iter()
10265 .flat_map(BTreeMap::values)
10266 .map(|profile| ManagedWorktreeProfileSummary {
10267 id: profile.profile_id().clone(),
10268 revision: profile.revision().clone(),
10269 retention: profile.retention(),
10270 })
10271 .collect(),
10272 }),
10273 },
10274 )
10275 })
10276 .collect::<BTreeMap<_, _>>();
10277 let mut agent_progress = Vec::new();
10278 for session in &control.sessions {
10279 let Some(binding) = bindings.get(&session.instance_id) else {
10280 continue;
10281 };
10282 if binding.generation != session.generation {
10283 continue;
10284 }
10285 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10286 let address = SessionAddress {
10287 workspace_id: binding.workspace_id.clone(),
10288 session: SessionKey {
10289 instance_id: session.instance_id,
10290 generation: session.generation,
10291 },
10292 };
10293 if let Some(progress) =
10294 agent_progress_from_provider_snapshot(address, &session.provider)
10295 {
10296 agent_progress.push(progress);
10297 }
10298 workspace.sessions.push(session.clone());
10299 }
10300 }
10301 NodeSnapshot {
10302 node_id: self.node_id.clone(),
10303 enabled_providers: self.enabled_providers.clone(),
10304 provider_runtime_statuses: {
10305 let updates = self
10306 .provider_runtime_status_updates
10307 .read()
10308 .unwrap_or_else(|poisoned| poisoned.into_inner());
10309 ProviderRuntimeStatuses::new(
10310 self.provider_runtime_statuses
10311 .iter()
10312 .filter(|status| !updates.contains_key(status.provider()))
10313 .cloned()
10314 .chain(updates.values().cloned()),
10315 )
10316 .expect("refreshed provider runtime inventory remains bounded and unique")
10317 },
10318 workspaces: workspaces.into_values().collect(),
10319 session_records: self
10320 .session_records
10321 .lock()
10322 .unwrap_or_else(|poisoned| poisoned.into_inner())
10323 .records
10324 .values()
10325 .cloned()
10326 .collect(),
10327 managed_worktrees: self.managed_worktrees
10328 .lock()
10329 .unwrap_or_else(|poisoned| poisoned.into_inner())
10330 .snapshots(),
10331 launch_inventory: Some(LaunchInventory {
10332 spawn_profiles: Some(
10333 self.spawn_profiles
10334 .iter()
10335 .filter_map(|profile| {
10336 resolved_spawn_profile_summary(profile, &environment_profiles)
10337 })
10338 .collect(),
10339 ),
10340 bundles: Some(
10341 self.bundle_catalog
10342 .read()
10343 .unwrap_or_else(|poisoned| poisoned.into_inner())
10344 .iter()
10345 .map(NodeBundle::receipt)
10346 .collect(),
10347 ),
10348 network_allowlists: {
10351 let catalog = self
10352 .network_allowlist_catalog
10353 .read()
10354 .unwrap_or_else(|poisoned| poisoned.into_inner());
10355 if catalog.is_empty() {
10356 None
10357 } else {
10358 Some(catalog.ids().cloned().collect())
10359 }
10360 },
10361 }),
10362 agent_progress,
10363 }
10364 }
10365
10366 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
10367 assert!(
10368 !matches!(
10369 &event,
10370 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
10371 ),
10372 "terminal frame and agent stream events must use their own dedicated live channel",
10373 );
10374 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10375 let sequence = history
10376 .last_sequence
10377 .checked_add(1)
10378 .expect("node event sequence exhausted");
10379 if history.events.len() == NODE_EVENT_HISTORY_MAX {
10380 if let Some(evicted) = history.events.pop_front() {
10381 history.replay_floor_sequence = evicted
10382 .sequence
10383 .checked_add(1)
10384 .expect("node replay floor sequence exhausted");
10385 history.removed_record_providers.remove(&evicted.sequence);
10386 }
10387 }
10388 match &event {
10389 NodeEvent::HarnessMcpReadCall { .. } => {
10390 panic!("transient harness MCP calls must not enter durable event history")
10391 }
10392 NodeEvent::SessionRecordUpserted { record } => {
10393 history
10394 .record_providers
10395 .insert(record.record_id.clone(), record.provider.clone());
10396 }
10397 NodeEvent::SessionRecordRemoved { record_id } => {
10398 if let Some(provider) = history.record_providers.remove(record_id) {
10399 history.removed_record_providers.insert(sequence, provider);
10400 }
10401 }
10402 NodeEvent::ManagedWorktreeUpserted { .. }
10403 | NodeEvent::ManagedWorktreeRemoved { .. }
10404 | NodeEvent::Control { .. }
10405 | NodeEvent::ControllerChanged { .. }
10406 | NodeEvent::SessionRecordHistorySummarized { .. }
10407 | NodeEvent::WorkspaceAdded { .. }
10408 | NodeEvent::WorkspaceRemoved { .. }
10409 | NodeEvent::ResyncRequired { .. }
10410 | NodeEvent::AgentStream { .. }
10411 | NodeEvent::TerminalFrame { .. } => {}
10412 }
10413 let envelope = NodeEventEnvelope { sequence, event };
10414 history.events.push_back(envelope.clone());
10415 history.last_sequence = sequence;
10416 let _ = self.event_tx.send(envelope.clone());
10417 drop(history);
10418 envelope
10419 }
10420
10421 fn publish_transient(&self, event: NodeEvent) -> bool {
10432 assert!(event.requires_harness_mcp_proxy_capability());
10433 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
10434 let envelope = NodeEventEnvelope { sequence: 0, event };
10435 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
10436 Ok(_) => true,
10437 Err(broadcast::error::SendError(undelivered)) => {
10438 if let Some(NodeEventEnvelope {
10439 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
10440 ..
10441 }) = undelivered.first() {
10442 tracing::warn!(
10448 call_id = call_id.as_str(),
10449 content_type = ?request.content_type,
10450 cause = "no-subscribers",
10451 "harness MCP proxy event could not be published",
10452 );
10453 }
10454 false
10455 }
10456 }
10457 }
10458
10459 fn publish_terminal_frames(&self) {
10460 let snapshot = self.handle.snapshot();
10461 let bindings = self
10462 .session_bindings
10463 .lock()
10464 .unwrap_or_else(|poisoned| poisoned.into_inner());
10465 let candidates = snapshot
10466 .sessions
10467 .iter()
10468 .filter_map(|session| {
10469 let binding = bindings.get(&session.instance_id)?;
10470 if binding.generation != session.generation {
10471 return None;
10472 }
10473 let frame = session.terminal_frame.clone()?;
10474 Some((
10475 SessionAddress {
10476 workspace_id: binding.workspace_id.clone(),
10477 session: SessionKey {
10478 instance_id: session.instance_id,
10479 generation: session.generation,
10480 },
10481 },
10482 frame,
10483 ))
10484 })
10485 .collect();
10486 drop(bindings);
10487 self.publish_terminal_frame_candidates(candidates);
10488 }
10489
10490 fn publish_terminal_frame_candidates(
10491 &self,
10492 candidates: Vec<(SessionAddress, TerminalFrame)>,
10493 ) {
10494 let mut watermarks = self
10495 .terminal_frame_watermarks
10496 .lock()
10497 .unwrap_or_else(|poisoned| poisoned.into_inner());
10498 let mut events = Vec::with_capacity(candidates.len());
10499 for (address, frame) in candidates {
10500 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
10501 let advanced = watermarks
10502 .get(&address.session.instance_id)
10503 .map_or(true, |(current_address, sequence)| {
10504 current_address != &address || frame.sequence > *sequence
10505 });
10506 if !advanced {
10507 continue;
10508 }
10509 if is_first_frame {
10510 if self.controller_state().is_none() {
10518 tracing::warn!(
10519 node_id = %self.node_id,
10520 workspace_id = %address.workspace_id,
10521 session = ?address.session,
10522 terminal_size = ?frame.size,
10523 "session producing output with no controller attached",
10524 );
10525 } else {
10526 tracing::info!(
10527 node_id = %self.node_id,
10528 workspace_id = %address.workspace_id,
10529 session = ?address.session,
10530 terminal_size = ?frame.size,
10531 "session produced its first output",
10532 );
10533 }
10534 }
10535 watermarks.insert(
10536 address.session.instance_id,
10537 (address.clone(), frame.sequence),
10538 );
10539 events.push(NodeEvent::TerminalFrame { address, frame });
10540 }
10541 drop(watermarks);
10542 if events.is_empty() {
10543 return;
10544 }
10545 let mut history = self
10546 .history
10547 .lock()
10548 .unwrap_or_else(|poisoned| poisoned.into_inner());
10549 let mut envelopes = Vec::with_capacity(events.len());
10550 for event in events {
10551 let sequence = history
10552 .last_sequence
10553 .checked_add(1)
10554 .expect("node event sequence exhausted");
10555 history.last_sequence = sequence;
10556 envelopes.push(NodeEventEnvelope { sequence, event });
10557 }
10558 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
10559 drop(history);
10560 }
10561
10562 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
10571 let mut history = self
10572 .history
10573 .lock()
10574 .unwrap_or_else(|poisoned| poisoned.into_inner());
10575 let sequence = history
10576 .last_sequence
10577 .checked_add(1)
10578 .expect("node event sequence exhausted");
10579 history.last_sequence = sequence;
10580 drop(history);
10581 tracing::debug!(
10589 source_sequence = chunk.source_sequence,
10590 kind = agent_stream_chunk_kind_label(&chunk.kind),
10591 envelope_sequence = sequence,
10592 "publishing agent stream chunk",
10593 );
10594 let envelope = NodeEventEnvelope {
10595 sequence,
10596 event: NodeEvent::AgentStream { address, chunk },
10597 };
10598 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
10599 }
10600
10601 fn current_sequence(&self) -> u64 {
10602 self.history
10603 .lock()
10604 .unwrap_or_else(|poisoned| poisoned.into_inner())
10605 .last_sequence
10606 }
10607
10608 fn controller_state(&self) -> Option<ControllerState> {
10609 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10610 let lease = controller.as_ref().copied()?;
10611 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
10612 if remaining.is_zero() {
10613 *controller = None;
10614 return None;
10615 }
10616 Some(ControllerState {
10617 connection_id: lease.connection_id,
10618 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
10619 })
10620 }
10621
10622 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
10623 if role != ClientRole::Operator {
10624 tracing::warn!(
10625 node_id = %self.node_id,
10626 connection_id,
10627 "controller lease rejected: observer connections cannot acquire control",
10628 );
10629 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
10630 }
10631 let lease_ms = if lease_ms == 0 {
10632 DEFAULT_CONTROLLER_LEASE_MS
10633 } else {
10634 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
10635 };
10636 let now = Instant::now();
10637 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10638 if let Some(active) = controller.as_ref() {
10639 if active.connection_id != connection_id && active.expires_at > now {
10640 tracing::warn!(
10641 node_id = %self.node_id,
10642 connection_id,
10643 holder_connection_id = active.connection_id,
10644 "controller lease rejected: another operator holds it",
10645 );
10646 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
10647 }
10648 }
10649 let is_new_attach = match controller.as_ref() {
10656 Some(active) => active.connection_id != connection_id,
10657 None => true,
10658 };
10659 *controller = Some(ControllerLease {
10660 connection_id,
10661 expires_at: now + Duration::from_millis(lease_ms),
10662 });
10663 drop(controller);
10664 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
10665 if is_new_attach {
10666 tracing::info!(
10667 node_id = %self.node_id,
10668 connection_id,
10669 lease_ms,
10670 "controller lease acquired",
10671 );
10672 } else {
10673 tracing::debug!(
10674 node_id = %self.node_id,
10675 connection_id,
10676 lease_ms,
10677 "controller lease renewed",
10678 );
10679 }
10680 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
10681 Ok(state)
10682 }
10683
10684 fn release_controller(&self, connection_id: u64) -> bool {
10685 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10686 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
10687 if released {
10688 *controller = None;
10689 }
10690 drop(controller);
10691 if released {
10692 tracing::info!(
10693 node_id = %self.node_id,
10694 connection_id,
10695 "controller lease released",
10696 );
10697 self.publish(NodeEvent::ControllerChanged { controller: None });
10698 }
10699 released
10700 }
10701
10702 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
10703 if role != ClientRole::Operator {
10704 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
10705 }
10706 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10707 let now = Instant::now();
10708 match controller.as_ref().copied() {
10709 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
10710 Some(active) if active.expires_at <= now => {
10711 *controller = None;
10712 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
10713 }
10714 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
10715 }
10716 }
10717
10718 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
10719 if !self
10720 .workspaces
10721 .read()
10722 .unwrap_or_else(|poisoned| poisoned.into_inner())
10723 .contains_key(&address.workspace_id)
10724 {
10725 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10726 }
10727 let binding = self
10728 .session_bindings
10729 .lock()
10730 .unwrap_or_else(|poisoned| poisoned.into_inner())
10731 .get(&address.session.instance_id)
10732 .cloned()
10733 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10734 if binding.workspace_id != address.workspace_id {
10735 return Err(failure(
10736 NodeFailureCode::SessionWorkspaceMismatch,
10737 "session belongs to another workspace",
10738 ));
10739 }
10740 if binding.generation != address.session.generation {
10741 return Err(failure(
10742 NodeFailureCode::StaleGeneration,
10743 &format!(
10744 "stale session generation: expected {}, received {}",
10745 binding.generation.0,
10746 address.session.generation.0,
10747 ),
10748 ));
10749 }
10750 let snapshot = self.handle.snapshot();
10751 let Some(current) = snapshot
10752 .sessions
10753 .iter()
10754 .find(|item| item.instance_id == address.session.instance_id)
10755 else {
10756 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
10757 };
10758 if current.generation != address.session.generation {
10759 return Err(failure(
10760 NodeFailureCode::StaleGeneration,
10761 &format!(
10762 "stale session generation: expected {}, received {}",
10763 current.generation.0,
10764 address.session.generation.0,
10765 ),
10766 ));
10767 }
10768 Ok(current.agent_id.clone())
10769 }
10770
10771 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
10772 self.dispatch_envelope(self.prepare_command(command))
10773 }
10774
10775 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
10776 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
10777 CommandEnvelope {
10778 id: CommandId(id),
10779 command,
10780 }
10781 }
10782
10783 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
10784 let command_id = envelope.id;
10785 self.handle
10786 .dispatch(envelope)
10787 .map_err(|error| match error {
10788 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
10789 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
10790 })?;
10791 Ok(command_id)
10792 }
10793
10794 fn record_rejected_command(
10803 &self,
10804 command_id: CommandId,
10805 message: String,
10806 unsupported_transport: bool,
10807 ) {
10808 let now = Instant::now();
10809 let mut reasons = self.rejected_command_reasons.lock()
10810 .unwrap_or_else(|poisoned| poisoned.into_inner());
10811 reasons.retain(|_, reason| reason.expires_at > now);
10812 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
10813 if let Some(&oldest) = reasons.keys().next() {
10814 reasons.remove(&oldest);
10815 }
10816 }
10817 reasons.insert(
10818 command_id,
10819 RejectedCommandReason {
10820 message,
10821 unsupported_transport,
10822 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
10823 },
10824 );
10825 }
10826
10827 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
10834 let now = Instant::now();
10835 let mut reasons = self.rejected_command_reasons.lock()
10836 .unwrap_or_else(|poisoned| poisoned.into_inner());
10837 match reasons.remove(&command_id) {
10838 Some(reason) if reason.expires_at > now => Some(reason),
10839 _ => None,
10840 }
10841 }
10842
10843 async fn dispatch_bounded(
10844 &self,
10845 command: ControlCommand,
10846 timeout_duration: Duration,
10847 ) -> Result<CommandId, NodeFailure> {
10848 let deadline = Instant::now() + timeout_duration;
10849 loop {
10850 match self.dispatch(command.clone()) {
10851 Ok(command_id) => return Ok(command_id),
10852 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
10853 sleep(Duration::from_millis(2)).await;
10854 }
10855 Err(error) => return Err(error),
10856 }
10857 }
10858 }
10859
10860 fn internal_session_snapshot(
10861 &self,
10862 address: &SessionAddress,
10863 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
10864 self.validate_address(address)?;
10865 self.handle
10866 .snapshot()
10867 .sessions
10868 .clone()
10869 .into_iter()
10870 .find(|session| {
10871 session.instance_id == address.session.instance_id
10872 && session.generation == address.session.generation
10873 })
10874 .ok_or_else(|| {
10875 failure(
10876 NodeFailureCode::UnknownSession,
10877 "session snapshot is unavailable",
10878 )
10879 })
10880 }
10881
10882 fn require_acp_session(
10890 &self,
10891 address: &SessionAddress,
10892 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
10893 let snapshot = self.internal_session_snapshot(address)?;
10894 if snapshot.transport != TransportKind::Acp {
10895 return Err(failure(
10896 NodeFailureCode::UnsupportedTransport,
10897 "this verb requires a session spawned over the ACP transport",
10898 ));
10899 }
10900 Ok(snapshot)
10901 }
10902
10903 async fn dispatch_history_bounded(
10904 &self,
10905 address: &SessionAddress,
10906 command: ControlCommand,
10907 operation: HistoryOperation,
10908 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
10909 self.validate_address(address)?;
10910 let started_after = self.current_sequence();
10911 let command_id = self
10912 .dispatch_bounded(
10913 command,
10914 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
10915 )
10916 .await?;
10917 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
10918 let mut scan_after = started_after;
10919 let mut accepted_after = None;
10920 loop {
10921 let events = self
10922 .history
10923 .lock()
10924 .unwrap_or_else(|poisoned| poisoned.into_inner())
10925 .events
10926 .iter()
10927 .filter(|envelope| envelope.sequence > scan_after)
10928 .cloned()
10929 .collect::<Vec<_>>();
10930 for envelope in events {
10931 scan_after = scan_after.max(envelope.sequence);
10932 let NodeEvent::Control {
10933 address: event_address,
10934 event,
10935 } = envelope.event
10936 else {
10937 continue;
10938 };
10939 if event_address != *address {
10940 continue;
10941 }
10942 if event.command_id == Some(command_id) {
10943 match &event.event {
10944 ControlEventKind::CommandRejected { .. } => {
10945 return Err(failure(
10946 NodeFailureCode::InvalidRequest,
10947 "history request was rejected",
10948 ));
10949 }
10950 ControlEventKind::HistoryRequested {
10951 operation: requested,
10952 ..
10953 } if requested == &operation => {
10954 accepted_after = Some(envelope.sequence);
10955 continue;
10956 }
10957 _ => {}
10958 }
10959 }
10960 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
10961 match (&operation, &event.event) {
10962 (
10963 HistoryOperation::Discover { .. },
10964 ControlEventKind::HistoryDiscovered { .. },
10965 )
10966 | (
10967 HistoryOperation::Load { .. },
10968 ControlEventKind::HistoryLoaded { .. },
10969 ) => return self.internal_session_snapshot(address),
10970 (_, ControlEventKind::HistoryFailed { .. }) => {
10971 return Err(failure(
10972 NodeFailureCode::BackendOperationFailed,
10973 "history operation failed",
10974 ));
10975 }
10976 _ => {}
10977 }
10978 }
10979 }
10980 if Instant::now() >= deadline {
10981 return Err(failure(
10982 NodeFailureCode::BackendBusy,
10983 if accepted_after.is_some() {
10984 "history operation did not settle before the bounded deadline"
10985 } else {
10986 "history operation was not accepted before the bounded deadline"
10987 },
10988 ));
10989 }
10990 sleep(Duration::from_millis(2)).await;
10991 }
10992 }
10993
10994 async fn discover_history(
10995 &self,
10996 address: &SessionAddress,
10997 limit: u16,
10998 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
10999 let query = HistoryQuery {
11000 working_directory: Some(self.workspace_root(&address.workspace_id)?),
11001 limit,
11002 };
11003 query
11004 .validate()
11005 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11006 let snapshot = self
11007 .dispatch_history_bounded(
11008 address,
11009 ControlCommand::DiscoverHistory {
11010 instance_id: address.session.instance_id,
11011 query: query.clone(),
11012 },
11013 HistoryOperation::Discover { query },
11014 )
11015 .await?;
11016 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11017 return Err(failure(
11018 NodeFailureCode::BackendOperationFailed,
11019 "history discovery settled into an invalid state",
11020 ));
11021 }
11022 Ok(snapshot.history.candidates)
11023 }
11024
11025 async fn load_history(
11026 &self,
11027 address: &SessionAddress,
11028 candidate_id: String,
11029 ) -> Result<HistorySessionRecord, NodeFailure> {
11030 validate_candidate_id(&candidate_id)
11031 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11032 let snapshot = self
11033 .dispatch_history_bounded(
11034 address,
11035 ControlCommand::LoadHistory {
11036 instance_id: address.session.instance_id,
11037 candidate_id: candidate_id.clone(),
11038 },
11039 HistoryOperation::Load {
11040 candidate_id: candidate_id.clone(),
11041 },
11042 )
11043 .await?;
11044 if snapshot.history.pending.is_some()
11045 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11046 {
11047 return Err(failure(
11048 NodeFailureCode::BackendOperationFailed,
11049 "history load settled into an invalid state",
11050 ));
11051 }
11052 snapshot.history.loaded.ok_or_else(|| {
11053 failure(
11054 NodeFailureCode::BackendOperationFailed,
11055 "history load produced no bounded session",
11056 )
11057 })
11058 }
11059
11060 async fn export_context_pack_for_session_record(
11067 &self,
11068 record_id: &SessionRecordId,
11069 session: &SessionAddress,
11070 allow_clean_detachment: bool,
11071 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11072 let outcome = self
11078 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11079 .await;
11080 if let Err(failure) = &outcome {
11081 tracing::warn!(
11082 record = %record_id,
11083 code = ?failure.code,
11084 reason = %failure.message,
11085 "session-record context-pack export refused",
11086 );
11087 }
11088 outcome
11089 }
11090
11091 async fn export_context_pack_for_session_record_inner(
11092 &self,
11093 record_id: &SessionRecordId,
11094 session: &SessionAddress,
11095 allow_clean_detachment: bool,
11096 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11097 let step = |name: &'static str| move |failure: NodeFailure| {
11102 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11103 failure
11104 };
11105 let record = self.record(record_id).map_err(step("record-lookup"))?;
11106 let identity = match record.provider_session.clone() {
11122 Some(identity) => {
11123 if identity.key != ProviderSessionKey::SessionId {
11124 return Err(step("provider-identity-key")(failure(
11125 NodeFailureCode::BackendOperationFailed,
11126 "managed session provider identity cannot bind history exactly",
11127 )));
11128 }
11129 Some(identity)
11130 }
11131 None if record.state == ManagedSessionState::IdentityPending => {
11132 return Err(step("provider-session-identity")(failure(
11133 NodeFailureCode::BackendOperationFailed,
11134 "managed session has not yet observed its provider session identity",
11135 )));
11136 }
11137 None => None,
11138 };
11139 self.revalidate_session_record_context_export(
11140 &record,
11141 identity.as_ref(),
11142 session,
11143 allow_clean_detachment,
11144 ).map_err(step("revalidate-before-discovery"))?;
11145 let candidates = self
11146 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11147 .await
11148 .map_err(step("discover-history"))?;
11149 let candidate_id = select_history_candidate(
11154 &candidates,
11155 identity.as_ref().map(|identity| identity.id.as_str()),
11156 ).map_err(step("unique-candidate"))?;
11157 let loaded = self.load_history(session, candidate_id.clone()).await
11158 .map_err(step("load-history"))?;
11159 if let Some(identity) = identity.as_ref() {
11160 if loaded.session_id != identity.id {
11161 return Err(failure(
11162 NodeFailureCode::SessionRecordConflict,
11163 "loaded history identity does not match the managed session record",
11164 ));
11165 }
11166 }
11167 self.revalidate_session_record_context_export(
11168 &record,
11169 identity.as_ref(),
11170 session,
11171 allow_clean_detachment,
11172 )?;
11173 self.revalidate_loaded_history(session, &candidate_id, &loaded)
11174 .map_err(step("revalidate-loaded-history"))?;
11175 let pack = self.materialize_context_pack(session).await
11176 .map_err(step("materialize-context-pack"))?;
11177 self.commit_context_pack_for_session_record(
11178 &record,
11179 identity.as_ref(),
11180 session,
11181 &candidate_id,
11182 &loaded,
11183 pack,
11184 allow_clean_detachment,
11185 )
11186 }
11187
11188 async fn export_context_pack(
11189 &self,
11190 address: &SessionAddress,
11191 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11192 let pack = self.materialize_context_pack(address).await?;
11193 self.commit_context_pack(pack)
11194 }
11195
11196 async fn materialize_context_pack(
11197 &self,
11198 address: &SessionAddress,
11199 ) -> Result<NodeContextPack, NodeFailure> {
11200 let snapshot = self.internal_session_snapshot(address)?;
11201 if !context_pack_source_status_is_usable(&snapshot.status) {
11202 return Err(failure(
11203 NodeFailureCode::InvalidRequest,
11204 "context export requires a running or successfully exited source session",
11205 ));
11206 }
11207 if snapshot.history.pending.is_some()
11208 || snapshot.history.loaded_candidate_id.is_none()
11209 {
11210 return Err(failure(
11211 NodeFailureCode::InvalidRequest,
11212 "context export requires settled loaded history",
11213 ));
11214 }
11215 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11216 failure(
11217 NodeFailureCode::InvalidRequest,
11218 "context export requires settled loaded history",
11219 )
11220 })?;
11221 let source_provider = snapshot.agent_id.clone();
11222 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11223 let repository = self
11224 .context_pack_repository(&address.workspace_id)
11225 .await?;
11226 let current = self.internal_session_snapshot(address)?;
11227 if !context_pack_source_status_is_usable(¤t.status)
11228 || current.agent_id != source_provider
11229 || current.history.pending.is_some()
11230 || current.history.loaded_candidate_id != loaded_candidate_id
11231 || current.history.loaded.as_ref() != Some(&loaded)
11232 {
11233 return Err(failure(
11234 NodeFailureCode::ContextPackMaterializationFailed,
11235 "context source changed during bounded repository capture",
11236 ));
11237 }
11238 NodeContextPack::export_with_repository(
11239 ContextPackLineageReceipt {
11240 source_node_id: self.node_id.clone(),
11241 source_session: address.clone(),
11242 source_provider,
11243 },
11244 &loaded,
11245 Some(repository),
11246 )
11247 .map_err(|_| {
11248 failure(
11249 NodeFailureCode::ContextPackMaterializationFailed,
11250 "bounded context pack export failed",
11251 )
11252 })
11253 }
11254
11255 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
11261 let mut store = self
11262 .context_pack_store
11263 .lock()
11264 .unwrap_or_else(|poisoned| poisoned.into_inner());
11265 let Some(store) = store.as_mut() else {
11266 return Ok(());
11267 };
11268 store.commit(pack).map_err(|_| {
11269 failure(
11270 NodeFailureCode::ContextPackMaterializationFailed,
11271 "durable context pack commit failed",
11272 )
11273 })
11274 }
11275
11276 fn commit_context_pack(
11277 &self,
11278 pack: NodeContextPack,
11279 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11280 self.commit_context_pack_durably(&pack)?;
11281 self.context_catalog
11282 .write()
11283 .unwrap_or_else(|poisoned| poisoned.into_inner())
11284 .insert(pack)
11285 .map_err(|_| {
11286 failure(
11287 NodeFailureCode::ContextPackMaterializationFailed,
11288 "context pack catalog is full",
11289 )
11290 })
11291 }
11292
11293 fn commit_context_pack_for_session_record(
11294 &self,
11295 expected: &ManagedSessionRecord,
11296 identity: Option<&ProviderSessionIdentity>,
11297 session: &SessionAddress,
11298 candidate_id: &str,
11299 loaded: &HistorySessionRecord,
11300 pack: NodeContextPack,
11301 allow_clean_detachment: bool,
11302 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11303 let current_root = self.workspace_root(&expected.workspace_id)?;
11304 let _transaction = self
11305 .state_transaction
11306 .lock()
11307 .unwrap_or_else(|poisoned| poisoned.into_inner());
11308 let records = self
11309 .session_records
11310 .lock()
11311 .unwrap_or_else(|poisoned| poisoned.into_inner());
11312 let current = records.records.get(&expected.record_id).ok_or_else(|| {
11313 failure(
11314 NodeFailureCode::SessionRecordConflict,
11315 "managed session record disappeared during context export",
11316 )
11317 })?;
11318 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
11319 return Err(failure(
11320 NodeFailureCode::SessionRecordConflict,
11321 "managed session record changed during context export",
11322 ));
11323 }
11324 let bindings = self
11325 .session_bindings
11326 .lock()
11327 .unwrap_or_else(|poisoned| poisoned.into_inner());
11328 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
11329 failure(
11330 NodeFailureCode::SessionRecordConflict,
11331 "managed session runtime binding disappeared during context export",
11332 )
11333 })?;
11334 let runtime = self
11335 .handle
11336 .snapshot()
11337 .sessions
11338 .iter()
11339 .find(|snapshot| {
11340 snapshot.instance_id == session.session.instance_id
11341 && snapshot.generation == session.session.generation
11342 })
11343 .cloned()
11344 .ok_or_else(|| {
11345 failure(
11346 NodeFailureCode::SessionRecordConflict,
11347 "managed session runtime disappeared during context export",
11348 )
11349 })?;
11350 let receipt = pack.receipt();
11351 if !session_record_context_export_binding_is_exact(
11352 expected,
11353 current,
11354 identity,
11355 session,
11356 binding,
11357 &runtime.agent_id,
11358 ¤t_root,
11359 allow_clean_detachment,
11360 ) || !session_record_context_export_source_is_usable(
11361 current,
11362 &runtime.status,
11363 allow_clean_detachment,
11364 ) || runtime.history.pending.is_some()
11365 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
11366 || runtime.history.loaded.as_ref() != Some(loaded)
11367 || receipt.lineage.source_node_id != self.node_id
11368 || receipt.lineage.source_session != *session
11369 || receipt.lineage.source_provider != current.provider
11370 || receipt.source_message_count != loaded.message_count
11371 {
11372 return Err(failure(
11373 NodeFailureCode::SessionRecordConflict,
11374 "managed session source changed before context catalog commit",
11375 ));
11376 }
11377 self.commit_context_pack_durably(&pack)?;
11378 self.context_catalog
11379 .write()
11380 .unwrap_or_else(|poisoned| poisoned.into_inner())
11381 .insert(pack)
11382 .map_err(|_| {
11383 failure(
11384 NodeFailureCode::ContextPackMaterializationFailed,
11385 "context pack catalog is full",
11386 )
11387 })
11388 }
11389
11390 async fn context_pack_repository(
11391 &self,
11392 workspace_id: &WorkspaceId,
11393 ) -> Result<ContextPackRepository, NodeFailure> {
11394 let first = self
11395 .context_pack_repository_observation(workspace_id)
11396 .await?;
11397 let second = self
11398 .context_pack_repository_observation(workspace_id)
11399 .await?;
11400 stable_context_pack_repository(first, second)
11401 }
11402
11403 async fn context_pack_repository_observation(
11404 &self,
11405 workspace_id: &WorkspaceId,
11406 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
11407 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
11408 let head = if git.is_repository {
11409 let root = self.workspace_root(workspace_id)?;
11410 match resolve_base_commit_with_timeout(
11411 &root,
11412 "HEAD",
11413 GIT_COMMAND_TIMEOUT_MS,
11414 )
11415 .await
11416 {
11417 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
11418 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
11419 ContextPackRepositoryHead::Unborn
11420 }
11421 Err(_) => {
11422 return Err(failure(
11423 NodeFailureCode::ContextPackMaterializationFailed,
11424 "context pack HEAD observation failed",
11425 ));
11426 }
11427 }
11428 } else {
11429 ContextPackRepositoryHead::NotRepository
11430 };
11431 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
11432 for selected_path in CONTEXT_PACK_SELECTED_FILES {
11433 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
11434 failure(
11435 NodeFailureCode::InvalidRepositoryPath,
11436 "context pack selected file path is invalid",
11437 )
11438 })?;
11439 match self.read_workspace_file(workspace_id.clone(), path).await {
11440 Ok(WorkspaceFileRead {
11441 content: WorkspaceFileContent::Utf8 { text, byte_len },
11442 ..
11443 }) => files.push(ContextPackRepositoryFileSource::utf8(
11444 selected_path,
11445 text,
11446 byte_len,
11447 )),
11448 Ok(WorkspaceFileRead {
11449 content: WorkspaceFileContent::NonUtf8 { byte_len },
11450 ..
11451 }) => files.push(ContextPackRepositoryFileSource::skipped(
11452 selected_path,
11453 ContextPackSelectedFileSkipReason::NonUtf8,
11454 Some(byte_len),
11455 )),
11456 Ok(WorkspaceFileRead {
11457 content: WorkspaceFileContent::TooLarge { .. },
11458 ..
11459 }) => files.push(ContextPackRepositoryFileSource::skipped(
11460 selected_path,
11461 ContextPackSelectedFileSkipReason::TooLarge,
11462 None,
11463 )),
11464 Err(error) => {
11465 let reason = if matches!(
11466 error.code,
11467 NodeFailureCode::InvalidRepositoryPath
11468 | NodeFailureCode::RepositoryFileNotRegular
11469 | NodeFailureCode::RepositoryPathUnsafe
11470 ) {
11471 ContextPackSelectedFileSkipReason::Unsafe
11472 } else {
11473 ContextPackSelectedFileSkipReason::Unavailable
11474 };
11475 files.push(ContextPackRepositoryFileSource::skipped(
11476 selected_path,
11477 reason,
11478 None,
11479 ));
11480 }
11481 }
11482 }
11483 Ok(ContextPackRepositoryObservation { head, git, files })
11484 }
11485
11486 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
11487 let referenced_by_binding = self
11488 .session_bindings
11489 .lock()
11490 .unwrap_or_else(|poisoned| poisoned.into_inner())
11491 .values()
11492 .any(|binding| {
11493 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
11494 });
11495 let referenced_by_record = self
11496 .session_records
11497 .lock()
11498 .unwrap_or_else(|poisoned| poisoned.into_inner())
11499 .records
11500 .values()
11501 .any(|record| record.context_id.as_ref() == Some(context_id));
11502 let referenced_by_materialization = self
11503 .materializations
11504 .lock()
11505 .unwrap_or_else(|poisoned| poisoned.into_inner())
11506 .values()
11507 .any(|ownership| {
11508 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
11509 });
11510 if referenced_by_binding
11511 || referenced_by_record
11512 || referenced_by_materialization
11513 {
11514 return Err(failure(
11515 NodeFailureCode::ContextPackBusy,
11516 "context pack is still referenced",
11517 ));
11518 }
11519 let mut catalog = self.context_catalog
11520 .write()
11521 .unwrap_or_else(|poisoned| poisoned.into_inner());
11522 if catalog.get(context_id).is_none() {
11523 return Err(failure(
11524 NodeFailureCode::UnknownContextPack,
11525 "context pack is unavailable",
11526 ));
11527 }
11528 self.spawn_idempotency
11529 .lock()
11530 .unwrap_or_else(|poisoned| poisoned.into_inner())
11531 .entries
11532 .retain(|_, entry| !entry.value.references_context(context_id));
11533 let removed = catalog.remove(context_id);
11534 debug_assert!(removed.is_some(), "validated context pack remains present");
11535 Ok(())
11536 }
11537
11538 async fn dispatch_input_bounded(
11539 &self,
11540 address: &SessionAddress,
11541 action: InputAction,
11542 ) -> Result<(), NodeFailure> {
11543 let started_after = self.current_sequence();
11544 let command_id = self
11545 .dispatch_bounded(
11546 ControlCommand::SendInput {
11547 instance_id: address.session.instance_id,
11548 action,
11549 },
11550 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11551 )
11552 .await?;
11553 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
11557 let mut scan_after = started_after;
11558 let mut accepted_after = None;
11559 loop {
11560 let events = self
11561 .history
11562 .lock()
11563 .unwrap_or_else(|poisoned| poisoned.into_inner())
11564 .events
11565 .iter()
11566 .filter(|envelope| envelope.sequence > scan_after)
11567 .cloned()
11568 .collect::<Vec<_>>();
11569 for envelope in events {
11570 scan_after = scan_after.max(envelope.sequence);
11571 let NodeEvent::Control {
11572 address: event_address,
11573 event,
11574 } = envelope.event
11575 else {
11576 continue;
11577 };
11578 if event_address != *address {
11579 continue;
11580 }
11581 if event.command_id == Some(command_id) {
11582 match &event.event {
11583 ControlEventKind::CommandRejected { message } => {
11584 return Err(failure(NodeFailureCode::InvalidRequest, message));
11585 }
11586 ControlEventKind::InputRequested { .. } => {
11587 accepted_after = Some(envelope.sequence);
11588 continue;
11589 }
11590 _ => {}
11591 }
11592 }
11593 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11594 match &event.event {
11595 ControlEventKind::InputCompleted { .. } => return Ok(()),
11596 ControlEventKind::InputFailed { message, .. } => {
11597 return Err(failure(
11598 NodeFailureCode::BackendOperationFailed,
11599 message,
11600 ));
11601 }
11602 _ => {}
11603 }
11604 }
11605 }
11606 if Instant::now() >= deadline {
11607 return Err(failure(
11608 NodeFailureCode::BackendBusy,
11609 if accepted_after.is_some() {
11610 "input was accepted but did not settle before the bounded deadline"
11611 } else {
11612 "input was not accepted before the bounded deadline"
11613 },
11614 ));
11615 }
11616 sleep(Duration::from_millis(2)).await;
11617 }
11618 }
11619
11620 async fn dispatch_resize_bounded(
11621 &self,
11622 address: &SessionAddress,
11623 size: gate4agent_types::TerminalSize,
11624 ) -> Result<(), NodeFailure> {
11625 let started_after = self.current_sequence();
11626 let command_id = self
11627 .dispatch_bounded(
11628 ControlCommand::Resize {
11629 instance_id: address.session.instance_id,
11630 size,
11631 },
11632 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11633 )
11634 .await?;
11635 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11636 let mut scan_after = started_after;
11637 let mut accepted_after = None;
11638 loop {
11639 let events = self
11640 .history
11641 .lock()
11642 .unwrap_or_else(|poisoned| poisoned.into_inner())
11643 .events
11644 .iter()
11645 .filter(|envelope| envelope.sequence > scan_after)
11646 .cloned()
11647 .collect::<Vec<_>>();
11648 for envelope in events {
11649 scan_after = scan_after.max(envelope.sequence);
11650 let NodeEvent::Control {
11651 address: event_address,
11652 event,
11653 } = envelope.event
11654 else {
11655 continue;
11656 };
11657 if event_address != *address {
11658 continue;
11659 }
11660 if event.command_id == Some(command_id) {
11661 match &event.event {
11662 ControlEventKind::CommandRejected { message } => {
11663 return Err(failure(NodeFailureCode::InvalidRequest, message));
11664 }
11665 ControlEventKind::ResizeRequested {
11666 size: requested_size,
11667 ..
11668 } if *requested_size == size => {
11669 accepted_after = Some(envelope.sequence);
11670 continue;
11671 }
11672 _ => {}
11673 }
11674 }
11675 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11676 match &event.event {
11677 ControlEventKind::Resized { size: settled_size }
11678 if *settled_size == size =>
11679 {
11680 return Ok(());
11681 }
11682 ControlEventKind::ResizeFailed { message } => {
11683 return Err(failure(
11684 NodeFailureCode::BackendOperationFailed,
11685 message,
11686 ));
11687 }
11688 _ => {}
11689 }
11690 }
11691 }
11692 if Instant::now() >= deadline {
11693 return Err(failure(
11694 NodeFailureCode::BackendBusy,
11695 if accepted_after.is_some() {
11696 "resize was accepted but did not settle before the bounded deadline"
11697 } else {
11698 "resize was not accepted before the bounded deadline"
11699 },
11700 ));
11701 }
11702 sleep(Duration::from_millis(2)).await;
11703 }
11704 }
11705
11706 async fn spawn_session(
11707 &self,
11708 workspace_id: WorkspaceId,
11709 provider: AgentId,
11710 mode: SessionMode,
11711 terminal_size: gate4agent_types::TerminalSize,
11712 initial_prompt: Option<String>,
11713 ) -> Result<SessionAddress, NodeFailure> {
11714 if self.managed_worktrees.lock()
11715 .unwrap_or_else(|poisoned| poisoned.into_inner())
11716 .lease_for_workspace(&workspace_id)
11717 .is_some()
11718 {
11719 return Err(failure(
11720 NodeFailureCode::ManagedWorktreeOwnershipConflict,
11721 "managed worktree targets may only be spawned through their lease request",
11722 ));
11723 }
11724 self
11725 .spawn_session_with_deadline(
11726 workspace_id,
11727 provider,
11728 mode,
11729 terminal_size,
11730 initial_prompt,
11731 None,
11732 None,
11733 None,
11734 None,
11735 None,
11736 SpawnRecordPolicy::ProviderIdentityOnly,
11737 None,
11738 &[],
11739 None,
11740 ApprovalLevel::default(),
11746 None,
11747 None,
11748 )
11749 .await
11750 .map(|(session, _runtime_policy)| session)
11751 }
11752
11753 async fn spawn_session_with_deadline(
11754 &self,
11755 workspace_id: WorkspaceId,
11756 provider: AgentId,
11757 mode: SessionMode,
11758 terminal_size: gate4agent_types::TerminalSize,
11759 initial_prompt: Option<String>,
11760 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
11761 bundle: Option<ResolvedBundleReceipt>,
11762 context: Option<ResolvedContextPackReceipt>,
11763 managed_authority: Option<ManagedWorktreeLeaseId>,
11764 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
11765 record_policy: SpawnRecordPolicy,
11766 deadline: Option<Instant>,
11767 required_capabilities: &[ProviderRuntimeCapability],
11768 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
11769 approval_level: ApprovalLevel,
11770 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
11771 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
11777 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
11778 let reserved_lease = self.managed_worktrees.lock()
11779 .unwrap_or_else(|poisoned| poisoned.into_inner())
11780 .lease_for_workspace(&workspace_id);
11781 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
11782 (Some(expected), Some(provided)) if expected == provided => {}
11783 (None, None) => {}
11784 _ => {
11785 return Err(failure(
11786 NodeFailureCode::ManagedWorktreeOwnershipConflict,
11787 "spawn lacks exact managed worktree lease authority",
11788 ));
11789 }
11790 }
11791 self.ensure_workspace_environment_profile_compatible(
11795 &workspace_id,
11796 environment_profile.as_ref(),
11797 )?;
11798 let runtime_requirement = match (mode, initial_prompt.is_some()) {
11799 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
11800 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
11801 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
11802 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
11803 };
11804 let transport = match mode {
11810 SessionMode::Pty => TransportKind::Pty,
11811 SessionMode::Inline => TransportKind::Pipe,
11812 SessionMode::Acp => TransportKind::Acp,
11813 };
11814 let pipe_transport = self.provider_supports_pipe_transport(&provider);
11815 let acp_transport = self.provider_supports_acp_transport(&provider);
11816 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
11817 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
11818 NodeFailureCode::UnsupportedSpawnCapability,
11819 "pre-admitted provider runtime no longer satisfies spawn requirements",
11820 ))?;
11821 runtime_policy
11822 } else if let Some(deadline) = deadline {
11823 let remaining = spawn_deadline_remaining(deadline)?;
11824 timeout(
11825 remaining,
11826 self.admit_provider_runtime(&provider, runtime_requirement),
11827 )
11828 .await
11829 .map_err(|_| {
11830 failure(
11831 NodeFailureCode::SpawnDeadlineExceeded,
11832 "provider admission exceeded the spawn deadline",
11833 )
11834 })??
11835 } else {
11836 self.admit_provider_runtime(&provider, runtime_requirement).await?
11837 };
11838 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
11839 let runtime_policy =
11851 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
11852 if required_capabilities
11853 .iter()
11854 .any(|capability| !runtime_policy.admits(*capability))
11855 {
11856 return Err(failure(
11857 NodeFailureCode::UnsupportedSpawnCapability,
11858 "provider runtime does not admit a required spawn capability",
11859 ));
11860 }
11861 if let Some(deadline) = deadline {
11862 spawn_deadline_remaining(deadline)?;
11863 }
11864 self.ensure_binding_capacity()?;
11865 let working_directory = self.workspace_root(&workspace_id)?;
11866 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
11867 let session = SessionKey {
11868 instance_id,
11869 generation: SessionGeneration(1),
11870 };
11871 let address = SessionAddress {
11872 workspace_id: workspace_id.clone(),
11873 session,
11874 };
11875 tracing::info!(
11883 node_id = %self.node_id,
11884 workspace_id = %workspace_id,
11885 provider = %provider,
11886 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
11887 session = ?address.session,
11888 "session spawn request accepted",
11889 );
11890 #[cfg(feature = "dig2-station-probe")]
11895 let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
11896 use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
11897 match acquire_lease(
11898 &self.browser_station_leases,
11899 profile_id,
11900 instance_id,
11901 unix_time_ms(),
11902 ) {
11903 Ok(guard) => Some(guard),
11904 Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
11905 return Err(failure(
11906 NodeFailureCode::BrowserStationProfileBusy,
11907 "dig2browser station profile is already leased by another session",
11908 ));
11909 }
11910 Err(BrowserStationLeaseError::CapacityExceeded) => {
11911 return Err(failure(
11912 NodeFailureCode::BrowserStationProfileBusy,
11913 "dig2browser station profile lease capacity exhausted",
11914 ));
11915 }
11916 Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
11917 return Err(failure(
11918 NodeFailureCode::BrowserStationProfileBusy,
11919 "session already holds a dig2browser station profile lease",
11920 ));
11921 }
11922 }
11923 } else {
11924 None
11925 };
11926 #[cfg(not(feature = "dig2-station-probe"))]
11927 let _ = browser_profile_id;
11928 let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
11929 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != provider
11938 {
11939 return Err(failure(
11940 NodeFailureCode::BindingMismatch,
11941 "harness MCP reservation does not match the exact PTY/ACP provider",
11942 ));
11943 }
11944 prepared.verify_helper_program().map_err(harness_mcp_failure)?;
11945 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
11946 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
11947 })?;
11948 let launch = prepared.launch();
11953 let mut env_entries = vec![
11954 (
11955 OsString::from(launch.endpoint_env.as_str()),
11956 prepared.endpoint().as_os_str().to_os_string(),
11957 ),
11958 (
11959 OsString::from(launch.token_env.as_str()),
11960 prepared.token().expose().into(),
11961 ),
11962 (
11963 OsString::from(launch.program_env.as_str()),
11964 prepared.helper_program().as_os_str().to_os_string(),
11965 ),
11966 ];
11967 if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
11968 env_entries.push(trace_entry);
11969 }
11970 let overlay = NativeMcpServerLaunchOverlay::new(
11971 provider.clone(),
11972 launch.server_name.as_str(),
11973 prepared.helper_program().as_os_str().to_os_string(),
11974 launch.args.iter().map(OsString::from).collect(),
11975 env_entries,
11976 launch.scrub_env.iter().map(OsString::from).collect(),
11977 ).map_err(|_| failure(
11978 NodeFailureCode::HarnessMcpUnavailable,
11979 "harness MCP launch overlay is unavailable",
11980 ))?;
11981 control
11982 .install_native_mcp_server_launch_overlay(instance_id, overlay)
11983 .map_err(|_| failure(
11984 NodeFailureCode::HarnessMcpUnavailable,
11985 "harness MCP launch overlay could not be installed",
11986 ))?;
11987 tracing::info!(
11993 node_id = %self.node_id,
11994 instance_id = ?instance_id,
11995 provider = %provider,
11996 mode = ?mode,
11997 "harness MCP overlay installed for spawn",
11998 );
11999 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
12000 } else {
12001 None
12002 };
12003 let prepared_materialization = self.prepare_session_materialization(
12004 &address,
12005 &provider,
12006 mode,
12007 environment_profile.as_ref(),
12008 bundle.as_ref(),
12009 context.as_ref(),
12010 managed_authority.clone(),
12011 )?;
12012 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
12013 Some((overlay, guard)) => (Some(overlay), Some(guard)),
12014 None => (None, None),
12015 };
12016 let materialization_id = materialization_guard
12017 .as_ref()
12018 .and_then(SessionMaterializationGuard::id)
12019 .cloned();
12020 let mut environment_selection = self.select_environment_profile(
12021 instance_id,
12022 &provider,
12023 mode,
12024 environment_profile.as_ref(),
12025 )?;
12026 let provider_home = materialization_guard
12029 .as_ref()
12030 .and_then(SessionMaterializationGuard::provider_home);
12031 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
12032 &provider,
12033 mode,
12034 approval_level,
12035 network_allowlist.as_ref(),
12036 provider_home,
12037 )?;
12038 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
12039 (prepared, args) if args.is_empty() => prepared,
12040 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
12041 overlay.append_extra_args(args).map_err(|_| {
12042 failure(
12043 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
12044 "network allowlist overlay could not merge with instance launch args",
12045 )
12046 })?;
12047 Some(PreparedNativeLaunchOverlay::Instance(overlay))
12048 }
12049 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
12050 Some(PreparedNativeLaunchOverlay::Instance(
12051 overlay
12052 .into_instance_with_extra_args(args)
12053 .map_err(|_| {
12054 failure(
12055 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
12056 "network allowlist overlay could not merge with environment overlay",
12057 )
12058 })?,
12059 ))
12060 }
12061 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
12062 NativeInstanceLaunchOverlay::new(
12063 provider.clone(),
12064 match mode {
12065 SessionMode::Pty => TransportKind::Pty,
12066 SessionMode::Acp => TransportKind::Acp,
12067 SessionMode::Inline => TransportKind::Pipe,
12068 },
12069 Vec::new(),
12070 args,
12071 )
12072 .map_err(|_| {
12073 failure(
12074 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
12075 "network allowlist overlay is invalid for this transport",
12076 )
12077 })?,
12078 )),
12079 };
12080 let mut instance_overlay = environment_overlay
12081 .map(|overlay| self.install_prepared_launch_overlay(instance_id, overlay))
12082 .transpose()?
12083 .flatten();
12084 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12085 let record_id = self.bind_spawn_session_with_materialization(
12086 &address,
12087 provider.clone(),
12088 mode,
12089 runtime_policy,
12090 record_policy,
12091 environment_profile,
12092 bundle,
12093 context,
12094 materialization_id,
12095 )?;
12096 if let Some(selection) = environment_selection.take() {
12097 selection.retain();
12098 }
12099 if let Some(overlay) = instance_overlay.take() {
12100 overlay.retain();
12101 }
12102 if let Some(guard) = materialization_guard.take() {
12103 guard.retain();
12104 }
12105 let agent_id = provider;
12109 let register_command_id = match self.dispatch_bounded(
12110 ControlCommand::Register {
12111 instance_id,
12112 agent_id,
12113 transport,
12114 },
12115 dispatch_timeout,
12116 )
12117 .await {
12118 Ok(command_id) => command_id,
12119 Err(error) => {
12120 if let Some(binding) = self.remove_binding(&address) {
12121 self.cleanup_session_owned_materialization(&address, &binding)?;
12122 }
12123 if let Some(record_id) = record_id.as_ref() {
12124 self.discard_record(record_id)?;
12125 }
12126 return Err(spawn_dispatch_error(error, deadline));
12127 }
12128 };
12129 let start_timeout = match spawn_dispatch_timeout(deadline) {
12130 Ok(timeout) => timeout,
12131 Err(error) => {
12132 let recovery = self
12133 .rollback_spawn(
12134 &address,
12135 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12136 )
12137 .await;
12138 return match recovery {
12139 Ok(()) => {
12140 if let Some(record_id) = record_id.as_ref() {
12141 self.discard_record(record_id)?;
12142 }
12143 Err(error)
12144 }
12145 Err(recovery_error) => {
12146 if let Some(record_id) = record_id.as_ref() {
12147 self.mark_record_error(
12148 record_id,
12149 "spawn deadline elapsed and registration recovery failed",
12150 )?;
12151 }
12152 Err(recovery_error)
12153 }
12154 };
12155 }
12156 };
12157 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12158 if let Err(recovery_error) = self
12159 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12160 .await
12161 {
12162 return Err(recovery_error);
12163 }
12164 if let Some(record_id) = record_id.as_ref() {
12165 self.discard_record(record_id)?;
12166 }
12167 return Err(start_error);
12168 }
12169 let start_result = self
12170 .dispatch_bounded(
12171 ControlCommand::Start {
12172 instance_id,
12173 runtime_policy,
12174 request: StartRequest {
12175 working_directory,
12176 terminal_size,
12177 initial_prompt,
12178 session_options: None,
12179 approval_level,
12180 },
12181 },
12182 start_timeout,
12183 )
12184 .await;
12185 let start_command_id = match start_result {
12186 Ok(command_id) => command_id,
12187 Err(start_error) => {
12188 let start_error = spawn_dispatch_error(start_error, deadline);
12189 let recovery = self
12190 .rollback_spawn(
12191 &address,
12192 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12193 )
12194 .await;
12195 return match recovery {
12196 Ok(()) => {
12197 if let Some(record_id) = record_id.as_ref() {
12198 self.discard_record(record_id)?;
12199 }
12200 Err(start_error)
12201 }
12202 Err(recovery_error) => {
12203 let diagnostic = format!(
12204 "start failed and registration recovery failed: {}",
12205 recovery_error.message,
12206 );
12207 if let Some(record_id) = record_id.as_ref() {
12208 self.mark_record_error(record_id, &diagnostic)?;
12209 }
12210 Err(failure(recovery_error.code, &diagnostic))
12211 }
12212 };
12213 }
12214 };
12215
12216 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12217 loop {
12218 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12227 .or_else(|| self.take_rejected_command_reason(start_command_id))
12228 {
12229 self.rollback_spawn(
12230 &address,
12231 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12232 ).await.map_err(|error| {
12233 failure(
12234 error.code,
12235 &format!(
12236 "spawn was rejected ({}) and registration recovery failed: {}",
12237 reason.message, error.message
12238 ),
12239 )
12240 })?;
12241 if let Some(record_id) = record_id.as_ref() {
12242 self.discard_record(record_id)?;
12243 }
12244 let code = if reason.unsupported_transport {
12245 NodeFailureCode::UnsupportedTransport
12246 } else {
12247 NodeFailureCode::BackendOperationFailed
12248 };
12249 return Err(failure(code, &reason.message));
12250 }
12251 if let Some(current) = self
12252 .handle
12253 .snapshot()
12254 .sessions
12255 .iter()
12256 .find(|current| current.instance_id == instance_id)
12257 {
12258 if current.generation != session.generation {
12259 let divergence = failure(
12260 NodeFailureCode::StaleGeneration,
12261 "spawn generation diverged",
12262 );
12263 return match self
12264 .rollback_spawn(
12265 &address,
12266 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12267 )
12268 .await
12269 {
12270 Ok(()) => {
12271 if let Some(record_id) = record_id.as_ref() {
12272 self.discard_record(record_id)?;
12273 }
12274 Err(divergence)
12275 }
12276 Err(recovery_error) => {
12277 if let Some(record_id) = record_id.as_ref() {
12278 self.mark_record_error(
12279 record_id,
12280 "spawn generation diverged and recovery failed",
12281 )?;
12282 }
12283 Err(recovery_error)
12284 }
12285 };
12286 }
12287 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12288 if let Some(overlay) = harness_mcp_overlay.take() {
12289 overlay.retain();
12290 }
12291 #[cfg(feature = "dig2-station-probe")]
12292 if let Some(guard) = browser_station_lease_guard.take() {
12293 guard.retain();
12294 }
12295 return Ok((address, runtime_policy));
12296 }
12297 }
12298 if Instant::now() >= commit_deadline {
12299 self.rollback_spawn(
12300 &address,
12301 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12302 ).await.map_err(|error| {
12303 failure(
12304 error.code,
12305 &format!(
12306 "start did not commit and registration recovery failed: {}",
12307 error.message
12308 ),
12309 )
12310 })?;
12311 if let Some(record_id) = record_id.as_ref() {
12312 self.discard_record(record_id)?;
12313 }
12314 return Err(failure(
12315 if deadline.is_some() {
12316 NodeFailureCode::SpawnDeadlineExceeded
12317 } else {
12318 NodeFailureCode::BackendBusy
12319 },
12320 "start did not commit before the bounded deadline; registration was removed",
12321 ));
12322 }
12323 sleep(Duration::from_millis(2)).await;
12324 }
12325 }
12326
12327 fn effective_spawn_runtime_policy(
12328 &self,
12329 runtime_policy: ProviderRuntimePolicy,
12330 ) -> ProviderRuntimePolicy {
12331 #[cfg(feature = "fixture")]
12332 let runtime_policy = if self.fixture_semantic_hook_policy {
12333 ProviderRuntimePolicy::new(true, true, true, true, false, true)
12334 .expect("monitoring Hook fixture policy is internally valid")
12335 } else {
12336 runtime_policy
12337 };
12338 runtime_policy
12339 }
12340
12341 fn verify_harness_mcp_before_start(
12342 &self,
12343 prepared: Option<&PreparedHarnessMcpSpawn>,
12344 ) -> Result<(), NodeFailure> {
12345 prepared
12346 .map(PreparedHarnessMcpSpawn::verify_helper_program)
12347 .transpose()
12348 .map(|_| ())
12349 .map_err(harness_mcp_failure)
12350 }
12351
12352 async fn rollback_spawn(
12353 &self,
12354 address: &SessionAddress,
12355 commit_timeout: Duration,
12356 ) -> Result<(), NodeFailure> {
12357 self.dispatch_bounded(
12358 ControlCommand::Remove {
12359 instance_id: address.session.instance_id,
12360 },
12361 commit_timeout,
12362 )
12363 .await?;
12364 self.wait_until_removed(address, commit_timeout).await?;
12365 if let Some(binding) = self.remove_binding(address) {
12366 self.cleanup_session_owned_materialization(address, &binding)?;
12367 }
12368 Ok(())
12369 }
12370
12371 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12372 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12373 self.dispatch_bounded(
12374 ControlCommand::Remove {
12375 instance_id: address.session.instance_id,
12376 },
12377 commit_timeout,
12378 )
12379 .await?;
12380 self.wait_until_removed(address, commit_timeout).await?;
12381 let removed = self.remove_binding(address);
12382 if let Some(binding) = removed.as_ref() {
12383 self.cleanup_session_owned_materialization(address, binding)?;
12384 }
12385 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12386 let snapshot = self.managed_worktrees.lock()
12387 .unwrap_or_else(|poisoned| poisoned.into_inner())
12388 .release_session(
12389 &lease_id,
12390 self.incarnation_id,
12391 address.session.instance_id,
12392 address.session.generation,
12393 unix_time_ms(),
12394 );
12395 if self.persist_state().is_err() {
12396 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12397 }
12398 if let Some(snapshot) = snapshot {
12399 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12400 }
12401 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12402 }
12403 Ok(())
12404 }
12405
12406 async fn wait_until_removed(
12452 &self,
12453 address: &SessionAddress,
12454 commit_timeout: Duration,
12455 ) -> Result<(), NodeFailure> {
12456 let deadline = Instant::now() + commit_timeout;
12457 let mut next_dispatch = Instant::now();
12458 loop {
12459 let snapshot = self.handle.snapshot();
12460 let status = snapshot
12461 .sessions
12462 .iter()
12463 .find(|session| session.instance_id == address.session.instance_id)
12464 .map(|session| session.status.clone());
12465 let Some(status) = status else {
12466 return Ok(());
12467 };
12468 let now = Instant::now();
12469 if now >= deadline {
12470 return Err(failure(
12471 NodeFailureCode::BackendBusy,
12472 "remove did not commit before the bounded deadline; session binding was retained",
12473 ));
12474 }
12475 if status.allows_remove() && now >= next_dispatch {
12476 let _ = self.dispatch(ControlCommand::Remove {
12477 instance_id: address.session.instance_id,
12478 });
12479 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
12480 }
12481 sleep(Duration::from_millis(2)).await;
12482 }
12483 }
12484
12485 fn resync(&self, after_sequence: u64) -> NodeResponse {
12486 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12487 let event_sequence = history.last_sequence;
12488 let oldest_available_sequence = history.replay_floor_sequence;
12489 let events = history
12490 .events
12491 .iter()
12492 .filter(|event| event.sequence > after_sequence)
12493 .cloned()
12494 .collect();
12495 drop(history);
12496 NodeResponse::Resync {
12497 event_sequence,
12498 oldest_available_sequence,
12499 snapshot: self.snapshot(),
12500 events,
12501 }
12502 }
12503}
12504
12505fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
12510 match kind {
12511 AgentStreamChunkKindV1::Text { .. } => "text",
12512 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
12513 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
12514 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
12515 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
12516 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
12517 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
12518 }
12519}
12520
12521async fn accept_connections(
12535 endpoint: &str,
12536 call_home: Option<std::net::SocketAddr>,
12537 shared: Arc<NodeShared>,
12538) -> Result<(), NodeServerError> {
12539 let mut connections = JoinSet::new();
12540 let result = match call_home {
12541 None => accept_connections_inner(endpoint, shared, &mut connections).await,
12542 Some(relay) => {
12543 let listener =
12544 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
12545 let dialler = call_home_loop(relay, Arc::clone(&shared));
12546 tokio::pin!(listener);
12547 tokio::pin!(dialler);
12548 tokio::select! {
12549 result = &mut listener => result,
12550 result = &mut dialler => result,
12551 }
12552 }
12553 };
12554 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
12555 while !connections.is_empty() {
12556 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
12557 if remaining.is_zero()
12558 || timeout(remaining, connections.join_next()).await.is_err()
12559 {
12560 break;
12561 }
12562 }
12563 connections.abort_all();
12564 while connections.join_next().await.is_some() {}
12565 result
12566}
12567
12568const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
12572
12573async fn call_home_loop(
12587 relay: std::net::SocketAddr,
12588 shared: Arc<NodeShared>,
12589) -> Result<(), NodeServerError> {
12590 let mut failures = 0_usize;
12591 loop {
12592 if shared.shutdown.load(Ordering::Acquire) {
12593 return Ok(());
12594 }
12595 let permit = tokio::select! {
12596 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12597 permit.map_err(|_| NodeServerError::PreauthClosed)?
12598 }
12599 _ = shared.shutdown_notify.notified() => return Ok(()),
12600 };
12601 match dial_relay_and_serve(relay, Arc::clone(&shared), permit).await {
12602 Ok(()) => {
12603 failures = 0;
12607 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed");
12608 }
12609 Err(error) => {
12610 failures = failures.saturating_add(1);
12611 tracing::warn!(
12612 node_id = %shared.node_id,
12613 %relay,
12614 cause = %error,
12615 attempt = failures,
12616 "call-home connection failed",
12617 );
12618 let backoff = CALL_HOME_BACKOFFS_MS
12619 [failures.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)];
12620 tokio::select! {
12621 _ = sleep(Duration::from_millis(backoff)) => {}
12622 _ = shared.shutdown_notify.notified() => return Ok(()),
12623 }
12624 }
12625 }
12626 }
12627}
12628
12629async fn dial_relay_and_serve(
12631 relay: std::net::SocketAddr,
12632 shared: Arc<NodeShared>,
12633 preauth_permit: OwnedSemaphorePermit,
12634) -> Result<(), NodeServerError> {
12635 let mut stream = tokio::net::TcpStream::connect(relay)
12636 .await
12637 .map_err(NodeServerError::CallHomeTransport)?;
12638 let _ = stream.set_nodelay(true);
12642 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
12643 .await
12644 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12645 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
12646 serve_connection(stream, shared, preauth_permit).await
12647}
12648
12649async fn accept_connections_inner(
12650 endpoint: &str,
12651 shared: Arc<NodeShared>,
12652 connections: &mut JoinSet<Result<(), NodeServerError>>,
12653) -> Result<(), NodeServerError> {
12654 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
12655 loop {
12656 while connections.try_join_next().is_some() {}
12657 if shared.shutdown.load(Ordering::Acquire) {
12658 return Ok(());
12659 }
12660 let preauth_permit = tokio::select! {
12661 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12662 permit.map_err(|_| NodeServerError::PreauthClosed)?
12663 }
12664 _ = shared.shutdown_notify.notified() => return Ok(()),
12665 };
12666 let server = tokio::select! {
12667 result = listener.accept() => result?,
12668 _ = shared.shutdown_notify.notified() => return Ok(()),
12669 };
12670 let connection_shared = Arc::clone(&shared);
12671 connections.spawn(async move {
12672 serve_connection(server, connection_shared, preauth_permit).await
12673 });
12674 if shared.shutdown.load(Ordering::Acquire) {
12675 return Ok(());
12676 }
12677 }
12678}
12679
12680async fn serve_connection<S>(
12688 mut pipe: S,
12689 shared: Arc<NodeShared>,
12690 preauth_permit: OwnedSemaphorePermit,
12691) -> Result<(), NodeServerError>
12692where
12693 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
12694{
12695 let hello_frame = timeout(
12696 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
12697 read_json_frame_limited_body_timeout(
12698 &mut pipe,
12699 MAX_NODE_HELLO_FRAME_BYTES,
12700 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12701 ),
12702 )
12703 .await
12704 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
12705 let ClientFrame::Hello(hello) = hello_frame else {
12706 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
12707 };
12708 if hello.build_stamp != BUILD_STAMP {
12709 return Err(NodeServerError::Handshake(format!(
12710 "build stamp mismatch: local={BUILD_STAMP} remote={}",
12711 hello.build_stamp,
12712 )));
12713 }
12714 let compatibility = match hello.compatibility.as_ref() {
12715 Some(offer) => {
12716 let mut selected = node_compatibility_support(&shared)?
12717 .negotiate(offer)
12718 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12719 project_negotiated_provider_ids(&mut selected);
12720 Some(selected)
12721 }
12722 None => None,
12723 };
12724 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
12725 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
12726 (Some(offer), Some(selected)) => negotiated_auth_proof(
12727 shared.access_token.as_bytes(),
12728 AuthDirection::Server,
12729 hello.role,
12730 &hello.client_nonce,
12731 &server_nonce,
12732 offer,
12733 selected,
12734 ),
12735 (None, None) => auth_proof(
12736 shared.access_token.as_bytes(),
12737 AuthDirection::Server,
12738 hello.role,
12739 &hello.client_nonce,
12740 &server_nonce,
12741 ),
12742 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
12743 }
12744 .map_err(NodeServerError::Authentication)?;
12745 write_json_frame_limited(
12746 &mut pipe,
12747 &ServerFrame::Challenge(ServerChallenge {
12748 build_stamp: BUILD_STAMP.to_owned(),
12749 server_nonce,
12750 server_proof,
12751 compatibility: compatibility.clone(),
12752 }),
12753 MAX_NODE_HELLO_FRAME_BYTES,
12754 )
12755 .await?;
12756 let authentication = timeout(
12757 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
12758 read_json_frame_limited_body_timeout(
12759 &mut pipe,
12760 MAX_NODE_HELLO_FRAME_BYTES,
12761 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12762 ),
12763 )
12764 .await
12765 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
12766 let ClientFrame::Authenticate(authentication) = authentication else {
12767 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
12768 };
12769 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
12770 (Some(offer), Some(selected)) => negotiated_auth_proof(
12771 shared.access_token.as_bytes(),
12772 AuthDirection::Client,
12773 hello.role,
12774 &hello.client_nonce,
12775 &server_nonce,
12776 offer,
12777 selected,
12778 ),
12779 (None, None) => auth_proof(
12780 shared.access_token.as_bytes(),
12781 AuthDirection::Client,
12782 hello.role,
12783 &hello.client_nonce,
12784 &server_nonce,
12785 ),
12786 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
12787 }
12788 .map_err(NodeServerError::Authentication)?;
12789 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
12790 return Err(NodeServerError::Handshake("access denied".to_owned()));
12791 }
12792 let selected_capabilities = compatibility
12793 .as_ref()
12794 .map(|selected| selected.capabilities.clone())
12795 .unwrap_or_default();
12796 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
12797 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
12798 });
12799 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
12800 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
12801 });
12802 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
12803 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
12804 });
12805 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
12806 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
12807 });
12808 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
12809 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
12810 });
12811 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
12812 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
12813 }) && selected_capabilities.iter().any(|capability| {
12814 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
12815 });
12816 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
12817 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
12818 });
12819 let include_session_bundles = selected_capabilities.iter().any(|capability| {
12820 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
12821 });
12822 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
12823 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
12824 });
12825 let include_agent_progress = selected_capabilities.iter().any(|capability| {
12826 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
12827 });
12828 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
12829 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
12830 });
12831 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
12832 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
12833 });
12834 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
12835 .try_acquire_owned()
12836 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
12837 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
12838 let _release_guard = ControllerReleaseGuard {
12839 shared: Arc::clone(&shared),
12840 connection_id,
12841 };
12842 drop(preauth_permit);
12843 let _authenticated_permit = authenticated_permit;
12844 let mut event_rx = shared.event_tx.subscribe();
12845 let mut terminal_event_rx = terminal_event_subscription(
12846 &shared,
12847 include_terminal_frame_events,
12848 );
12849 let mut agent_stream_event_rx = agent_stream_event_subscription(
12850 &shared,
12851 include_agent_stream_events,
12852 );
12853 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
12854 &shared,
12855 include_harness_mcp_proxy,
12856 );
12857 write_json_frame(
12858 &mut pipe,
12859 &ServerFrame::Hello(NodeHello {
12860 build_stamp: BUILD_STAMP.to_owned(),
12861 incarnation_id: shared.incarnation_id,
12862 connection_id,
12863 role: hello.role,
12864 event_sequence: shared.current_sequence(),
12865 controller: shared.controller_state(),
12866 snapshot: snapshot_for_wire(
12867 &shared,
12868 include_provider_runtime_status,
12869 include_open_provider_ids,
12870 include_spawn_profiles,
12871 include_managed_worktrees,
12872 include_child_environment_profiles,
12873 include_session_bundles,
12874 include_history_context_packs,
12875 include_agent_progress,
12876 include_session_task_correlation,
12877 ),
12878 compatibility,
12879 }),
12880 )
12881 .await?;
12882 let (mut reader, mut writer) = tokio::io::split(pipe);
12883 let (frame_tx, mut frame_rx) = mpsc::channel(16);
12884 let reader_task = tokio::spawn(async move {
12885 loop {
12886 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
12887 &mut reader,
12888 MAX_NODE_CLIENT_FRAME_BYTES,
12889 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12890 )
12891 .await;
12892 let terminal = frame.is_err();
12893 if frame_tx.send(frame).await.is_err() || terminal {
12894 break;
12895 }
12896 }
12897 });
12898 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
12899 let mut pending_events = Vec::new();
12900 let mut resync_required = false;
12901 let mut discard_events_through = 0_u64;
12902 loop {
12903 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
12908 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
12909 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
12910 for _ in 0..durable_drain_budget {
12911 match event_rx.try_recv() {
12912 Ok(event) => queue_connection_event(
12913 &mut pending_events,
12914 event,
12915 discard_events_through,
12916 ),
12917 Err(broadcast::error::TryRecvError::Empty) => break,
12918 Err(broadcast::error::TryRecvError::Lagged(_)) => {
12919 resync_required = true;
12920 break;
12921 }
12922 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
12923 }
12924 }
12925 if let Some(receiver) = terminal_event_rx.as_mut() {
12926 match receiver.try_recv() {
12927 Ok(events) => queue_connection_event_batch(
12928 &mut pending_events,
12929 &events,
12930 discard_events_through,
12931 ),
12932 Err(broadcast::error::TryRecvError::Empty) => {}
12933 Err(broadcast::error::TryRecvError::Lagged(_)) => {
12934 resync_required = true;
12935 }
12936 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
12937 }
12938 }
12939 if let Some(receiver) = agent_stream_event_rx.as_mut() {
12940 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
12950 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
12951 for _ in 0..agent_stream_drain_budget {
12952 match receiver.try_recv() {
12953 Ok(events) => queue_connection_event_batch(
12954 &mut pending_events,
12955 &events,
12956 discard_events_through,
12957 ),
12958 Err(broadcast::error::TryRecvError::Empty) => break,
12959 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
12960 tracing::warn!(
12961 connection_id,
12962 dropped,
12963 "agent stream event subscription lagged; forcing a resync",
12964 );
12965 resync_required = true;
12966 break;
12967 }
12968 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
12969 }
12970 }
12971 }
12972 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
12973 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
12979 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
12980 for _ in 0..harness_mcp_proxy_drain_budget {
12981 match receiver.try_recv() {
12982 Ok(events) => queue_connection_event_batch(
12983 &mut pending_events,
12984 &events,
12985 discard_events_through,
12986 ),
12987 Err(broadcast::error::TryRecvError::Empty) => break,
12988 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
12989 tracing::warn!(
12990 connection_id,
12991 dropped,
12992 "harness MCP proxy event subscription lagged; forcing a resync",
12993 );
12994 resync_required = true;
12995 break;
12996 }
12997 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
12998 }
12999 }
13000 }
13001 if resync_required {
13002 let event = resync_required_event(&shared);
13003 apply_connection_resync_watermark(
13004 &mut pending_events,
13005 &mut discard_events_through,
13006 event.sequence,
13007 );
13008 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13009 resync_required = false;
13010 continue;
13011 }
13012 if !pending_events.is_empty() {
13013 pending_events.sort_unstable_by_key(|event| event.sequence);
13014 let burst_len = connection_event_burst_len(pending_events.len());
13015 shared
13016 .connection_events_sent
13017 .fetch_add(burst_len as u64, Ordering::Relaxed);
13018 for event in pending_events.drain(..burst_len) {
13019 let event = if include_managed_worktrees {
13020 Some(event)
13021 } else {
13022 project_event_without_managed_worktrees(event)
13023 };
13024 let event = event.and_then(|event| if include_open_provider_ids {
13025 Some(event)
13026 } else {
13027 project_event_legacy_provider_ids(&shared, event)
13028 });
13029 let event = event.map(|event| if include_child_environment_profiles {
13030 event
13031 } else {
13032 project_event_without_child_environment_profile(event)
13033 });
13034 let event = event.map(|event| if include_session_bundles {
13035 event
13036 } else {
13037 project_event_without_session_bundle(event)
13038 });
13039 let event = event.map(|event| {
13040 project_event_history_for_wire(event, include_history_context_packs)
13041 });
13042 let event = event.map(|event| if include_history_context_packs {
13043 event
13044 } else {
13045 project_event_without_context_pack(event)
13046 });
13047 let event = event.map(|event| if include_session_task_correlation {
13048 event
13049 } else {
13050 project_event_without_session_task_binding(event)
13051 });
13052 let exact_harness_controller = hello.role == ClientRole::Operator
13053 && shared.controller_state().is_some_and(|controller| {
13054 controller.connection_id == connection_id
13055 });
13056 let event = event.and_then(|event| {
13057 if include_harness_mcp_proxy && exact_harness_controller {
13058 Some(event)
13059 } else {
13060 project_event_without_harness_mcp_proxy(event)
13061 }
13062 });
13063 if let Some(event) = event {
13064 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13065 }
13066 }
13067 }
13068 tokio::select! {
13069 biased;
13070 frame = frame_rx.recv() => {
13071 let frame = match frame {
13072 Some(frame) => frame,
13073 None => break,
13074 };
13075 let frame = match frame {
13076 Ok(frame) => frame,
13077 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13078 Err(error) => return Err(error.into()),
13079 };
13080 let ClientFrame::Request(request) = frame else {
13081 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13082 };
13083 #[cfg(feature = "fixture")]
13084 let probe_spawn_managed_worktree_v2 = matches!(
13085 &request.request,
13086 NodeRequest::SpawnManagedWorktreeV2 { .. }
13087 );
13088 let requires_open_provider_ids =
13089 request_requires_open_provider_ids(&shared, &request.request);
13090 let requires_child_environment_profile =
13091 request_requires_child_environment_profile(&shared, &request.request);
13092 let requires_session_bundle =
13093 request_requires_session_bundle(&shared, &request.request);
13094 let requires_history_context_pack =
13095 request_requires_history_context_pack(&shared, &request.request);
13096 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13097 ResponseEnvelope {
13098 request_id: request.request_id,
13099 result: Err(failure(
13100 NodeFailureCode::UnsupportedCapability,
13101 "open provider IDs were not negotiated",
13102 )),
13103 }
13104 } else if requires_child_environment_profile
13105 && !include_child_environment_profiles
13106 {
13107 ResponseEnvelope {
13108 request_id: request.request_id,
13109 result: Err(failure(
13110 NodeFailureCode::UnsupportedCapability,
13111 "child environment profiles were not negotiated",
13112 )),
13113 }
13114 } else if requires_session_bundle && !include_session_bundles {
13115 ResponseEnvelope {
13116 request_id: request.request_id,
13117 result: Err(failure(
13118 NodeFailureCode::UnsupportedCapability,
13119 "session bundles were not negotiated",
13120 )),
13121 }
13122 } else if requires_history_context_pack && !include_history_context_packs {
13123 ResponseEnvelope {
13124 request_id: request.request_id,
13125 result: Err(failure(
13126 NodeFailureCode::UnsupportedCapability,
13127 "history context packs were not negotiated",
13128 )),
13129 }
13130 } else if request_uses_unnegotiated_capability(
13131 &request.request,
13132 &selected_capabilities,
13133 )
13134 {
13135 ResponseEnvelope {
13136 request_id: request.request_id,
13137 result: Err(failure(
13138 NodeFailureCode::UnsupportedCapability,
13139 "request capability was not negotiated",
13140 )),
13141 }
13142 } else {
13143 process_request(&shared, connection_id, hello.role, request).await
13144 };
13145 #[cfg(feature = "fixture")]
13146 if probe_spawn_managed_worktree_v2 {
13147 shared
13148 .fixture_spawn_managed_worktree_v2_failure_probe
13149 .record(reply.result.as_ref().err().map(|failure| failure.code));
13150 }
13151 if !include_provider_runtime_status {
13152 clear_response_provider_runtime_status(&mut reply);
13153 }
13154 if !include_managed_worktrees {
13155 project_response_without_managed_worktrees(&mut reply);
13156 }
13157 if !include_open_provider_ids {
13158 project_response_legacy_provider_ids(&shared, &mut reply);
13159 }
13160 if !include_child_environment_profiles {
13161 project_response_without_child_environment_profile(&mut reply);
13162 }
13163 if !include_session_bundles {
13164 project_response_without_session_bundle(&mut reply);
13165 }
13166 project_response_history_for_wire(
13167 &mut reply,
13168 include_history_context_packs,
13169 );
13170 if !include_history_context_packs {
13171 project_response_without_context_pack(&mut reply);
13172 }
13173 if !include_agent_progress {
13174 project_response_without_agent_progress(&mut reply);
13175 }
13176 if !include_session_task_correlation {
13177 project_response_without_session_task_binding(&mut reply);
13178 }
13179 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13180 }
13181 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13182 event = event_rx.recv() => {
13183 match event {
13184 Ok(event) => queue_connection_event(
13185 &mut pending_events,
13186 event,
13187 discard_events_through,
13188 ),
13189 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13190 Err(broadcast::error::RecvError::Closed) => break,
13191 }
13192 }
13193 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13194 match events {
13195 Ok(events) => queue_connection_event_batch(
13196 &mut pending_events,
13197 &events,
13198 discard_events_through,
13199 ),
13200 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13201 Err(broadcast::error::RecvError::Closed) => break,
13202 }
13203 }
13204 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13205 match events {
13206 Ok(events) => queue_connection_event_batch(
13207 &mut pending_events,
13208 &events,
13209 discard_events_through,
13210 ),
13211 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13212 Err(broadcast::error::RecvError::Closed) => break,
13213 }
13214 }
13215 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13216 match events {
13217 Ok(events) => queue_connection_event_batch(
13218 &mut pending_events,
13219 &events,
13220 discard_events_through,
13221 ),
13222 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13223 Err(broadcast::error::RecvError::Closed) => break,
13224 }
13225 }
13226 _ = shared.shutdown_notify.notified() => break,
13227 }
13228 }
13229 Ok(())
13230}
13231
13232async fn receive_terminal_event_batch(
13233 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13234) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13235 match receiver {
13236 Some(receiver) => receiver.recv().await,
13237 None => std::future::pending().await,
13238 }
13239}
13240
13241fn terminal_event_subscription(
13242 shared: &NodeShared,
13243 enabled: bool,
13244) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13245 enabled.then(|| shared.terminal_event_tx.subscribe())
13246}
13247
13248fn agent_stream_event_subscription(
13252 shared: &NodeShared,
13253 enabled: bool,
13254) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13255 enabled.then(|| shared.agent_stream_event_tx.subscribe())
13256}
13257
13258fn harness_mcp_proxy_event_subscription(
13263 shared: &NodeShared,
13264 enabled: bool,
13265) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13266 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13267}
13268
13269fn queue_connection_event(
13270 pending: &mut Vec<NodeEventEnvelope>,
13271 event: NodeEventEnvelope,
13272 discard_through: u64,
13273) {
13274 if event.sequence <= discard_through
13275 && !event.event.requires_harness_mcp_proxy_capability()
13276 {
13277 return;
13278 }
13279 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13280 if let Some(index) = pending.iter().position(|current| {
13281 matches!(
13282 ¤t.event,
13283 NodeEvent::TerminalFrame {
13284 address: current_address,
13285 ..
13286 } if current_address == address
13287 )
13288 }) {
13289 pending.remove(index);
13290 }
13291 }
13292 pending.push(event);
13293}
13294
13295fn project_event_without_harness_mcp_proxy(
13296 envelope: NodeEventEnvelope,
13297) -> Option<NodeEventEnvelope> {
13298 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13299}
13300
13301fn queue_connection_event_batch(
13302 pending: &mut Vec<NodeEventEnvelope>,
13303 events: &[NodeEventEnvelope],
13304 discard_through: u64,
13305) {
13306 for event in events {
13307 queue_connection_event(pending, event.clone(), discard_through);
13308 }
13309}
13310
13311fn apply_connection_resync_watermark(
13312 pending: &mut Vec<NodeEventEnvelope>,
13313 discard_through: &mut u64,
13314 marker_sequence: u64,
13315) {
13316 *discard_through = (*discard_through).max(marker_sequence);
13317 pending.retain(|event| event.sequence > *discard_through);
13318}
13319
13320fn connection_event_burst_len(pending_len: usize) -> usize {
13321 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13322}
13323
13324fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13325 let history = shared
13326 .history
13327 .lock()
13328 .unwrap_or_else(|poisoned| poisoned.into_inner());
13329 let sequence = history.last_sequence;
13330 let oldest_available_sequence = history.replay_floor_sequence;
13331 NodeEventEnvelope {
13332 sequence,
13333 event: NodeEvent::ResyncRequired {
13334 oldest_available_sequence,
13335 },
13336 }
13337}
13338
13339fn node_compatibility_support(
13340 shared: &NodeShared,
13341) -> Result<NodeCompatibilitySupport, NodeServerError> {
13342 let mut support = node_compatibility_support_for_manifest(
13343 &shared.provider_contracts,
13344 &shared.provider_adapter_contracts,
13345 )?;
13346 if shared
13347 .delivery_store
13348 .lock()
13349 .unwrap_or_else(|poisoned| poisoned.into_inner())
13350 .is_some()
13351 {
13352 support.capabilities.push(
13353 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13354 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13355 );
13356 }
13357 if shared.harness_mcp_registry.is_some() {
13358 support.capabilities.push(
13359 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
13360 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13361 );
13362 }
13363 Ok(support)
13364}
13365
13366fn node_compatibility_support_for_manifest(
13367 provider_contracts: &[ProviderContractSupport],
13368 provider_adapter_contracts: &[ProviderAdapterContractSupport],
13369) -> Result<NodeCompatibilitySupport, NodeServerError> {
13370 Ok(NodeCompatibilitySupport {
13371 build_stamp: BUILD_STAMP.to_owned(),
13372 capabilities: baseline_capabilities()?,
13373 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
13374 path_semantics: platform::path_semantics(),
13375 local_transport: platform::local_transport(),
13376 state_schema: StateSchemaSupport {
13377 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
13378 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13379 },
13380 provider_contracts: provider_contracts.to_vec(),
13381 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
13382 })
13383}
13384
13385fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
13386 let capabilities = [
13387 NODE_COMPATIBILITY_METADATA_CAPABILITY,
13388 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
13389 NODE_REPOSITORY_PATH_CAPABILITY,
13390 NODE_WORKSPACE_FILE_READ_CAPABILITY,
13391 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
13392 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
13393 NODE_GIT_READ_CAPABILITY,
13394 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
13395 NODE_PROVIDER_ID_OPEN_CAPABILITY,
13396 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
13397 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
13398 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
13399 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
13400 NODE_ACP_CONTROL_CAPABILITY,
13401 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
13402 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
13403 NODE_WORKTREE_SELECTION_CAPABILITY,
13404 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
13405 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
13406 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
13407 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
13408 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
13409 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
13410 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
13411 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
13412 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
13413 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
13414 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
13415 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
13416 ]
13417 .into_iter()
13418 .map(|capability| {
13419 CapabilityId::new(capability)
13420 .map_err(|error| NodeServerError::Handshake(error.to_string()))
13421 })
13422 .collect::<Result<Vec<_>, _>>()?;
13423 #[cfg(windows)]
13424 let capabilities = {
13425 let mut capabilities = capabilities;
13426 capabilities.push(
13427 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
13428 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13429 );
13430 capabilities
13431 };
13432 Ok(capabilities)
13433}
13434
13435fn request_uses_unnegotiated_capability(
13436 request: &NodeRequest,
13437 selected_capabilities: &[CapabilityId],
13438) -> bool {
13439 let selected = |required: &str| {
13440 selected_capabilities
13441 .iter()
13442 .any(|capability| capability.as_str() == required)
13443 };
13444 request
13445 .required_capability()
13446 .is_some_and(|required| !selected(required))
13447 || (request.requires_worktree_selection_capability()
13448 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
13449 || (request.requires_spawn_spec_defaults_overrides_capability()
13450 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
13451 || (request.requires_spawn_profile_revision_capability()
13452 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
13453 || (request.requires_child_environment_profile_capability()
13454 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
13455 || (request.requires_session_bundle_materialization_capability()
13456 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
13457 || (request.requires_history_context_pack_capability()
13458 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
13459}
13460
13461fn snapshot_for_wire(
13462 shared: &NodeShared,
13463 include_provider_runtime_status: bool,
13464 include_open_provider_ids: bool,
13465 include_spawn_profiles: bool,
13466 include_managed_worktrees: bool,
13467 include_child_environment_profiles: bool,
13468 include_session_bundles: bool,
13469 include_history_context_packs: bool,
13470 include_agent_progress: bool,
13471 include_session_task_correlation: bool,
13472) -> NodeSnapshot {
13473 let mut snapshot = shared.snapshot();
13474 if !include_provider_runtime_status {
13475 snapshot.provider_runtime_statuses.clear();
13476 }
13477 if !include_open_provider_ids {
13478 project_snapshot_legacy_provider_ids(&mut snapshot);
13479 }
13480 if !include_managed_worktrees {
13481 snapshot.managed_worktrees.clear();
13482 for workspace in &mut snapshot.workspaces {
13483 workspace.managed_worktree_profiles = None;
13484 }
13485 }
13486 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
13487 if !include_spawn_profiles {
13488 inventory.spawn_profiles = None;
13489 }
13490 if !include_session_bundles {
13491 inventory.bundles = None;
13492 }
13493 if inventory.spawn_profiles.is_none()
13494 && inventory.bundles.is_none()
13495 && inventory.network_allowlists.is_none()
13496 {
13497 snapshot.launch_inventory = None;
13498 }
13499 }
13500 if !include_child_environment_profiles {
13501 clear_snapshot_child_environment_profiles(&mut snapshot);
13502 }
13503 if !include_session_bundles {
13504 clear_snapshot_session_bundles(&mut snapshot);
13505 }
13506 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
13507 if !include_history_context_packs {
13508 clear_snapshot_context_packs(&mut snapshot);
13509 }
13510 if !include_agent_progress {
13511 snapshot.agent_progress.clear();
13512 }
13513 if !include_session_task_correlation {
13514 clear_snapshot_session_task_bindings(&mut snapshot);
13515 }
13516 snapshot
13517}
13518
13519fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
13520 for record in &mut snapshot.session_records {
13521 record.task_binding = None;
13522 }
13523}
13524
13525fn project_event_without_session_task_binding(
13526 mut envelope: NodeEventEnvelope,
13527) -> NodeEventEnvelope {
13528 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
13529 record.task_binding = None;
13530 }
13531 envelope
13532}
13533
13534fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
13535 let Ok(response) = reply.result.as_mut() else { return };
13536 match response {
13537 NodeResponse::Snapshot { snapshot, .. } => {
13538 clear_snapshot_session_task_bindings(snapshot);
13539 }
13540 NodeResponse::Resync { snapshot, events, .. } => {
13541 clear_snapshot_session_task_bindings(snapshot);
13542 for event in events {
13543 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
13544 record.task_binding = None;
13545 }
13546 }
13547 }
13548 NodeResponse::SessionRecordUpdated { record }
13549 | NodeResponse::ProviderSessionIndexed { record }
13550 | NodeResponse::NativeSessionIndexed { record, .. }
13551 | NodeResponse::SessionRecordResumed { record, .. } => {
13552 record.task_binding = None;
13553 }
13554 _ => {}
13555 }
13556}
13557
13558fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
13559 let Ok(response) = reply.result.as_mut() else { return };
13560 match response {
13561 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
13562 snapshot.agent_progress.clear();
13563 }
13564 _ => {}
13565 }
13566}
13567
13568fn project_event_without_managed_worktrees(
13569 mut envelope: NodeEventEnvelope,
13570) -> Option<NodeEventEnvelope> {
13571 if matches!(
13572 envelope.event,
13573 NodeEvent::ManagedWorktreeUpserted { .. }
13574 | NodeEvent::ManagedWorktreeRemoved { .. }
13575 ) {
13576 return None;
13577 }
13578 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
13579 workspace.managed_worktree_profiles = None;
13580 }
13581 Some(envelope)
13582}
13583
13584fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
13585 if matches!(
13586 reply.result,
13587 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
13588 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
13589 ) {
13590 reply.result = Err(failure(
13591 NodeFailureCode::UnsupportedCapability,
13592 "managed worktree capability was not negotiated",
13593 ));
13594 return;
13595 }
13596 let Ok(response) = reply.result.as_mut() else { return };
13597 match response {
13598 NodeResponse::Snapshot { snapshot, .. } => {
13599 snapshot.managed_worktrees.clear();
13600 for workspace in &mut snapshot.workspaces {
13601 workspace.managed_worktree_profiles = None;
13602 }
13603 }
13604 NodeResponse::Resync { snapshot, events, .. } => {
13605 snapshot.managed_worktrees.clear();
13606 for workspace in &mut snapshot.workspaces {
13607 workspace.managed_worktree_profiles = None;
13608 }
13609 *events = std::mem::take(events)
13610 .into_iter()
13611 .filter_map(project_event_without_managed_worktrees)
13612 .collect();
13613 }
13614 NodeResponse::WorkspaceRegistered { workspace }
13615 | NodeResponse::StandaloneWorkspaceCreated { workspace }
13616 | NodeResponse::WorktreeCreated { workspace, .. } => {
13617 workspace.managed_worktree_profiles = None;
13618 }
13619 NodeResponse::WorkspaceInspected { inspection } => {
13620 inspection.git.managed_worktree = None;
13621 }
13622 _ => {}
13623 }
13624}
13625
13626fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
13627 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
13628 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13629 });
13630 if includes_open_provider_ids {
13631 return;
13632 }
13633 compatibility
13634 .provider_contracts
13635 .retain(|contract| provider_id_is_legacy(&contract.provider));
13636 compatibility
13637 .provider_adapter_contracts
13638 .retain(|contract| provider_id_is_legacy(&contract.provider));
13639}
13640
13641fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
13642 if let Some(spec) = match request {
13643 NodeRequest::SpawnSpec { spec }
13644 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13645 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13646 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13647 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13648 _ => None,
13649 } {
13650 return shared
13651 .resolve_spawn_spec(spec)
13652 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
13653 }
13654 request_requires_open_provider_ids_with(
13655 request,
13656 |session| shared.validate_address(session).ok(),
13657 |record_id| shared.record(record_id).ok().map(|record| record.provider),
13658 )
13659}
13660
13661fn request_requires_child_environment_profile(
13662 shared: &NodeShared,
13663 request: &NodeRequest,
13664) -> bool {
13665 if let Some(spec) = match request {
13666 NodeRequest::SpawnSpec { spec }
13667 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13668 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13669 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13670 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13671 _ => None,
13672 } {
13673 return shared
13674 .resolve_spawn_spec(spec)
13675 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
13676 }
13677 match request {
13678 NodeRequest::Resume { session, .. }
13679 | NodeRequest::Prompt { session, .. }
13680 | NodeRequest::Paste { session, .. }
13681 | NodeRequest::Input { session, .. }
13682 | NodeRequest::TerminalBytes { session, .. }
13683 | NodeRequest::TerminalControl { session, .. }
13684 | NodeRequest::Resize { session, .. }
13685 | NodeRequest::Interrupt { session }
13686 | NodeRequest::Stop { session, .. }
13687 | NodeRequest::Remove { session }
13688 | NodeRequest::ResolveInteraction { session, .. }
13689 | NodeRequest::SetSessionMode { session, .. }
13690 | NodeRequest::SetSessionConfigOption { session, .. }
13691 | NodeRequest::SetSessionModel { session, .. }
13692 | NodeRequest::DiscoverHistory { session, .. }
13693 | NodeRequest::LoadHistory { session, .. }
13694 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13695 | NodeRequest::ExportContextPack { session } => shared
13696 .session_bindings
13697 .lock()
13698 .unwrap_or_else(|poisoned| poisoned.into_inner())
13699 .get(&session.session.instance_id)
13700 .filter(|binding| {
13701 binding.workspace_id == session.workspace_id
13702 && binding.generation == session.session.generation
13703 })
13704 .map_or(true, |binding| binding.environment_profile.is_some()),
13705 NodeRequest::RenameSessionRecord { record_id, .. }
13706 | NodeRequest::SetSessionTask { record_id, .. }
13707 | NodeRequest::ResumeSessionRecord { record_id, .. }
13708 | NodeRequest::ForgetSessionRecord { record_id } => shared
13709 .record(record_id)
13710 .map_or(true, |record| record.environment_profile.is_some()),
13711 NodeRequest::ArmHarnessMcpReservation { .. }
13712 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13713 | NodeRequest::ActivateHarnessMcpReservation { .. }
13714 | NodeRequest::AbortHarnessMcpReservation { .. }
13715 | NodeRequest::PutHarnessMcpReplyChunk { .. }
13716 | NodeRequest::RejectHarnessMcpCall { .. }
13717 | NodeRequest::Snapshot
13718 | NodeRequest::Resync { .. }
13719 | NodeRequest::BeginDeliveryStage { .. }
13720 | NodeRequest::PutDeliveryBlobChunk { .. }
13721 | NodeRequest::CommitDeliveryStage { .. }
13722 | NodeRequest::AbortDeliveryStage { .. }
13723 | NodeRequest::BrowseHostDirectories { .. }
13724 | NodeRequest::InspectWorkspace { .. }
13725 | NodeRequest::ReadWorkspaceFile { .. }
13726 | NodeRequest::WriteWorkspaceFile { .. }
13727 | NodeRequest::CreateWorkspaceFile { .. }
13728 | NodeRequest::CreateWorkspaceDirectory { .. }
13729 | NodeRequest::ReadGitHistory { .. }
13730 | NodeRequest::ReadGitDiff { .. }
13731 | NodeRequest::AcquireController { .. }
13732 | NodeRequest::ReleaseController
13733 | NodeRequest::Spawn { .. }
13734 | NodeRequest::SpawnSpec { .. }
13735 | NodeRequest::RegisterWorkspace { .. }
13736 | NodeRequest::CreateStandaloneWorkspace { .. }
13737 | NodeRequest::UnregisterWorkspace { .. }
13738 | NodeRequest::CreateWorktree { .. }
13739 | NodeRequest::RemoveWorktree { .. }
13740 | NodeRequest::SpawnManagedWorktree { .. }
13741 | NodeRequest::SpawnManagedWorktreeV2 { .. }
13742 | NodeRequest::CleanupManagedWorktree { .. }
13743 | NodeRequest::IndexProviderSession { .. }
13744 | NodeRequest::IndexNativeSession { .. }
13745 | NodeRequest::CatalogNativeSessions { .. }
13746 | NodeRequest::PageNativeSessions { .. }
13747 | NodeRequest::PreviewNativeSession { .. }
13748 | NodeRequest::PreviewSessionRecord { .. }
13749 | NodeRequest::ForgetContextPack { .. }
13750 | NodeRequest::ResolveDurableContextPack { .. }
13751 | NodeRequest::ReadContextPack { .. }
13752 | NodeRequest::Shutdown => false,
13753 }
13754}
13755
13756fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
13757 if let Some(spec) = match request {
13758 NodeRequest::SpawnSpec { spec }
13759 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13760 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13761 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13762 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13763 _ => None,
13764 } {
13765 return shared
13766 .resolve_spawn_spec(spec)
13767 .is_ok_and(|resolved| resolved.bundle_id.is_some());
13768 }
13769 match request {
13770 NodeRequest::Resume { session, .. }
13771 | NodeRequest::Prompt { session, .. }
13772 | NodeRequest::Paste { session, .. }
13773 | NodeRequest::Input { session, .. }
13774 | NodeRequest::TerminalBytes { session, .. }
13775 | NodeRequest::TerminalControl { session, .. }
13776 | NodeRequest::Resize { session, .. }
13777 | NodeRequest::Interrupt { session }
13778 | NodeRequest::Stop { session, .. }
13779 | NodeRequest::Remove { session }
13780 | NodeRequest::ResolveInteraction { session, .. }
13781 | NodeRequest::SetSessionMode { session, .. }
13782 | NodeRequest::SetSessionConfigOption { session, .. }
13783 | NodeRequest::SetSessionModel { session, .. }
13784 | NodeRequest::DiscoverHistory { session, .. }
13785 | NodeRequest::LoadHistory { session, .. }
13786 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13787 | NodeRequest::ExportContextPack { session } => shared
13788 .session_bindings
13789 .lock()
13790 .unwrap_or_else(|poisoned| poisoned.into_inner())
13791 .get(&session.session.instance_id)
13792 .filter(|binding| {
13793 binding.workspace_id == session.workspace_id
13794 && binding.generation == session.session.generation
13795 })
13796 .map_or(true, |binding| binding.bundle.is_some()),
13797 NodeRequest::RenameSessionRecord { record_id, .. }
13798 | NodeRequest::SetSessionTask { record_id, .. }
13799 | NodeRequest::ResumeSessionRecord { record_id, .. }
13800 | NodeRequest::ForgetSessionRecord { record_id } => shared
13801 .record(record_id)
13802 .map_or(true, |record| record.bundle.is_some()),
13803 NodeRequest::ArmHarnessMcpReservation { .. }
13804 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13805 | NodeRequest::ActivateHarnessMcpReservation { .. }
13806 | NodeRequest::AbortHarnessMcpReservation { .. }
13807 | NodeRequest::PutHarnessMcpReplyChunk { .. }
13808 | NodeRequest::RejectHarnessMcpCall { .. }
13809 | NodeRequest::Snapshot
13810 | NodeRequest::Resync { .. }
13811 | NodeRequest::BeginDeliveryStage { .. }
13812 | NodeRequest::PutDeliveryBlobChunk { .. }
13813 | NodeRequest::CommitDeliveryStage { .. }
13814 | NodeRequest::AbortDeliveryStage { .. }
13815 | NodeRequest::BrowseHostDirectories { .. }
13816 | NodeRequest::InspectWorkspace { .. }
13817 | NodeRequest::ReadWorkspaceFile { .. }
13818 | NodeRequest::WriteWorkspaceFile { .. }
13819 | NodeRequest::CreateWorkspaceFile { .. }
13820 | NodeRequest::CreateWorkspaceDirectory { .. }
13821 | NodeRequest::ReadGitHistory { .. }
13822 | NodeRequest::ReadGitDiff { .. }
13823 | NodeRequest::AcquireController { .. }
13824 | NodeRequest::ReleaseController
13825 | NodeRequest::Spawn { .. }
13826 | NodeRequest::SpawnSpec { .. }
13827 | NodeRequest::RegisterWorkspace { .. }
13828 | NodeRequest::CreateStandaloneWorkspace { .. }
13829 | NodeRequest::UnregisterWorkspace { .. }
13830 | NodeRequest::CreateWorktree { .. }
13831 | NodeRequest::RemoveWorktree { .. }
13832 | NodeRequest::SpawnManagedWorktree { .. }
13833 | NodeRequest::SpawnManagedWorktreeV2 { .. }
13834 | NodeRequest::CleanupManagedWorktree { .. }
13835 | NodeRequest::IndexProviderSession { .. }
13836 | NodeRequest::IndexNativeSession { .. }
13837 | NodeRequest::CatalogNativeSessions { .. }
13838 | NodeRequest::PageNativeSessions { .. }
13839 | NodeRequest::PreviewNativeSession { .. }
13840 | NodeRequest::PreviewSessionRecord { .. }
13841 | NodeRequest::ForgetContextPack { .. }
13842 | NodeRequest::ResolveDurableContextPack { .. }
13843 | NodeRequest::ReadContextPack { .. }
13844 | NodeRequest::Shutdown => false,
13845 }
13846}
13847
13848fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
13849 if request.requires_history_context_pack_capability() {
13850 return true;
13851 }
13852 if let Some(spec) = match request {
13853 NodeRequest::SpawnSpec { spec }
13854 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13855 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13856 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13857 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13858 _ => None,
13859 } {
13860 return shared
13861 .resolve_spawn_spec(spec)
13862 .map_or(true, |resolved| resolved.context_id.is_some());
13863 }
13864 match request {
13865 NodeRequest::Resume { session, .. }
13866 | NodeRequest::Prompt { session, .. }
13867 | NodeRequest::Paste { session, .. }
13868 | NodeRequest::Input { session, .. }
13869 | NodeRequest::TerminalBytes { session, .. }
13870 | NodeRequest::TerminalControl { session, .. }
13871 | NodeRequest::Resize { session, .. }
13872 | NodeRequest::Interrupt { session }
13873 | NodeRequest::Stop { session, .. }
13874 | NodeRequest::Remove { session }
13875 | NodeRequest::ResolveInteraction { session, .. }
13876 | NodeRequest::SetSessionMode { session, .. }
13877 | NodeRequest::SetSessionConfigOption { session, .. }
13878 | NodeRequest::SetSessionModel { session, .. } => shared
13879 .session_bindings
13880 .lock()
13881 .unwrap_or_else(|poisoned| poisoned.into_inner())
13882 .get(&session.session.instance_id)
13883 .filter(|binding| {
13884 binding.workspace_id == session.workspace_id
13885 && binding.generation == session.session.generation
13886 })
13887 .map_or(true, |binding| binding.context.is_some()),
13888 NodeRequest::RenameSessionRecord { record_id, .. }
13889 | NodeRequest::SetSessionTask { record_id, .. }
13890 | NodeRequest::ResumeSessionRecord { record_id, .. }
13891 | NodeRequest::ForgetSessionRecord { record_id } => shared
13892 .record(record_id)
13893 .map_or(true, |record| record.context.is_some()),
13894 NodeRequest::ArmHarnessMcpReservation { .. }
13895 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13896 | NodeRequest::ActivateHarnessMcpReservation { .. }
13897 | NodeRequest::AbortHarnessMcpReservation { .. }
13898 | NodeRequest::PutHarnessMcpReplyChunk { .. }
13899 | NodeRequest::RejectHarnessMcpCall { .. }
13900 | NodeRequest::Snapshot
13901 | NodeRequest::Resync { .. }
13902 | NodeRequest::BeginDeliveryStage { .. }
13903 | NodeRequest::PutDeliveryBlobChunk { .. }
13904 | NodeRequest::CommitDeliveryStage { .. }
13905 | NodeRequest::AbortDeliveryStage { .. }
13906 | NodeRequest::BrowseHostDirectories { .. }
13907 | NodeRequest::InspectWorkspace { .. }
13908 | NodeRequest::ReadWorkspaceFile { .. }
13909 | NodeRequest::WriteWorkspaceFile { .. }
13910 | NodeRequest::CreateWorkspaceFile { .. }
13911 | NodeRequest::CreateWorkspaceDirectory { .. }
13912 | NodeRequest::ReadGitHistory { .. }
13913 | NodeRequest::ReadGitDiff { .. }
13914 | NodeRequest::AcquireController { .. }
13915 | NodeRequest::ReleaseController
13916 | NodeRequest::Spawn { .. }
13917 | NodeRequest::SpawnSpec { .. }
13918 | NodeRequest::RegisterWorkspace { .. }
13919 | NodeRequest::CreateStandaloneWorkspace { .. }
13920 | NodeRequest::UnregisterWorkspace { .. }
13921 | NodeRequest::CreateWorktree { .. }
13922 | NodeRequest::RemoveWorktree { .. }
13923 | NodeRequest::SpawnManagedWorktree { .. }
13924 | NodeRequest::SpawnManagedWorktreeV2 { .. }
13925 | NodeRequest::CleanupManagedWorktree { .. }
13926 | NodeRequest::IndexProviderSession { .. }
13927 | NodeRequest::IndexNativeSession { .. }
13928 | NodeRequest::CatalogNativeSessions { .. }
13929 | NodeRequest::PageNativeSessions { .. }
13930 | NodeRequest::PreviewNativeSession { .. }
13931 | NodeRequest::PreviewSessionRecord { .. }
13932 | NodeRequest::DiscoverHistory { .. }
13933 | NodeRequest::LoadHistory { .. }
13934 | NodeRequest::ExportContextPackForSessionRecord { .. }
13935 | NodeRequest::ExportContextPack { .. }
13936 | NodeRequest::ForgetContextPack { .. }
13937 | NodeRequest::ResolveDurableContextPack { .. }
13938 | NodeRequest::ReadContextPack { .. }
13939 | NodeRequest::Shutdown => false,
13940 }
13941}
13942
13943fn request_requires_open_provider_ids_with(
13944 request: &NodeRequest,
13945 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
13946 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
13947) -> bool {
13948 match request {
13949 NodeRequest::Spawn { provider, .. } => !provider_id_is_legacy(provider),
13950 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
13951 NodeRequest::CatalogNativeSessions { route, .. }
13952 | NodeRequest::PageNativeSessions { route, .. } => {
13953 !provider_id_is_legacy(&route.provider)
13954 }
13955 NodeRequest::PreviewNativeSession { selection, .. }
13956 | NodeRequest::IndexNativeSession { selection, .. } => {
13957 !provider_id_is_legacy(&selection.route.provider)
13958 }
13959 NodeRequest::SpawnSpec { .. }
13960 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
13961 NodeRequest::SpawnManagedWorktree { .. }
13962 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
13963 NodeRequest::Resume { session, .. }
13964 | NodeRequest::Prompt { session, .. }
13965 | NodeRequest::Paste { session, .. }
13966 | NodeRequest::Input { session, .. }
13967 | NodeRequest::TerminalBytes { session, .. }
13968 | NodeRequest::TerminalControl { session, .. }
13969 | NodeRequest::Resize { session, .. }
13970 | NodeRequest::Interrupt { session }
13971 | NodeRequest::Stop { session, .. }
13972 | NodeRequest::Remove { session }
13973 | NodeRequest::ResolveInteraction { session, .. }
13974 | NodeRequest::SetSessionMode { session, .. }
13975 | NodeRequest::SetSessionConfigOption { session, .. }
13976 | NodeRequest::SetSessionModel { session, .. }
13977 | NodeRequest::DiscoverHistory { session, .. }
13978 | NodeRequest::LoadHistory { session, .. }
13979 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13980 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
13981 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
13982 NodeRequest::RenameSessionRecord { record_id, .. }
13983 | NodeRequest::SetSessionTask { record_id, .. }
13984 | NodeRequest::ResumeSessionRecord { record_id, .. }
13985 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
13986 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
13987 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
13988 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
13989 NodeRequest::ArmHarnessMcpReservation { .. }
13990 | NodeRequest::ActivateHarnessMcpReservation { .. }
13991 | NodeRequest::AbortHarnessMcpReservation { .. }
13992 | NodeRequest::PutHarnessMcpReplyChunk { .. }
13993 | NodeRequest::RejectHarnessMcpCall { .. }
13994 | NodeRequest::Snapshot
13995 | NodeRequest::Resync { .. }
13996 | NodeRequest::BeginDeliveryStage { .. }
13997 | NodeRequest::PutDeliveryBlobChunk { .. }
13998 | NodeRequest::CommitDeliveryStage { .. }
13999 | NodeRequest::AbortDeliveryStage { .. }
14000 | NodeRequest::BrowseHostDirectories { .. }
14001 | NodeRequest::InspectWorkspace { .. }
14002 | NodeRequest::ReadWorkspaceFile { .. }
14003 | NodeRequest::WriteWorkspaceFile { .. }
14004 | NodeRequest::CreateWorkspaceFile { .. }
14005 | NodeRequest::CreateWorkspaceDirectory { .. }
14006 | NodeRequest::ReadGitHistory { .. }
14007 | NodeRequest::ReadGitDiff { .. }
14008 | NodeRequest::AcquireController { .. }
14009 | NodeRequest::ReleaseController
14010 | NodeRequest::RegisterWorkspace { .. }
14011 | NodeRequest::CreateStandaloneWorkspace { .. }
14012 | NodeRequest::UnregisterWorkspace { .. }
14013 | NodeRequest::CreateWorktree { .. }
14014 | NodeRequest::RemoveWorktree { .. }
14015 | NodeRequest::CleanupManagedWorktree { .. }
14016 | NodeRequest::Shutdown => false,
14017 NodeRequest::ForgetContextPack { .. } => true,
14018 NodeRequest::ResolveDurableContextPack { .. }
14024 | NodeRequest::ReadContextPack { .. } => true,
14025 }
14026}
14027
14028fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14029 snapshot.enabled_providers.retain(provider_id_is_legacy);
14030 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14031 snapshot
14032 .provider_runtime_statuses
14033 .as_slice()
14034 .iter()
14035 .filter(|status| provider_id_is_legacy(status.provider()))
14036 .cloned(),
14037 )
14038 .expect("a filtered provider runtime projection remains valid");
14039 snapshot
14040 .session_records
14041 .retain(|record| provider_id_is_legacy(&record.provider));
14042 for workspace in &mut snapshot.workspaces {
14043 workspace
14044 .sessions
14045 .retain(|session| provider_id_is_legacy(&session.agent_id));
14046 }
14047}
14048
14049fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14050 for record in &mut snapshot.session_records {
14051 record.environment_profile = None;
14052 }
14053 if let Some(profiles) = snapshot
14054 .launch_inventory
14055 .as_mut()
14056 .and_then(|inventory| inventory.spawn_profiles.as_mut())
14057 {
14058 for profile in profiles {
14059 profile.environment_profile = None;
14060 }
14061 }
14062}
14063
14064fn project_event_without_child_environment_profile(
14065 mut envelope: NodeEventEnvelope,
14066) -> NodeEventEnvelope {
14067 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14068 record.environment_profile = None;
14069 }
14070 envelope
14071}
14072
14073fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14074 let contains_environment_profile = match reply.result.as_ref() {
14075 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14076 receipt.environment_profile.is_some()
14077 }
14078 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14079 receipt.spawn.environment_profile.is_some()
14080 }
14081 _ => false,
14082 };
14083 if contains_environment_profile {
14084 reply.result = Err(failure(
14085 NodeFailureCode::UnsupportedCapability,
14086 "child environment profiles were not negotiated",
14087 ));
14088 return;
14089 }
14090 let Ok(response) = reply.result.as_mut() else {
14091 return;
14092 };
14093 match response {
14094 NodeResponse::Snapshot { snapshot, .. } => {
14095 clear_snapshot_child_environment_profiles(snapshot);
14096 }
14097 NodeResponse::Resync { snapshot, events, .. } => {
14098 clear_snapshot_child_environment_profiles(snapshot);
14099 for event in events {
14100 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14101 record.environment_profile = None;
14102 }
14103 }
14104 }
14105 NodeResponse::SessionRecordUpdated { record }
14106 | NodeResponse::ProviderSessionIndexed { record }
14107 | NodeResponse::NativeSessionIndexed { record, .. }
14108 | NodeResponse::SessionRecordResumed { record, .. } => {
14109 record.environment_profile = None;
14110 }
14111 NodeResponse::Armed { .. }
14112 | NodeResponse::Spawned { .. }
14113 | NodeResponse::Activated { .. }
14114 | NodeResponse::Aborted { .. }
14115 | NodeResponse::ReplyChunkAccepted { .. }
14116 | NodeResponse::CallRejected { .. }
14117 | NodeResponse::DeliveryStageBegun { .. }
14118 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14119 | NodeResponse::DeliveryCommitted { .. }
14120 | NodeResponse::DeliveryStageAborted { .. }
14121 | NodeResponse::WorkspaceInspected { .. }
14122 | NodeResponse::HostDirectoriesBrowsed { .. }
14123 | NodeResponse::WorkspaceFileRead { .. }
14124 | NodeResponse::WorkspaceFileWritten { .. }
14125 | NodeResponse::WorkspaceFileCreated { .. }
14126 | NodeResponse::WorkspaceDirectoryCreated { .. }
14127 | NodeResponse::GitHistoryRead { .. }
14128 | NodeResponse::GitDiffRead { .. }
14129 | NodeResponse::Controller { .. }
14130 | NodeResponse::SpawnAccepted { .. }
14131 | NodeResponse::SpawnSpecAccepted { .. }
14132 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14133 | NodeResponse::ManagedWorktreeCleanup { .. }
14134 | NodeResponse::NativeSessionsCataloged { .. }
14135 | NodeResponse::NativeSessionsPaged { .. }
14136 | NodeResponse::NativeSessionPreviewed { .. }
14137 | NodeResponse::SessionRecordPreviewed { .. }
14138 | NodeResponse::HistoryDiscovered { .. }
14139 | NodeResponse::HistoryLoaded { .. }
14140 | NodeResponse::ContextPackForSessionRecordExported { .. }
14141 | NodeResponse::ContextPackExported { .. }
14142 | NodeResponse::ContextPackForgotten { .. }
14143 | NodeResponse::DurableContextPackResolved { .. }
14144 | NodeResponse::ContextPackBytesRead { .. }
14145 | NodeResponse::SessionRecordForgotten { .. }
14146 | NodeResponse::WorkspaceRegistered { .. }
14147 | NodeResponse::StandaloneWorkspaceCreated { .. }
14148 | NodeResponse::WorkspaceUnregistered { .. }
14149 | NodeResponse::WorktreeCreated { .. }
14150 | NodeResponse::WorktreeRemoved { .. }
14151 | NodeResponse::Accepted
14152 | NodeResponse::ShuttingDown => {}
14153 }
14154}
14155
14156fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14157 for record in &mut snapshot.session_records {
14158 record.bundle = None;
14159 }
14160}
14161
14162fn project_event_without_session_bundle(
14163 mut envelope: NodeEventEnvelope,
14164) -> NodeEventEnvelope {
14165 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14166 record.bundle = None;
14167 }
14168 envelope
14169}
14170
14171fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14172 let contains_bundle = match reply.result.as_ref() {
14173 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14174 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14175 receipt.spawn.bundle.is_some()
14176 }
14177 _ => false,
14178 };
14179 if contains_bundle {
14180 reply.result = Err(failure(
14181 NodeFailureCode::UnsupportedCapability,
14182 "session bundles were not negotiated",
14183 ));
14184 return;
14185 }
14186 let Ok(response) = reply.result.as_mut() else {
14187 return;
14188 };
14189 match response {
14190 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14191 NodeResponse::Resync { snapshot, events, .. } => {
14192 clear_snapshot_session_bundles(snapshot);
14193 for event in events {
14194 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14195 record.bundle = None;
14196 }
14197 }
14198 }
14199 NodeResponse::SessionRecordUpdated { record }
14200 | NodeResponse::ProviderSessionIndexed { record }
14201 | NodeResponse::NativeSessionIndexed { record, .. }
14202 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14203 NodeResponse::Armed { .. }
14204 | NodeResponse::Spawned { .. }
14205 | NodeResponse::Activated { .. }
14206 | NodeResponse::Aborted { .. }
14207 | NodeResponse::ReplyChunkAccepted { .. }
14208 | NodeResponse::CallRejected { .. }
14209 | NodeResponse::DeliveryStageBegun { .. }
14210 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14211 | NodeResponse::DeliveryCommitted { .. }
14212 | NodeResponse::DeliveryStageAborted { .. }
14213 | NodeResponse::WorkspaceInspected { .. }
14214 | NodeResponse::HostDirectoriesBrowsed { .. }
14215 | NodeResponse::WorkspaceFileRead { .. }
14216 | NodeResponse::WorkspaceFileWritten { .. }
14217 | NodeResponse::WorkspaceFileCreated { .. }
14218 | NodeResponse::WorkspaceDirectoryCreated { .. }
14219 | NodeResponse::GitHistoryRead { .. }
14220 | NodeResponse::GitDiffRead { .. }
14221 | NodeResponse::Controller { .. }
14222 | NodeResponse::SpawnAccepted { .. }
14223 | NodeResponse::SpawnSpecAccepted { .. }
14224 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14225 | NodeResponse::ManagedWorktreeCleanup { .. }
14226 | NodeResponse::NativeSessionsCataloged { .. }
14227 | NodeResponse::NativeSessionsPaged { .. }
14228 | NodeResponse::NativeSessionPreviewed { .. }
14229 | NodeResponse::SessionRecordPreviewed { .. }
14230 | NodeResponse::HistoryDiscovered { .. }
14231 | NodeResponse::HistoryLoaded { .. }
14232 | NodeResponse::ContextPackForSessionRecordExported { .. }
14233 | NodeResponse::ContextPackExported { .. }
14234 | NodeResponse::ContextPackForgotten { .. }
14235 | NodeResponse::DurableContextPackResolved { .. }
14236 | NodeResponse::ContextPackBytesRead { .. }
14237 | NodeResponse::SessionRecordForgotten { .. }
14238 | NodeResponse::WorkspaceRegistered { .. }
14239 | NodeResponse::StandaloneWorkspaceCreated { .. }
14240 | NodeResponse::WorkspaceUnregistered { .. }
14241 | NodeResponse::WorktreeCreated { .. }
14242 | NodeResponse::WorktreeRemoved { .. }
14243 | NodeResponse::Accepted
14244 | NodeResponse::ShuttingDown => {}
14245 }
14246}
14247
14248fn project_snapshot_history_for_wire(
14249 snapshot: &mut NodeSnapshot,
14250 include_history_metadata: bool,
14251) {
14252 for workspace in &mut snapshot.workspaces {
14253 for session in &mut workspace.sessions {
14254 if include_history_metadata {
14255 session.history.loaded = None;
14256 } else {
14257 session.history = Default::default();
14258 }
14259 }
14260 }
14261}
14262
14263fn project_event_history_for_wire(
14264 mut envelope: NodeEventEnvelope,
14265 include_history_metadata: bool,
14266) -> NodeEventEnvelope {
14267 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14268 for session in &mut workspace.sessions {
14269 if include_history_metadata {
14270 session.history.loaded = None;
14271 } else {
14272 session.history = Default::default();
14273 }
14274 }
14275 }
14276 envelope
14277}
14278
14279fn project_response_history_for_wire(
14280 reply: &mut ResponseEnvelope,
14281 include_history_metadata: bool,
14282) {
14283 let Ok(response) = reply.result.as_mut() else {
14284 return;
14285 };
14286 match response {
14287 NodeResponse::Snapshot { snapshot, .. } => {
14288 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14289 }
14290 NodeResponse::Resync {
14291 snapshot, events, ..
14292 } => {
14293 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14294 for event in events {
14295 *event = project_event_history_for_wire(
14296 event.clone(),
14297 include_history_metadata,
14298 );
14299 }
14300 }
14301 NodeResponse::WorkspaceRegistered { workspace }
14302 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14303 | NodeResponse::WorktreeCreated { workspace, .. } => {
14304 for session in &mut workspace.sessions {
14305 if include_history_metadata {
14306 session.history.loaded = None;
14307 } else {
14308 session.history = Default::default();
14309 }
14310 }
14311 }
14312 _ => {}
14313 }
14314}
14315
14316fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14317 for record in &mut snapshot.session_records {
14318 record.context_id = None;
14319 record.context = None;
14320 record.exported_context = None;
14321 }
14322}
14323
14324fn project_event_without_context_pack(
14325 mut envelope: NodeEventEnvelope,
14326) -> NodeEventEnvelope {
14327 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14328 record.context_id = None;
14329 record.context = None;
14330 record.exported_context = None;
14331 }
14332 envelope
14333}
14334
14335fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14336 let contains_context = match reply.result.as_ref() {
14337 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14338 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14339 receipt.spawn.context.is_some()
14340 }
14341 Ok(NodeResponse::HistoryDiscovered { .. })
14342 | Ok(NodeResponse::HistoryLoaded { .. })
14343 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14344 | Ok(NodeResponse::ContextPackExported { .. })
14345 | Ok(NodeResponse::ContextPackForgotten { .. })
14346 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14347 _ => false,
14348 };
14349 if contains_context {
14350 reply.result = Err(failure(
14351 NodeFailureCode::UnsupportedCapability,
14352 "history context packs were not negotiated",
14353 ));
14354 return;
14355 }
14356 let Ok(response) = reply.result.as_mut() else {
14357 return;
14358 };
14359 match response {
14360 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
14361 NodeResponse::Resync {
14362 snapshot, events, ..
14363 } => {
14364 clear_snapshot_context_packs(snapshot);
14365 for event in events {
14366 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14367 record.context_id = None;
14368 record.context = None;
14369 record.exported_context = None;
14370 }
14371 }
14372 }
14373 NodeResponse::SessionRecordUpdated { record }
14374 | NodeResponse::ProviderSessionIndexed { record }
14375 | NodeResponse::NativeSessionIndexed { record, .. }
14376 | NodeResponse::SessionRecordResumed { record, .. } => {
14377 record.context_id = None;
14378 record.context = None;
14379 record.exported_context = None;
14380 }
14381 _ => {}
14382 }
14383}
14384
14385fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
14386 let contains_open_record = match reply.result.as_ref() {
14387 Ok(NodeResponse::SessionRecordUpdated { record })
14388 | Ok(NodeResponse::ProviderSessionIndexed { record })
14389 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
14390 !provider_id_is_legacy(&record.provider)
14391 }
14392 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
14393 !provider_id_is_legacy(&selection.route.provider)
14394 || !provider_id_is_legacy(&record.provider)
14395 }
14396 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14397 !provider_id_is_legacy(&receipt.provider)
14398 }
14399 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14400 !provider_id_is_legacy(&receipt.spawn.provider)
14401 }
14402 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
14403 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
14404 !provider_id_is_legacy(&route.provider)
14405 }
14406 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
14407 !provider_id_is_legacy(&selection.route.provider)
14408 }
14409 _ => false,
14410 };
14411 if contains_open_record {
14412 reply.result = Err(failure(
14413 NodeFailureCode::UnsupportedCapability,
14414 "open provider IDs were not negotiated",
14415 ));
14416 return;
14417 }
14418 let Ok(response) = reply.result.as_mut() else {
14419 return;
14420 };
14421 match response {
14422 NodeResponse::Snapshot { snapshot, .. } => {
14423 project_snapshot_legacy_provider_ids(snapshot);
14424 }
14425 NodeResponse::Resync { snapshot, events, .. } => {
14426 project_snapshot_legacy_provider_ids(snapshot);
14427 *events = std::mem::take(events)
14428 .into_iter()
14429 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
14430 .collect();
14431 }
14432 NodeResponse::WorkspaceRegistered { workspace }
14433 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14434 | NodeResponse::WorktreeCreated { workspace, .. } => {
14435 workspace
14436 .sessions
14437 .retain(|session| provider_id_is_legacy(&session.agent_id));
14438 }
14439 NodeResponse::Armed { .. }
14440 | NodeResponse::Spawned { .. }
14441 | NodeResponse::Activated { .. }
14442 | NodeResponse::Aborted { .. }
14443 | NodeResponse::ReplyChunkAccepted { .. }
14444 | NodeResponse::CallRejected { .. }
14445 | NodeResponse::DeliveryStageBegun { .. }
14446 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14447 | NodeResponse::DeliveryCommitted { .. }
14448 | NodeResponse::DeliveryStageAborted { .. }
14449 | NodeResponse::WorkspaceInspected { .. }
14450 | NodeResponse::HostDirectoriesBrowsed { .. }
14451 | NodeResponse::WorkspaceFileRead { .. }
14452 | NodeResponse::WorkspaceFileWritten { .. }
14453 | NodeResponse::WorkspaceFileCreated { .. }
14454 | NodeResponse::WorkspaceDirectoryCreated { .. }
14455 | NodeResponse::GitHistoryRead { .. }
14456 | NodeResponse::GitDiffRead { .. }
14457 | NodeResponse::Controller { .. }
14458 | NodeResponse::SpawnAccepted { .. }
14459 | NodeResponse::SpawnSpecAccepted { .. }
14460 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14461 | NodeResponse::ManagedWorktreeCleanup { .. }
14462 | NodeResponse::NativeSessionsCataloged { .. }
14463 | NodeResponse::NativeSessionsPaged { .. }
14464 | NodeResponse::NativeSessionPreviewed { .. }
14465 | NodeResponse::SessionRecordPreviewed { .. }
14466 | NodeResponse::HistoryDiscovered { .. }
14467 | NodeResponse::HistoryLoaded { .. }
14468 | NodeResponse::ContextPackForSessionRecordExported { .. }
14469 | NodeResponse::ContextPackExported { .. }
14470 | NodeResponse::ContextPackForgotten { .. }
14471 | NodeResponse::DurableContextPackResolved { .. }
14472 | NodeResponse::ContextPackBytesRead { .. }
14473 | NodeResponse::SessionRecordUpdated { .. }
14474 | NodeResponse::ProviderSessionIndexed { .. }
14475 | NodeResponse::NativeSessionIndexed { .. }
14476 | NodeResponse::SessionRecordResumed { .. }
14477 | NodeResponse::SessionRecordForgotten { .. }
14478 | NodeResponse::WorkspaceUnregistered { .. }
14479 | NodeResponse::WorktreeRemoved { .. }
14480 | NodeResponse::Accepted
14481 | NodeResponse::ShuttingDown => {}
14482 }
14483}
14484
14485fn project_event_legacy_provider_ids(
14486 shared: &NodeShared,
14487 mut envelope: NodeEventEnvelope,
14488) -> Option<NodeEventEnvelope> {
14489 let include = match &mut envelope.event {
14490 NodeEvent::HarnessMcpReadCall { .. } => true,
14491 NodeEvent::Control { address, .. }
14492 | NodeEvent::AgentStream { address, .. }
14493 | NodeEvent::TerminalFrame { address, .. } => shared
14494 .validate_address(address)
14495 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
14496 NodeEvent::WorkspaceAdded { workspace } => {
14497 workspace
14498 .sessions
14499 .retain(|session| provider_id_is_legacy(&session.agent_id));
14500 true
14501 }
14502 NodeEvent::SessionRecordUpserted { record } => {
14503 provider_id_is_legacy(&record.provider)
14504 }
14505 NodeEvent::SessionRecordHistorySummarized { .. } => false,
14506 NodeEvent::SessionRecordRemoved { .. } => shared
14507 .history
14508 .lock()
14509 .unwrap_or_else(|poisoned| poisoned.into_inner())
14510 .removed_record_providers
14511 .get(&envelope.sequence)
14512 .is_some_and(provider_id_is_legacy),
14513 NodeEvent::ControllerChanged { .. }
14514 | NodeEvent::ManagedWorktreeUpserted { .. }
14515 | NodeEvent::ManagedWorktreeRemoved { .. }
14516 | NodeEvent::WorkspaceRemoved { .. }
14517 | NodeEvent::ResyncRequired { .. } => true,
14518 };
14519 include.then_some(envelope)
14520}
14521
14522fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
14523 let Ok(response) = reply.result.as_mut() else {
14524 return;
14525 };
14526 match response {
14527 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14528 snapshot.provider_runtime_statuses.clear();
14529 }
14530 NodeResponse::Armed { .. }
14531 | NodeResponse::Spawned { .. }
14532 | NodeResponse::Activated { .. }
14533 | NodeResponse::Aborted { .. }
14534 | NodeResponse::ReplyChunkAccepted { .. }
14535 | NodeResponse::CallRejected { .. }
14536 | NodeResponse::DeliveryStageBegun { .. }
14537 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14538 | NodeResponse::DeliveryCommitted { .. }
14539 | NodeResponse::DeliveryStageAborted { .. }
14540 | NodeResponse::WorkspaceInspected { .. }
14541 | NodeResponse::HostDirectoriesBrowsed { .. }
14542 | NodeResponse::WorkspaceFileRead { .. }
14543 | NodeResponse::WorkspaceFileWritten { .. }
14544 | NodeResponse::WorkspaceFileCreated { .. }
14545 | NodeResponse::WorkspaceDirectoryCreated { .. }
14546 | NodeResponse::GitHistoryRead { .. }
14547 | NodeResponse::GitDiffRead { .. }
14548 | NodeResponse::Controller { .. }
14549 | NodeResponse::SpawnAccepted { .. }
14550 | NodeResponse::SpawnSpecAccepted { .. }
14551 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14552 | NodeResponse::ManagedWorktreeCleanup { .. }
14553 | NodeResponse::NativeSessionsCataloged { .. }
14554 | NodeResponse::NativeSessionsPaged { .. }
14555 | NodeResponse::NativeSessionPreviewed { .. }
14556 | NodeResponse::SessionRecordPreviewed { .. }
14557 | NodeResponse::HistoryDiscovered { .. }
14558 | NodeResponse::HistoryLoaded { .. }
14559 | NodeResponse::ContextPackForSessionRecordExported { .. }
14560 | NodeResponse::ContextPackExported { .. }
14561 | NodeResponse::ContextPackForgotten { .. }
14562 | NodeResponse::DurableContextPackResolved { .. }
14563 | NodeResponse::ContextPackBytesRead { .. }
14564 | NodeResponse::SessionRecordUpdated { .. }
14565 | NodeResponse::ProviderSessionIndexed { .. }
14566 | NodeResponse::NativeSessionIndexed { .. }
14567 | NodeResponse::SessionRecordResumed { .. }
14568 | NodeResponse::SessionRecordForgotten { .. }
14569 | NodeResponse::WorkspaceRegistered { .. }
14570 | NodeResponse::StandaloneWorkspaceCreated { .. }
14571 | NodeResponse::WorkspaceUnregistered { .. }
14572 | NodeResponse::WorktreeCreated { .. }
14573 | NodeResponse::WorktreeRemoved { .. }
14574 | NodeResponse::Accepted
14575 | NodeResponse::ShuttingDown => {}
14576 }
14577}
14578
14579struct ControllerReleaseGuard {
14580 shared: Arc<NodeShared>,
14581 connection_id: u64,
14582}
14583
14584impl Drop for ControllerReleaseGuard {
14585 fn drop(&mut self) {
14586 self.shared.release_controller(self.connection_id);
14587 }
14588}
14589
14590struct AbortTaskOnDrop(AbortHandle);
14591
14592impl Drop for AbortTaskOnDrop {
14593 fn drop(&mut self) {
14594 self.0.abort();
14595 }
14596}
14597
14598fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
14603 std::env::var(name)
14604 .ok()
14605 .and_then(|value| value.parse::<u64>().ok())
14606 .filter(|value| *value > 0)
14607 .unwrap_or(default)
14608 .min(max)
14609}
14610
14611fn workspace_inspection_time_budget_ms() -> u64 {
14612 env_config_bounded(
14613 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
14614 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
14615 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
14616 )
14617}
14618
14619fn workspace_inspection_entry_cap() -> usize {
14620 env_config_bounded(
14621 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
14622 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
14623 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
14624 ) as usize
14625}
14626
14627struct WorkspaceWalkBudget {
14633 deadline: Instant,
14634 entry_cap: usize,
14635 entries_visited: u64,
14636 time_budget_exceeded: bool,
14637 entry_cap_exceeded: bool,
14638}
14639
14640impl WorkspaceWalkBudget {
14641 fn new(deadline: Instant, entry_cap: usize) -> Self {
14642 Self {
14643 deadline,
14644 entry_cap,
14645 entries_visited: 0,
14646 time_budget_exceeded: false,
14647 entry_cap_exceeded: false,
14648 }
14649 }
14650
14651 fn is_exhausted(&self) -> bool {
14652 self.time_budget_exceeded || self.entry_cap_exceeded
14653 }
14654
14655 fn record_visit(&mut self) -> bool {
14658 self.entries_visited += 1;
14659 if self.entries_visited as usize >= self.entry_cap {
14660 self.entry_cap_exceeded = true;
14661 }
14662 if Instant::now() >= self.deadline {
14663 self.time_budget_exceeded = true;
14664 }
14665 self.is_exhausted()
14666 }
14667}
14668
14669fn collect_workspace_entries(
14670 root: &Path,
14671 deadline: Instant,
14672 entry_cap: usize,
14673) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
14674 let mut entries = Vec::new();
14675 let mut truncated = false;
14676 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
14677 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
14678 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
14682 (entries, truncated, budget)
14683}
14684
14685fn workspace_inspection_truncation(
14689 walk_budget: &WorkspaceWalkBudget,
14690 git_time_budget_exceeded: bool,
14691 elapsed: Duration,
14692) -> Option<WorkspaceInspectionTruncationV1> {
14693 if !walk_budget.time_budget_exceeded
14694 && !walk_budget.entry_cap_exceeded
14695 && !git_time_budget_exceeded
14696 {
14697 return None;
14698 }
14699 Some(WorkspaceInspectionTruncationV1 {
14700 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
14701 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
14702 git_time_budget_exceeded,
14703 entries_visited: walk_budget.entries_visited,
14704 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
14705 })
14706}
14707
14708fn walk_workspace_directory(
14709 directory: &Path,
14710 relative_directory: Option<&RepositoryPath>,
14711 depth: usize,
14712 entries: &mut Vec<WorkspaceEntry>,
14713 truncated: &mut bool,
14714 budget: &mut WorkspaceWalkBudget,
14715) {
14716 if budget.is_exhausted() {
14717 *truncated = true;
14718 return;
14719 }
14720 let read_dir = match std::fs::read_dir(directory) {
14721 Ok(read_dir) => read_dir,
14722 Err(_) => {
14723 *truncated = true;
14724 return;
14725 }
14726 };
14727 let mut children = Vec::new();
14728 for child in read_dir {
14729 match child {
14730 Ok(child) => children.push(child),
14731 Err(_) => *truncated = true,
14732 }
14733 }
14734 children.sort_by_key(|child| child.file_name());
14735 for child in children {
14736 if budget.record_visit() {
14737 *truncated = true;
14738 return;
14739 }
14740 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
14741 *truncated = true;
14742 return;
14743 }
14744 let file_type = match child.file_type() {
14745 Ok(file_type) => file_type,
14746 Err(_) => {
14747 *truncated = true;
14748 continue;
14749 }
14750 };
14751 if file_type.is_symlink() {
14752 continue;
14753 }
14754 let name = match child.file_name().into_string() {
14755 Ok(name) => name,
14756 Err(_) => {
14757 *truncated = true;
14758 continue;
14759 }
14760 };
14761 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
14762 continue;
14763 }
14764 let relative_path = match relative_directory {
14765 None => windows_repository_path(name),
14766 Some(parent) => parent
14767 .as_utf8()
14768 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
14769 };
14770 let Some(relative_path) = relative_path else {
14771 *truncated = true;
14772 continue;
14773 };
14774 let kind = if file_type.is_dir() {
14775 WorkspaceEntryKind::Directory
14776 } else if file_type.is_file() {
14777 WorkspaceEntryKind::File
14778 } else {
14779 continue;
14780 };
14781 entries.push(WorkspaceEntry {
14782 relative_path: relative_path.clone(),
14783 kind,
14784 });
14785 if file_type.is_dir() {
14786 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
14787 walk_workspace_directory(
14788 &child.path(),
14789 Some(&relative_path),
14790 depth + 1,
14791 entries,
14792 truncated,
14793 budget,
14794 );
14795 } else {
14796 *truncated = true;
14797 }
14798 }
14799 }
14800}
14801
14802fn windows_repository_path(value: String) -> Option<RepositoryPath> {
14803 if value.contains('\\') {
14804 return None;
14805 }
14806 RepositoryPath::utf8(value).ok()
14807}
14808
14809fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
14810 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
14811}
14812
14813fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
14822 if name.eq_ignore_ascii_case(".git")
14823 || name.eq_ignore_ascii_case("target")
14824 || name.eq_ignore_ascii_case("node_modules")
14825 {
14826 return true;
14827 }
14828 path.join("CACHEDIR.TAG").is_file()
14829}
14830
14831fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
14832 matches!(
14833 status,
14834 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
14835 )
14836}
14837
14838#[derive(Clone, Debug, Eq, PartialEq)]
14839enum ContextPackRepositoryHead {
14840 NotRepository,
14841 Unborn,
14842 Commit(String),
14843}
14844
14845#[derive(Clone, Debug, Eq, PartialEq)]
14846struct ContextPackRepositoryObservation {
14847 head: ContextPackRepositoryHead,
14848 git: GitSnapshot,
14849 files: Vec<ContextPackRepositoryFileSource>,
14850}
14851
14852fn stable_context_pack_repository(
14853 first: ContextPackRepositoryObservation,
14854 second: ContextPackRepositoryObservation,
14855) -> Result<ContextPackRepository, NodeFailure> {
14856 if first != second {
14857 return Err(failure(
14858 NodeFailureCode::ContextPackMaterializationFailed,
14859 "context repository changed during bounded capture",
14860 ));
14861 }
14862 Ok(ContextPackRepository::from_sources(
14863 &second.git,
14864 second.files,
14865 ))
14866}
14867
14868fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
14875 if Instant::now() < deadline {
14876 return false;
14877 }
14878 append_git_diagnostic(
14879 snapshot,
14880 &format!("{stage} skipped: workspace inspection time budget exhausted"),
14881 );
14882 true
14883}
14884
14885async fn run_git_bounded_with_deadline(
14894 root: &str,
14895 arguments: &[&str],
14896 output_limit: usize,
14897 deadline: Instant,
14898) -> io::Result<GitCommandOutput> {
14899 let remaining = deadline.checked_duration_since(Instant::now());
14900 let timeout_ms = remaining
14901 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
14902 .unwrap_or(0)
14903 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
14904 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
14905}
14906
14907async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
14914 let mut snapshot = GitSnapshot {
14915 is_repository: false,
14916 branch: None,
14917 status: Vec::new(),
14918 recent_commits: Vec::new(),
14919 worktrees: Vec::new(),
14920 managed_worktree: None,
14921 truncated: false,
14922 diagnostic: None,
14923 };
14924 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
14925 return (snapshot, true);
14926 }
14927 let repository = match run_git_bounded_with_deadline(
14928 root,
14929 &["rev-parse", "--is-inside-work-tree"],
14930 4 * 1_024,
14931 deadline,
14932 )
14933 .await
14934 {
14935 Ok(output) => output,
14936 Err(error) => {
14937 append_git_diagnostic(
14938 &mut snapshot,
14939 &format!("git inspection unavailable: {error}"),
14940 );
14941 return (snapshot, false);
14942 }
14943 };
14944 snapshot.truncated |= repository.truncated;
14945 if repository.timed_out {
14946 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
14947 return (snapshot, false);
14948 }
14949 if !repository.success
14950 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
14951 {
14952 let stderr = String::from_utf8_lossy(&repository.stderr);
14953 if !stderr.to_ascii_lowercase().contains("not a git repository") {
14954 append_git_diagnostic(&mut snapshot, stderr.trim());
14955 }
14956 return (snapshot, false);
14957 }
14958 snapshot.is_repository = true;
14959
14960 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
14961 return (snapshot, true);
14962 }
14963 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
14964 Ok(output) => {
14965 snapshot.truncated |= output.truncated;
14966 if output.timed_out {
14967 append_git_diagnostic(&mut snapshot, "git branch query timed out");
14968 } else if output.success {
14969 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
14970 if !branch.is_empty() {
14971 snapshot.branch = Some(truncate_git_field(
14972 &branch,
14973 MAX_REPOSITORY_PATH_BYTES,
14974 &mut snapshot.truncated,
14975 ));
14976 }
14977 }
14978 }
14979 Err(error) => append_git_diagnostic(
14980 &mut snapshot,
14981 &format!("git branch query failed: {error}"),
14982 ),
14983 }
14984 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
14985 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
14986 snapshot.truncated |= output.truncated;
14987 if output.success && !output.timed_out {
14988 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
14989 if !head.is_empty() {
14990 snapshot.branch = Some(format!("detached:{head}"));
14991 }
14992 }
14993 }
14994 }
14995
14996 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
14997 return (snapshot, true);
14998 }
14999 match run_git_bounded_with_deadline(
15000 root,
15001 &[
15002 "status",
15003 "--porcelain=v1",
15004 "-z",
15005 "--renames",
15006 "--untracked-files=normal",
15007 "--",
15008 ".",
15009 ],
15010 GIT_OUTPUT_MAX_BYTES,
15011 deadline,
15012 )
15013 .await
15014 {
15015 Ok(output) => {
15016 snapshot.truncated |= output.truncated;
15017 if output.timed_out {
15018 append_git_diagnostic(&mut snapshot, "git status timed out");
15019 } else if output.success {
15020 parse_git_status(&output.stdout, &mut snapshot);
15021 } else {
15022 append_git_diagnostic(
15023 &mut snapshot,
15024 String::from_utf8_lossy(&output.stderr).trim(),
15025 );
15026 }
15027 }
15028 Err(error) => append_git_diagnostic(
15029 &mut snapshot,
15030 &format!("git status failed: {error}"),
15031 ),
15032 }
15033
15034 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15035 return (snapshot, true);
15036 }
15037 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15038 match run_git_bounded_with_deadline(
15039 root,
15040 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15041 16 * 1_024,
15042 deadline,
15043 )
15044 .await
15045 {
15046 Ok(output) => {
15047 snapshot.truncated |= output.truncated;
15048 if output.timed_out {
15049 append_git_diagnostic(&mut snapshot, "git log timed out");
15050 } else if output.success {
15051 parse_git_commits(&output.stdout, &mut snapshot);
15052 } else {
15053 let stderr = String::from_utf8_lossy(&output.stderr);
15054 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15055 append_git_diagnostic(&mut snapshot, stderr.trim());
15056 }
15057 }
15058 }
15059 Err(error) => append_git_diagnostic(
15060 &mut snapshot,
15061 &format!("git log failed: {error}"),
15062 ),
15063 }
15064
15065 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15066 return (snapshot, true);
15067 }
15068 match list_git_worktrees_with_deadline(root, deadline).await {
15069 Ok(worktrees) => {
15070 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15071 }
15072 Err(error) => append_git_diagnostic(
15073 &mut snapshot,
15074 &format!("git worktree list failed: {}", error.message),
15075 ),
15076 }
15077 (snapshot, false)
15078}
15079
15080fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15081 let mut remaining = output;
15082 while !remaining.is_empty() {
15083 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15084 snapshot.truncated = true;
15085 break;
15086 }
15087 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15088 snapshot.truncated = true;
15089 break;
15090 };
15091 let record = &remaining[..record_end];
15092 remaining = &remaining[record_end + 1..];
15093 let has_previous_path = record
15094 .get(..2)
15095 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15096 let previous_path_bytes = if has_previous_path {
15097 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15098 snapshot.truncated = true;
15099 break;
15100 };
15101 let previous = &remaining[..previous_end];
15102 remaining = &remaining[previous_end + 1..];
15103 Some(previous)
15104 } else {
15105 None
15106 };
15107 if record.len() < 4
15108 || record[2] != b' '
15109 || !is_git_status_code(record[0])
15110 || !is_git_status_code(record[1])
15111 {
15112 snapshot.truncated = true;
15113 continue;
15114 }
15115 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15116 snapshot.truncated = true;
15117 continue;
15118 };
15119 let previous_path = match previous_path_bytes {
15120 Some(previous) => {
15121 let Some(previous) = windows_repository_path_from_bytes(previous) else {
15122 snapshot.truncated = true;
15123 continue;
15124 };
15125 Some(previous)
15126 }
15127 None => None,
15128 };
15129 snapshot.status.push(GitStatusEntry {
15130 index_status: (record[0] as char).to_string(),
15131 worktree_status: (record[1] as char).to_string(),
15132 path,
15133 previous_path,
15134 });
15135 }
15136}
15137
15138fn is_git_status_code(byte: u8) -> bool {
15139 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15140}
15141
15142fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15143 for line in String::from_utf8_lossy(output).lines() {
15144 let Some((id, summary)) = line.split_once('\u{1f}') else {
15145 continue;
15146 };
15147 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15148 snapshot.truncated = true;
15149 break;
15150 }
15151 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15152 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15153 snapshot.recent_commits.push(GitCommitSummary { id, summary });
15154 }
15155}
15156
15157fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15158 if value.len() <= max_bytes {
15159 return value.to_owned();
15160 }
15161 *truncated = true;
15162 let mut boundary = max_bytes;
15163 while boundary > 0 && !value.is_char_boundary(boundary) {
15164 boundary -= 1;
15165 }
15166 value[..boundary].to_owned()
15167}
15168
15169fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15170 if message.is_empty() {
15171 return;
15172 }
15173 let normalized = message
15174 .chars()
15175 .map(|character| if character.is_control() { ' ' } else { character })
15176 .collect::<String>();
15177 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15178 if !diagnostic.is_empty() {
15179 diagnostic.push_str("; ");
15180 }
15181 diagnostic.push_str(normalized.trim());
15182 snapshot.diagnostic = Some(truncate_git_field(
15183 &diagnostic,
15184 GIT_DIAGNOSTIC_MAX_BYTES,
15185 &mut snapshot.truncated,
15186 ));
15187}
15188
15189async fn run_git_bounded(
15190 root: &str,
15191 arguments: &[&str],
15192 output_limit: usize,
15193) -> io::Result<GitCommandOutput> {
15194 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15195}
15196
15197async fn read_git_history_bounded(
15198 root: &str,
15199 path: Option<&RepositoryPath>,
15200 before: Option<&GitObjectId>,
15201 limit: u16,
15202) -> Result<GitHistoryPage, NodeFailure> {
15203 let path = path
15204 .map(|path| {
15205 path.as_utf8().ok_or_else(|| {
15206 failure(
15207 NodeFailureCode::InvalidRepositoryPath,
15208 "git history path must use UTF-8",
15209 )
15210 })
15211 })
15212 .transpose()?
15213 .unwrap_or(".");
15214 let count = (usize::from(limit) + 1).to_string();
15215 let start = before
15216 .map(|revision| format!("{}^", revision.as_str()))
15217 .unwrap_or_else(|| "HEAD".to_owned());
15218 let arguments = [
15219 "--no-pager",
15220 "log",
15221 "--no-decorate",
15222 "--date=iso-strict",
15223 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15224 "-n",
15225 count.as_str(),
15226 start.as_str(),
15227 "--",
15228 path,
15229 ];
15230 let output = run_git_bounded(root, &arguments, 256 * 1_024)
15231 .await
15232 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15233 if output.timed_out {
15234 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15235 }
15236 if !output.success {
15237 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15238 }
15239 let mut commits = parse_git_history(&output.stdout);
15240 let has_more = commits.len() > usize::from(limit) || output.truncated;
15241 commits.truncate(usize::from(limit));
15242 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15243 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15244}
15245
15246fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15247 String::from_utf8_lossy(output)
15248 .split('\u{1e}')
15249 .filter_map(|record| {
15250 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15251 if fields.len() != 9 {
15252 return None;
15253 }
15254 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15255 let parents = if fields[1].is_empty() {
15256 Vec::new()
15257 } else {
15258 fields[1]
15259 .split(' ')
15260 .map(|value| GitObjectId::new(value.to_owned()))
15261 .collect::<Result<Vec<_>, _>>()
15262 .ok()?
15263 };
15264 Some(GitCommitDetails {
15265 id,
15266 parents,
15267 subject: bounded_git_text(fields[2], 512),
15268 author_name: bounded_git_text(fields[3], 256),
15269 author_email: bounded_git_text(fields[4], 320),
15270 authored_at: bounded_git_text(fields[5], 64),
15271 committer_name: bounded_git_text(fields[6], 256),
15272 committer_email: bounded_git_text(fields[7], 320),
15273 committed_at: bounded_git_text(fields[8], 64),
15274 signature_status: GitSignatureStatus::NoSignature,
15275 signer: None,
15276 })
15277 })
15278 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15279 .collect()
15280}
15281
15282fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15283 let normalized = value
15284 .chars()
15285 .map(|character| if character.is_control() { ' ' } else { character })
15286 .collect::<String>();
15287 if normalized.len() <= max_bytes {
15288 return normalized;
15289 }
15290 let mut boundary = max_bytes;
15291 while boundary > 0 && !normalized.is_char_boundary(boundary) {
15292 boundary -= 1;
15293 }
15294 normalized[..boundary].to_owned()
15295}
15296
15297async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15298 let path = request.path.as_ref().map(|path| {
15299 path.as_utf8()
15300 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15301 }).transpose()?;
15302 let mut arguments = match &request.mode {
15303 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15304 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15305 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15306 };
15307 if let GitDiffMode::Commit { revision } = &request.mode {
15308 arguments.push(revision.as_str());
15309 }
15310 arguments.push("--");
15311 if let Some(path) = path {
15312 arguments.push(path);
15313 }
15314 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15315 .await
15316 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15317 if output.timed_out {
15318 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15319 }
15320 if !output.success {
15321 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15322 }
15323 Ok(GitDiff {
15324 mode: request.mode,
15325 path: request.path,
15326 text: String::from_utf8_lossy(&output.stdout).into_owned(),
15327 truncated: output.truncated,
15328 })
15329}
15330
15331async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15332 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15333 ResponseEnvelope { request_id: envelope.request_id, result }
15334}
15335
15336async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15337 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15338 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15339 }
15340 let read_only = matches!(
15341 &request,
15342 NodeRequest::Snapshot
15343 | NodeRequest::Resync { .. }
15344 | NodeRequest::BrowseHostDirectories { .. }
15345 | NodeRequest::InspectWorkspace { .. }
15346 | NodeRequest::ReadWorkspaceFile { .. }
15347 | NodeRequest::ReadGitHistory { .. }
15348 | NodeRequest::ReadGitDiff { .. }
15349 | NodeRequest::CatalogNativeSessions { .. }
15350 | NodeRequest::PageNativeSessions { .. }
15351 | NodeRequest::PreviewNativeSession { .. }
15352 | NodeRequest::PreviewSessionRecord { .. }
15353 );
15354 let _mutation_guard = if read_only {
15355 None
15356 } else {
15357 let guard = shared.mutation_gate.lock().await;
15358 if shared.shutdown.load(Ordering::Acquire) {
15359 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
15360 }
15361 Some(guard)
15362 };
15363 match request {
15364 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
15365 event_sequence: shared.current_sequence(),
15366 controller: shared.controller_state(),
15367 snapshot: shared.snapshot(),
15368 }),
15369 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
15370 NodeRequest::ArmHarnessMcpReservation {
15371 reservation_id,
15372 activation_digest,
15373 spawn_spec,
15374 launch,
15375 expires_at_unix_ms,
15376 } => {
15377 shared.require_controller(connection_id, role)?;
15378 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15379 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15380 })?;
15381 let expires_at_unix_ms = registry
15382 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
15383 .await
15384 .map_err(harness_mcp_failure)?;
15385 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
15386 }
15387 NodeRequest::SpawnSpecWithHarnessMcp {
15388 reservation_id,
15389 activation_digest,
15390 spec,
15391 deadline_unix_ms,
15392 } => {
15393 shared.require_controller(connection_id, role)?;
15394 let receipt = shared
15395 .spawn_from_spec_with_harness_mcp(
15396 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
15397 )
15398 .await?;
15399 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
15400 }
15401 NodeRequest::ActivateHarnessMcpReservation {
15402 reservation_id,
15403 activation_digest,
15404 record_id,
15405 session,
15406 } => {
15407 shared.require_controller(connection_id, role)?;
15408 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
15409 current.instance_id == session.session.instance_id
15410 && current.generation == session.session.generation
15411 }).and_then(|current| current.process_id).ok_or_else(|| {
15412 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
15413 })?;
15414 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15415 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15416 })?.activate(
15417 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
15418 ).map_err(harness_mcp_failure)?;
15419 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
15420 }
15421 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
15422 shared.require_controller(connection_id, role)?;
15423 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15424 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15425 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
15426 if let (Some(control), Some(instance_id)) =
15427 (shared.native_launch_profile_control.as_ref(), instance_id)
15428 {
15429 control.clear_native_mcp_server_launch_overlay(instance_id);
15430 }
15431 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
15432 }
15433 NodeRequest::PutHarnessMcpReplyChunk {
15434 reservation_id, activation_digest, record_id, session, call_id,
15435 offset, final_chunk, chunk_hex,
15436 } => {
15437 shared.require_controller(connection_id, role)?;
15438 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15439 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15440 })?.put_reply_chunk(
15441 &reservation_id, &activation_digest, &record_id, &session, &call_id,
15442 offset, final_chunk, &chunk_hex,
15443 ).map_err(harness_mcp_failure)?;
15444 Ok(NodeResponse::ReplyChunkAccepted {
15445 reservation_id, activation_digest, record_id, session, call_id,
15446 next_offset, completed,
15447 })
15448 }
15449 NodeRequest::RejectHarnessMcpCall {
15450 reservation_id, activation_digest, record_id, session, call_id, reason,
15451 } => {
15452 shared.require_controller(connection_id, role)?;
15453 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15454 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15455 })?.reject_call(
15456 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
15457 ).map_err(harness_mcp_failure)?;
15458 Ok(NodeResponse::CallRejected {
15459 reservation_id, activation_digest, record_id, session, call_id,
15460 })
15461 }
15462 NodeRequest::BeginDeliveryStage { manifest } => {
15463 shared.require_controller(connection_id, role)?;
15464 shared.begin_delivery_stage(manifest)
15465 }
15466 NodeRequest::PutDeliveryBlobChunk {
15467 stage_id,
15468 blob_digest,
15469 offset,
15470 chunk_hex,
15471 } => {
15472 shared.require_controller(connection_id, role)?;
15473 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
15474 }
15475 NodeRequest::CommitDeliveryStage { stage_id } => {
15476 shared.require_controller(connection_id, role)?;
15477 let receipt = shared.commit_delivery_stage(stage_id)?;
15478 Ok(NodeResponse::DeliveryCommitted { receipt })
15479 }
15480 NodeRequest::AbortDeliveryStage { stage_id } => {
15481 shared.require_controller(connection_id, role)?;
15482 shared.abort_delivery_stage(&stage_id)?;
15483 Ok(NodeResponse::DeliveryStageAborted { stage_id })
15484 }
15485 NodeRequest::BrowseHostDirectories { directory, after } => {
15486 let listing = shared.browse_host_directories(directory, after).await?;
15487 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
15488 }
15489 NodeRequest::InspectWorkspace { workspace_id } => {
15490 let inspection = shared.inspect_workspace(workspace_id).await?;
15491 Ok(NodeResponse::WorkspaceInspected { inspection })
15492 }
15493 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
15494 let file = shared.read_workspace_file(workspace_id, path).await?;
15495 Ok(NodeResponse::WorkspaceFileRead { file })
15496 }
15497 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
15498 shared.require_controller(connection_id, role)?;
15499 let file = shared
15500 .write_workspace_file(workspace_id, path, expected_revision, text)
15501 .await?;
15502 Ok(NodeResponse::WorkspaceFileWritten { file })
15503 }
15504 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
15505 shared.require_controller(connection_id, role)?;
15506 let file = shared.create_workspace_file(workspace_id, path).await?;
15507 Ok(NodeResponse::WorkspaceFileCreated { file })
15508 }
15509 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
15510 shared.require_controller(connection_id, role)?;
15511 let entry = shared
15512 .create_workspace_directory(workspace_id.clone(), path)
15513 .await?;
15514 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
15515 }
15516 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
15517 if role != ClientRole::Operator {
15518 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
15519 }
15520 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
15521 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
15522 }
15523 let page = shared
15524 .read_git_history(workspace_id.clone(), path, before, limit)
15525 .await?;
15526 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
15527 }
15528 NodeRequest::ReadGitDiff { workspace_id, request } => {
15529 if role != ClientRole::Operator {
15530 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
15531 }
15532 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
15533 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
15534 }
15535 NodeRequest::CatalogNativeSessions { route, limit } => {
15536 if role != ClientRole::Operator {
15537 return Err(failure(
15538 NodeFailureCode::ObserverReadOnly,
15539 "native session catalog requires an operator connection",
15540 ));
15541 }
15542 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
15543 return Err(failure(
15544 NodeFailureCode::InvalidRequest,
15545 "native session catalog limit is invalid",
15546 ));
15547 }
15548 let (entries, summary) = shared
15549 .catalog_native_sessions(route.clone(), limit)
15550 .await?;
15551 Ok(NodeResponse::NativeSessionsCataloged {
15552 route,
15553 entries,
15554 summary: Some(summary),
15555 })
15556 }
15557 NodeRequest::PageNativeSessions {
15558 route,
15559 window,
15560 catalog_revision,
15561 recent_cutoff_unix_ms,
15562 after_selection_id,
15563 limit,
15564 } => {
15565 if role != ClientRole::Operator {
15566 return Err(failure(
15567 NodeFailureCode::ObserverReadOnly,
15568 "native session catalog paging requires an operator connection",
15569 ));
15570 }
15571 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
15572 || after_selection_id
15573 .as_deref()
15574 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
15575 {
15576 return Err(failure(
15577 NodeFailureCode::InvalidRequest,
15578 "native session catalog page request is invalid",
15579 ));
15580 }
15581 let page = shared
15582 .page_native_sessions(
15583 route.clone(),
15584 window,
15585 catalog_revision,
15586 recent_cutoff_unix_ms,
15587 after_selection_id,
15588 limit,
15589 )
15590 .await?;
15591 Ok(NodeResponse::NativeSessionsPaged {
15592 route,
15593 page,
15594 })
15595 }
15596 NodeRequest::PreviewNativeSession {
15597 selection,
15598 message_limit,
15599 } => {
15600 if role != ClientRole::Operator {
15601 return Err(failure(
15602 NodeFailureCode::ObserverReadOnly,
15603 "native session preview requires an operator connection",
15604 ));
15605 }
15606 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15607 .contains(&message_limit)
15608 || selection.validate().is_err()
15609 {
15610 return Err(failure(
15611 NodeFailureCode::InvalidRequest,
15612 "native session preview request is invalid",
15613 ));
15614 }
15615 let preview = shared
15616 .preview_native_session(selection.clone(), message_limit)
15617 .await?;
15618 Ok(NodeResponse::NativeSessionPreviewed {
15619 selection,
15620 preview,
15621 })
15622 }
15623 NodeRequest::IndexNativeSession {
15624 selection,
15625 display_name,
15626 } => {
15627 shared.require_controller(connection_id, role)?;
15628 let record = shared
15629 .index_native_session(selection.clone(), display_name)
15630 .await?;
15631 Ok(NodeResponse::NativeSessionIndexed { selection, record })
15632 }
15633 NodeRequest::PreviewSessionRecord {
15634 record_id,
15635 message_limit,
15636 } => {
15637 if role != ClientRole::Operator {
15638 return Err(failure(
15639 NodeFailureCode::ObserverReadOnly,
15640 "native session preview requires an operator connection",
15641 ));
15642 }
15643 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15644 .contains(&message_limit)
15645 {
15646 return Err(failure(
15647 NodeFailureCode::InvalidRequest,
15648 "native session preview request is invalid",
15649 ));
15650 }
15651 let record = shared.record(&record_id)?;
15652 let working_directory = shared.workspace_root(&record.workspace_id)?;
15653 if !platform::roots_equal(
15654 &working_directory,
15655 windows_path_text(&record.canonical_root),
15656 ) {
15657 return Err(failure(
15658 NodeFailureCode::SessionRecordConflict,
15659 "managed session workspace root changed; refusing to preview another directory",
15660 ));
15661 }
15662 let identity = record.provider_session.clone().ok_or_else(|| {
15663 failure(
15664 NodeFailureCode::BackendOperationFailed,
15665 "native session preview is unavailable for this session record",
15666 )
15667 })?;
15668 if identity.key != ProviderSessionKey::SessionId {
15669 return Err(failure(
15670 NodeFailureCode::BackendOperationFailed,
15671 "native session preview is unavailable for this session identity",
15672 ));
15673 }
15674 let preview = shared
15675 .preview_native_session_by_session_id(
15676 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
15677 )
15678 .await?;
15679 if preview.session_id != identity.id {
15680 return Err(failure(
15681 NodeFailureCode::SessionRecordConflict,
15682 "native session preview identity changed while loading",
15683 ));
15684 }
15685 shared.revalidate_session_record_preview(&record, &identity)?;
15686 let preview: gate4agent_types::SessionRecordPreview = preview.into();
15687 shared.publish(NodeEvent::SessionRecordHistorySummarized {
15688 record_id: record_id.clone(),
15689 summary: SessionHistorySummaryV1::from(&preview),
15690 });
15691 Ok(NodeResponse::SessionRecordPreviewed {
15692 record_id,
15693 preview,
15694 })
15695 }
15696 NodeRequest::AcquireController { lease_ms } => {
15697 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
15698 Ok(NodeResponse::Controller { controller: Some(controller) })
15699 }
15700 NodeRequest::ReleaseController => {
15701 shared.release_controller(connection_id);
15702 Ok(NodeResponse::Controller { controller: shared.controller_state() })
15703 }
15704 NodeRequest::RegisterWorkspace { workspace_id, root } => {
15705 shared.require_controller(connection_id, role)?;
15706 let root = require_windows_path(root)?;
15707 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
15708 let workspace = shared.register_workspace(workspace_id, root).await?;
15709 Ok(NodeResponse::WorkspaceRegistered { workspace })
15710 }
15711 NodeRequest::CreateStandaloneWorkspace {
15712 workspace_id,
15713 root,
15714 initial_branch,
15715 } => {
15716 shared.require_controller(connection_id, role)?;
15717 let root = require_windows_path(root)?;
15718 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
15719 let workspace = shared
15720 .create_standalone_workspace(workspace_id, root, initial_branch)
15721 .await?;
15722 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
15723 }
15724 NodeRequest::UnregisterWorkspace { workspace_id } => {
15725 shared.require_controller(connection_id, role)?;
15726 shared.reject_managed_reservation(Some(&workspace_id), None)?;
15727 shared.unregister_workspace(&workspace_id)?;
15728 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
15729 }
15730 NodeRequest::CreateWorktree {
15731 source_workspace_id,
15732 workspace_id,
15733 target_root,
15734 branch,
15735 base,
15736 } => {
15737 shared.require_controller(connection_id, role)?;
15738 let target_root = require_windows_path(target_root)?;
15739 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
15740 let (worktree, workspace) = shared
15741 .create_worktree(
15742 source_workspace_id,
15743 workspace_id,
15744 target_root,
15745 branch,
15746 base,
15747 )
15748 .await?;
15749 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
15750 }
15751 NodeRequest::RemoveWorktree {
15752 source_workspace_id,
15753 target_root,
15754 } => {
15755 shared.require_controller(connection_id, role)?;
15756 let native_target_root = require_windows_path(target_root.clone())?;
15757 shared.reject_managed_reservation(None, Some(&native_target_root))?;
15758 let workspace_id = shared
15759 .remove_worktree(source_workspace_id, native_target_root)
15760 .await?;
15761 Ok(NodeResponse::WorktreeRemoved {
15762 target_root,
15763 workspace_id,
15764 })
15765 }
15766 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
15767 shared.require_controller(connection_id, role)?;
15768 if !terminal_size.is_valid() {
15769 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
15770 }
15771 if let Some(prompt) = initial_prompt.as_deref() {
15772 validate_node_text("spawn initial prompt", prompt)?;
15773 }
15774 let session = shared
15775 .spawn_session(workspace_id, provider, mode, terminal_size, initial_prompt)
15776 .await?;
15777 Ok(NodeResponse::SpawnAccepted { session })
15778 }
15779 NodeRequest::SpawnSpec { spec } => {
15780 shared.require_controller(connection_id, role)?;
15781 let receipt = shared.spawn_from_spec(spec).await?;
15782 Ok(NodeResponse::SpawnSpecAccepted { receipt })
15783 }
15784 NodeRequest::SpawnManagedWorktree { request } => {
15785 shared.require_controller(connection_id, role)?;
15786 let receipt = shared.spawn_managed_worktree(request).await?;
15787 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
15788 }
15789 NodeRequest::SpawnManagedWorktreeV2 { request } => {
15790 shared.require_controller(connection_id, role)?;
15791 let receipt = shared.spawn_managed_worktree_v2(request).await?;
15792 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
15793 }
15794 NodeRequest::CleanupManagedWorktree { lease_id } => {
15795 shared.require_controller(connection_id, role)?;
15796 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
15797 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
15798 }
15799 NodeRequest::DiscoverHistory { session, limit } => {
15800 controlled_session(shared, connection_id, role, &session)?;
15801 let candidates = shared.discover_history(&session, limit).await?;
15802 Ok(NodeResponse::HistoryDiscovered {
15803 session,
15804 candidates,
15805 })
15806 }
15807 NodeRequest::LoadHistory {
15808 session,
15809 candidate_id,
15810 } => {
15811 controlled_session(shared, connection_id, role, &session)?;
15812 let loaded = shared
15813 .load_history(&session, candidate_id)
15814 .await?;
15815 Ok(NodeResponse::HistoryLoaded {
15816 session,
15817 session_id: loaded.session_id,
15818 message_count: loaded.message_count,
15819 completed_turn_count: loaded.completed_turn_count,
15820 })
15821 }
15822 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
15823 shared.require_controller(connection_id, role)?;
15824 let context = shared
15825 .export_context_pack_for_session_record(&record_id, &session, false)
15826 .await?;
15827 Ok(NodeResponse::ContextPackForSessionRecordExported {
15828 record_id,
15829 session,
15830 context,
15831 })
15832 }
15833 NodeRequest::ExportContextPack { session } => {
15834 controlled_session(shared, connection_id, role, &session)?;
15835 let context = shared.export_context_pack(&session).await?;
15836 Ok(NodeResponse::ContextPackExported { context })
15837 }
15838 NodeRequest::ForgetContextPack { context_id } => {
15839 shared.require_controller(connection_id, role)?;
15840 shared.forget_context_pack(&context_id)?;
15841 Ok(NodeResponse::ContextPackForgotten { context_id })
15842 }
15843 NodeRequest::ResolveDurableContextPack { context_id } => {
15844 let context = shared
15848 .context_catalog
15849 .read()
15850 .unwrap_or_else(|poisoned| poisoned.into_inner())
15851 .get(&context_id)
15852 .map(|pack| pack.receipt().clone())
15853 .ok_or_else(|| {
15854 failure(
15855 NodeFailureCode::UnknownContextPack,
15856 "context pack is not present",
15857 )
15858 })?;
15859 Ok(NodeResponse::DurableContextPackResolved { context })
15860 }
15861 NodeRequest::ReadContextPack { digest } => {
15862 let (id, byte_len, bytes) = shared
15867 .context_catalog
15868 .read()
15869 .unwrap_or_else(|poisoned| poisoned.into_inner())
15870 .get_by_digest(&digest)
15871 .map(|pack| {
15872 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
15873 })
15874 .ok_or_else(|| {
15875 failure(
15876 NodeFailureCode::UnknownContextPack,
15877 &format!("context pack {digest} not on this node"),
15878 )
15879 })?;
15880 tracing::debug!(
15881 digest = digest.as_str(),
15882 byte_len,
15883 "read context pack bytes",
15884 );
15885 Ok(NodeResponse::ContextPackBytesRead {
15886 pack: ContextPackBytesRead {
15887 digest,
15888 id,
15889 byte_len,
15890 bytes,
15891 },
15892 })
15893 }
15894 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
15895 let provider = controlled_session(shared, connection_id, role, &session)?;
15896 if let Some(prompt) = initial_prompt.as_deref() {
15897 validate_node_text("resume initial prompt", prompt)?;
15898 }
15899 let runtime_requirement = if initial_prompt.is_some() {
15900 ProviderRuntimeRequirement::ResumeWithPrompt
15901 } else {
15902 ProviderRuntimeRequirement::Resume
15903 };
15904 shared.require_session_runtime_policy(&session, runtime_requirement)?;
15905 let runtime_policy = shared
15906 .admit_provider_runtime(&provider, runtime_requirement)
15907 .await?;
15908 let working_directory = shared.bound_workspace_root(&session)?;
15909 let request = ResumeLaunchRequest {
15910 working_directory,
15911 terminal_size,
15912 initial_prompt,
15913 };
15914 request
15915 .validate()
15916 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
15917 let command = shared.prepare_command(ControlCommand::Resume {
15918 instance_id: session.session.instance_id,
15919 target: ResumeTarget::CurrentProvider,
15920 runtime_policy,
15921 request,
15922 });
15923 shared.arm_resume(&session, command.id, runtime_policy)?;
15924 let dispatch = shared.dispatch_envelope(command);
15925 if let Err(error) = dispatch {
15926 shared.clear_armed_resume(&session);
15927 return Err(error);
15928 }
15929 Ok(NodeResponse::Accepted)
15930 }
15931 NodeRequest::RenameSessionRecord {
15932 record_id,
15933 display_name,
15934 } => {
15935 shared.require_controller(connection_id, role)?;
15936 let record = shared.rename_session_record(&record_id, display_name)?;
15937 Ok(NodeResponse::SessionRecordUpdated { record })
15938 }
15939 NodeRequest::SetSessionTask {
15940 record_id,
15941 expected_revision,
15942 target,
15943 } => {
15944 shared.require_controller(connection_id, role)?;
15945 let record = shared.set_session_task(&record_id, expected_revision, target)?;
15946 Ok(NodeResponse::SessionRecordUpdated { record })
15947 }
15948 NodeRequest::IndexProviderSession {
15949 workspace_id,
15950 provider,
15951 identity,
15952 display_name,
15953 } => {
15954 shared.require_controller(connection_id, role)?;
15955 let record = shared.index_provider_session(
15956 workspace_id,
15957 provider,
15958 identity,
15959 display_name,
15960 )?;
15961 Ok(NodeResponse::ProviderSessionIndexed { record })
15962 }
15963 NodeRequest::ResumeSessionRecord {
15964 record_id,
15965 terminal_size,
15966 initial_prompt,
15967 } => {
15968 shared.require_controller(connection_id, role)?;
15969 if let Some(prompt) = initial_prompt.as_deref() {
15970 validate_node_text("managed resume initial prompt", prompt)?;
15971 }
15972 let (record, session) = shared
15973 .resume_session_record(
15974 &record_id,
15975 terminal_size,
15976 initial_prompt,
15977 )
15978 .await?;
15979 Ok(NodeResponse::SessionRecordResumed { record, session })
15980 }
15981 NodeRequest::ForgetSessionRecord { record_id } => {
15982 shared.require_controller(connection_id, role)?;
15983 shared.forget_session_record(&record_id).await?;
15984 Ok(NodeResponse::SessionRecordForgotten { record_id })
15985 }
15986 NodeRequest::Prompt { session, text } => {
15987 let agent_id = controlled_session(shared, connection_id, role, &session)?;
15988 validate_node_text("prompt", &text)?;
15989 shared.require_session_runtime_policy(
15990 &session,
15991 ProviderRuntimeRequirement::SemanticPrompt,
15992 )?;
15993 let framing = prompt_framing(&agent_id);
15994 shared
15995 .dispatch_input_bounded(
15996 &session,
15997 InputAction::SubmitPrompt(PromptPayload { text, framing }),
15998 )
15999 .await?;
16000 Ok(NodeResponse::Accepted)
16001 }
16002 NodeRequest::Paste { session, text } => {
16003 controlled_session(shared, connection_id, role, &session)?;
16004 validate_node_text("paste", &text)?;
16005 shared.require_session_runtime_policy(
16006 &session,
16007 ProviderRuntimeRequirement::SemanticPrompt,
16008 )?;
16009 shared
16010 .dispatch_input_bounded(
16011 &session,
16012 InputAction::InsertDraft(PromptPayload {
16013 text,
16014 framing: PromptFraming::BracketedPaste,
16015 }),
16016 )
16017 .await?;
16018 Ok(NodeResponse::Accepted)
16019 }
16020 NodeRequest::Input { session, text } => {
16021 controlled_session(shared, connection_id, role, &session)?;
16022 validate_node_text("terminal input", &text)?;
16023 shared
16024 .dispatch_input_bounded(
16025 &session,
16026 InputAction::TerminalText(TerminalText { text }),
16027 )
16028 .await?;
16029 Ok(NodeResponse::Accepted)
16030 }
16031 NodeRequest::TerminalBytes { session, bytes } => {
16032 controlled_session(shared, connection_id, role, &session)?;
16033 let action = terminal_bytes_action(bytes)?;
16034 shared
16035 .dispatch_input_bounded(&session, action)
16036 .await?;
16037 Ok(NodeResponse::Accepted)
16038 }
16039 NodeRequest::TerminalControl { session, control } => {
16040 controlled_session(shared, connection_id, role, &session)?;
16041 shared
16042 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16043 .await?;
16044 Ok(NodeResponse::Accepted)
16045 }
16046 NodeRequest::Resize { session, size } => {
16047 controlled_session(shared, connection_id, role, &session)?;
16048 if !size.is_valid() {
16049 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16050 }
16051 shared.dispatch_resize_bounded(&session, size).await?;
16052 Ok(NodeResponse::Accepted)
16053 }
16054 NodeRequest::Interrupt { session } => {
16055 controlled_session(shared, connection_id, role, &session)?;
16056 shared
16057 .dispatch_input_bounded(
16058 &session,
16059 InputAction::TerminalControl(TerminalControl::Interrupt),
16060 )
16061 .await?;
16062 Ok(NodeResponse::Accepted)
16063 }
16064 NodeRequest::Stop { session, force } => {
16065 controlled_session(shared, connection_id, role, &session)?;
16066 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16067 Ok(NodeResponse::Accepted)
16068 }
16069 NodeRequest::Remove { session } => {
16070 controlled_session(shared, connection_id, role, &session)?;
16071 shared.remove_session(&session).await?;
16072 Ok(NodeResponse::Accepted)
16073 }
16074 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16075 shared.require_controller(connection_id, role)?;
16076 let snapshot = shared.require_acp_session(&session)?;
16077 let interaction = snapshot
16078 .provider
16079 .interactions
16080 .iter()
16081 .find(|interaction| {
16082 interaction_correlation(
16083 session.session.instance_id,
16084 session.session.generation,
16085 interaction.id.0,
16086 ) == correlation_id
16087 })
16088 .ok_or_else(|| failure(
16089 NodeFailureCode::InvalidRequest,
16090 "unknown interaction correlation",
16091 ))?;
16092 if interaction.status != ProviderInteractionStatus::Pending {
16093 return Err(failure(
16094 NodeFailureCode::BackendOperationFailed,
16095 "interaction has already been resolved",
16096 ));
16097 }
16098 response
16099 .validate_for(interaction.interaction_kind)
16100 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16101 let interaction_id = interaction.id;
16102 let response_kind = response.kind();
16103 let response_outcome = response.outcome();
16104 shared.dispatch(ControlCommand::ResolveInteraction {
16105 instance_id: session.session.instance_id,
16106 generation: session.session.generation,
16107 interaction_id,
16108 response,
16109 })?;
16110 tracing::info!(
16116 node_id = %shared.node_id,
16117 session = ?session.session,
16118 correlation_id = %correlation_id,
16119 interaction_id = interaction_id.0,
16120 response_kind = ?response_kind,
16121 outcome = ?response_outcome,
16122 "interaction resolved",
16123 );
16124 Ok(NodeResponse::Accepted)
16125 }
16126 NodeRequest::SetSessionMode { session, mode_id } => {
16127 shared.require_controller(connection_id, role)?;
16128 shared.require_acp_session(&session)?;
16129 shared.dispatch(ControlCommand::SetSessionMode {
16130 instance_id: session.session.instance_id,
16131 mode_id,
16132 })?;
16133 Ok(NodeResponse::Accepted)
16134 }
16135 NodeRequest::SetSessionConfigOption {
16136 session,
16137 option_id,
16138 value_json,
16139 } => {
16140 shared.require_controller(connection_id, role)?;
16141 shared.require_acp_session(&session)?;
16142 shared.dispatch(ControlCommand::SetSessionConfigOption {
16143 instance_id: session.session.instance_id,
16144 option_id,
16145 value_json,
16146 })?;
16147 Ok(NodeResponse::Accepted)
16148 }
16149 NodeRequest::SetSessionModel { session, model_id } => {
16150 shared.require_controller(connection_id, role)?;
16151 shared.require_acp_session(&session)?;
16152 shared.dispatch(ControlCommand::SetSessionModel {
16153 instance_id: session.session.instance_id,
16154 model_id,
16155 })?;
16156 Ok(NodeResponse::Accepted)
16157 }
16158 NodeRequest::Shutdown => {
16159 shared.require_controller(connection_id, role)?;
16160 shared.begin_shutdown_locked().await?;
16161 Ok(NodeResponse::ShuttingDown)
16162 }
16163 }
16164}
16165
16166fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16167 shared.require_controller(connection_id, role)?;
16168 shared.validate_address(session)
16169}
16170
16171fn select_history_candidate(
16177 candidates: &[HistoryCandidateSummary],
16178 provider_session_id: Option<&str>,
16179) -> Result<String, NodeFailure> {
16180 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16181 Some(id) => candidate.session_id_hint == id,
16182 None => true,
16183 });
16184 let candidate = matches.next().ok_or_else(|| {
16185 failure(
16186 NodeFailureCode::SessionRecordConflict,
16187 if provider_session_id.is_some() {
16188 "no history candidate matches the managed provider session"
16189 } else {
16190 "no history candidate exists for the managed session"
16191 },
16192 )
16193 })?;
16194 if matches.next().is_some() {
16195 return Err(failure(
16196 NodeFailureCode::SessionRecordConflict,
16197 if provider_session_id.is_some() {
16198 "history identity is ambiguous for the managed provider session"
16199 } else {
16200 "history is ambiguous without a verified provider session identity"
16201 },
16202 ));
16203 }
16204 Ok(candidate.id.clone())
16205}
16206
16207fn resolved_spawn_profile_summary(
16208 profile: &SpawnProfileDefaults,
16209 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16210) -> Option<SpawnProfileSummary> {
16211 let environment_profile = match profile.environment_profile_id.as_ref() {
16212 None => None,
16213 Some(profile_id) => {
16214 let binding = environment_profiles.get(profile_id)?;
16215 if binding.provider != profile.provider
16216 || binding.native_profile_id(profile.mode).is_none()
16217 {
16218 return None;
16219 }
16220 Some(ResolvedEnvironmentProfileReceipt {
16221 profile_id: binding.id.clone(),
16222 profile_revision: binding.revision.clone(),
16223 network_allowlist: None,
16224 browser_profile_id: None,
16225 })
16226 }
16227 };
16228 Some(SpawnProfileSummary {
16229 id: profile.profile_id.clone(),
16230 revision: profile.revision.clone(),
16231 environment_profile,
16232 })
16233}
16234
16235fn session_record_export_target_matches(
16254 expected: &ManagedSessionRecord,
16255 current: &ManagedSessionRecord,
16256 session: &SessionAddress,
16257 allow_clean_detachment: bool,
16258) -> bool {
16259 if current == expected && current.active_session.as_ref() == Some(session) {
16260 return current.state == ManagedSessionState::Live;
16261 }
16262 allow_clean_detachment
16263 && current.state == ManagedSessionState::Dormant
16264 && current.active_session.is_none()
16265 && current.record_id == expected.record_id
16266 && current.display_name == expected.display_name
16267 && current.provider == expected.provider
16268 && current.mode == expected.mode
16269 && current.workspace_id == expected.workspace_id
16270 && current.canonical_root == expected.canonical_root
16271 && current.provider_session == expected.provider_session
16272 && current.environment_profile == expected.environment_profile
16273 && current.bundle == expected.bundle
16274 && current.context_id == expected.context_id
16275 && current.context == expected.context
16276 && current.exported_context == expected.exported_context
16277 && current.task_binding == expected.task_binding
16278 && current.created_at_unix_ms == expected.created_at_unix_ms
16279 && current.last_error == expected.last_error
16280}
16281
16282fn session_record_context_export_binding_is_exact(
16283 expected: &ManagedSessionRecord,
16284 current: &ManagedSessionRecord,
16285 identity: Option<&ProviderSessionIdentity>,
16286 session: &SessionAddress,
16287 binding: &SessionBinding,
16288 provider: &AgentId,
16289 current_root: &str,
16290 allow_clean_detachment: bool,
16291) -> bool {
16292 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
16293 && current.provider_session.as_ref() == identity
16294 && current.workspace_id == session.workspace_id
16295 && provider == ¤t.provider
16296 && binding.workspace_id == session.workspace_id
16297 && binding.generation == session.session.generation
16298 && binding.record_id.as_ref() == Some(¤t.record_id)
16299 && platform::roots_equal(
16300 current_root,
16301 windows_path_text(¤t.canonical_root),
16302 )
16303}
16304
16305fn session_record_context_export_source_is_usable(
16306 record: &ManagedSessionRecord,
16307 status: &SessionStatus,
16308 allow_clean_detachment: bool,
16309) -> bool {
16310 (record.state == ManagedSessionState::Live
16311 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
16312 && context_pack_source_status_is_usable(status)
16313}
16314
16315fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
16316 PromptFraming::BracketedPaste
16317}
16318
16319fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
16320 if text.len() > MAX_NODE_TEXT_BYTES {
16321 return Err(failure(
16322 NodeFailureCode::InvalidRequest,
16323 &format!(
16324 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
16325 text.len(),
16326 ),
16327 ));
16328 }
16329 Ok(())
16330}
16331
16332fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
16333 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
16334 return Err(failure(
16335 NodeFailureCode::InvalidRequest,
16336 &format!(
16337 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
16338 bytes.len(),
16339 ),
16340 ));
16341 }
16342 Ok(())
16343}
16344
16345fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
16346 validate_terminal_bytes(&bytes)?;
16347 Ok(InputAction::TerminalBytes(bytes))
16348}
16349
16350fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
16351 if root.is_empty()
16352 || root.len() > MAX_WORKSPACE_ROOT_BYTES
16353 || root.chars().any(char::is_control)
16354 {
16355 return Err(failure(
16356 NodeFailureCode::InvalidWorkspaceRoot,
16357 &format!(
16358 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
16359 ),
16360 ));
16361 }
16362 Ok(())
16363}
16364
16365fn canonical_standalone_workspace_candidate(
16366 workspace_id: &WorkspaceId,
16367 root: &str,
16368) -> Result<String, NodeServerError> {
16369 let path = Path::new(root);
16370 match std::fs::symlink_metadata(path) {
16371 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
16372 .map(|workspace| workspace.canonical_root),
16373 Err(error) if error.kind() == io::ErrorKind::NotFound => {
16374 if !path.is_absolute() {
16375 return Err(NodeServerError::InvalidWorkspaceRoot {
16376 workspace_id: workspace_id.clone(),
16377 path: root.to_owned(),
16378 message: "path is not absolute".to_owned(),
16379 });
16380 }
16381 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16382 workspace_id: workspace_id.clone(),
16383 path: root.to_owned(),
16384 message: "path has no standalone directory name".to_owned(),
16385 })?;
16386 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16387 workspace_id: workspace_id.clone(),
16388 path: root.to_owned(),
16389 message: "path has no parent directory".to_owned(),
16390 })?;
16391 let canonical = std::fs::canonicalize(parent)
16392 .map(|parent| parent.join(name))
16393 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
16394 workspace_id: workspace_id.clone(),
16395 path: root.to_owned(),
16396 message: error.to_string(),
16397 })?;
16398 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
16399 NodeServerError::InvalidWorkspaceRoot {
16400 workspace_id: workspace_id.clone(),
16401 path: path.to_string_lossy().into_owned(),
16402 message: "canonical path is not valid Unicode".to_owned(),
16403 }
16404 })?;
16405 let canonical_root = platform::normalize_canonical_root(canonical_root);
16406 validate_workspace_root(workspace_id, &canonical_root)?;
16407 Ok(canonical_root)
16408 }
16409 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
16410 workspace_id: workspace_id.clone(),
16411 path: root.to_owned(),
16412 message: error.to_string(),
16413 }),
16414 }
16415}
16416
16417fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
16418 let Some(revision) = revision else {
16419 return Ok(());
16420 };
16421 if revision.is_empty()
16422 || revision.len() > MAX_REPOSITORY_PATH_BYTES
16423 || revision.chars().any(char::is_control)
16424 {
16425 return Err(failure(
16426 NodeFailureCode::InvalidRequest,
16427 &format!(
16428 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
16429 ),
16430 ));
16431 }
16432 Ok(())
16433}
16434
16435fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16436 let code = match error.kind {
16437 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
16438 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
16439 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
16440 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
16441 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
16442 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
16443 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
16444 };
16445 failure(code, &error.message)
16446}
16447
16448fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
16449 let code = match error.kind {
16450 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
16451 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
16452 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
16453 NodeFailureCode::BackendOperationFailed
16454 }
16455 StandaloneWorkspaceErrorKind::RecoveryRequired => {
16456 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
16457 }
16458 };
16459 failure(code, error.message)
16460}
16461
16462fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16463 let (code, message) = match error.kind {
16464 GitWorktreeErrorKind::Invalid => (
16465 NodeFailureCode::InvalidRequest,
16466 "managed Git input is invalid",
16467 ),
16468 GitWorktreeErrorKind::NotRepository => (
16469 NodeFailureCode::NotGitRepository,
16470 "managed source is not a Git repository",
16471 ),
16472 GitWorktreeErrorKind::Conflict => (
16473 NodeFailureCode::WorktreeConflict,
16474 "managed Git identity conflicts with existing repository state",
16475 ),
16476 GitWorktreeErrorKind::Protected => (
16477 NodeFailureCode::ManagedWorktreeOwnershipConflict,
16478 "managed worktree ownership validation failed",
16479 ),
16480 GitWorktreeErrorKind::Dirty => (
16481 NodeFailureCode::WorktreeDirty,
16482 "managed worktree contains uncommitted changes",
16483 ),
16484 GitWorktreeErrorKind::Locked => (
16485 NodeFailureCode::WorktreeLocked,
16486 "managed worktree is locked",
16487 ),
16488 GitWorktreeErrorKind::Failed => (
16489 NodeFailureCode::BackendOperationFailed,
16490 "managed Git operation failed",
16491 ),
16492 };
16493 NodeFailure { code, message: message.to_owned() }
16494}
16495
16496fn managed_spawn_receipt(
16497 resolved: &ResolvedSpawnSpec,
16498 incarnation_id: NodeIncarnationId,
16499 session: SessionAddress,
16500 lease: ManagedWorktreeLeaseSnapshot,
16501 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
16502 bundle: Option<ResolvedBundleReceipt>,
16503 context: Option<ResolvedContextPackReceipt>,
16504) -> ManagedWorktreeSpawnReceipt {
16505 let mut managed = resolved.clone();
16506 managed.target.worktree_id = Some(lease.workspace_id.clone());
16507 ManagedWorktreeSpawnReceipt {
16508 spawn: managed.receipt_with_materialization(
16509 incarnation_id,
16510 session,
16511 environment_profile,
16512 bundle,
16513 context,
16514 ),
16515 lease,
16516 }
16517}
16518
16519fn validate_selected_worktree(
16520 source_root: &str,
16521 selected_root: &str,
16522 worktrees: &[NativeGitWorktreeSnapshot],
16523) -> Result<(), NodeFailure> {
16524 if !worktrees
16525 .iter()
16526 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
16527 {
16528 return Err(failure(
16529 NodeFailureCode::WorktreeProtected,
16530 "source workspace is not an authoritative non-bare Git worktree root",
16531 ));
16532 }
16533 if !worktrees.iter().any(|worktree| {
16534 worktree_paths_equal(&worktree.path, selected_root)
16535 && !worktree.is_main
16536 && !worktree.is_bare
16537 }) {
16538 return Err(failure(
16539 NodeFailureCode::WorktreeProtected,
16540 "selected workspace is not an eligible Git worktree in the source repository",
16541 ));
16542 }
16543 Ok(())
16544}
16545
16546fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
16547 let code = match error.kind() {
16548 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16549 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16550 NodeFailureCode::RepositoryPathUnsafe
16551 }
16552 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16553 WorkspaceFileReadErrorKind::NotRegularFile => {
16554 NodeFailureCode::RepositoryFileNotRegular
16555 }
16556 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16557 NodeFailureCode::RepositoryFileReadFailed
16558 }
16559 WorkspaceFileReadErrorKind::RevisionConflict => {
16560 NodeFailureCode::RepositoryFileRevisionConflict
16561 }
16562 WorkspaceFileReadErrorKind::AlreadyExists
16563 | WorkspaceFileReadErrorKind::ParentNotFound
16564 | WorkspaceFileReadErrorKind::ParentNotDirectory
16565 | WorkspaceFileReadErrorKind::Canceled => {
16566 NodeFailureCode::RepositoryFileReadFailed
16567 }
16568 };
16569 failure(code, "repository file read failed")
16570}
16571
16572fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
16573 let code = match error.kind() {
16574 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16575 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16576 NodeFailureCode::RepositoryPathUnsafe
16577 }
16578 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16579 WorkspaceFileReadErrorKind::NotRegularFile => {
16580 NodeFailureCode::RepositoryFileNotRegular
16581 }
16582 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16583 NodeFailureCode::RepositoryFileWriteFailed
16584 }
16585 WorkspaceFileReadErrorKind::RevisionConflict => {
16586 NodeFailureCode::RepositoryFileRevisionConflict
16587 }
16588 WorkspaceFileReadErrorKind::AlreadyExists
16589 | WorkspaceFileReadErrorKind::ParentNotFound
16590 | WorkspaceFileReadErrorKind::ParentNotDirectory
16591 | WorkspaceFileReadErrorKind::Canceled => {
16592 NodeFailureCode::RepositoryFileWriteFailed
16593 }
16594 };
16595 failure(code, "repository file write failed")
16596}
16597
16598async fn settle_workspace_entry_create<T>(
16599 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
16600 commit_state: Arc<AtomicU8>,
16601 deadline: Duration,
16602 timeout_message: &'static str,
16603 task_failure_message: &'static str,
16604) -> Result<T, NodeFailure> {
16605 let joined = match timeout(deadline, &mut task).await {
16606 Ok(joined) => joined,
16607 Err(_) => {
16608 if commit_state
16609 .compare_exchange(
16610 WORKSPACE_ENTRY_CREATE_PENDING,
16611 WORKSPACE_ENTRY_CREATE_CANCELED,
16612 Ordering::AcqRel,
16613 Ordering::Acquire,
16614 )
16615 .is_ok()
16616 {
16617 return Err(failure(
16618 NodeFailureCode::RepositoryEntryCreateTimedOut,
16619 timeout_message,
16620 ));
16621 }
16622 debug_assert_eq!(
16623 commit_state.load(Ordering::Acquire),
16624 WORKSPACE_ENTRY_CREATE_COMMITTING,
16625 );
16626 task.await
16627 }
16628 };
16629 joined
16630 .map_err(|_| {
16631 failure(
16632 NodeFailureCode::RepositoryEntryCreateFailed,
16633 task_failure_message,
16634 )
16635 })?
16636 .map_err(workspace_entry_create_failure)
16637}
16638
16639fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
16640 let code = match error.kind() {
16641 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16642 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16643 NodeFailureCode::RepositoryPathUnsafe
16644 }
16645 WorkspaceFileReadErrorKind::AlreadyExists => {
16646 NodeFailureCode::RepositoryEntryAlreadyExists
16647 }
16648 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
16649 NodeFailureCode::RepositoryParentNotFound
16650 }
16651 WorkspaceFileReadErrorKind::ParentNotDirectory
16652 | WorkspaceFileReadErrorKind::NotRegularFile => {
16653 NodeFailureCode::RepositoryParentNotDirectory
16654 }
16655 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16656 NodeFailureCode::RepositoryEntryCreateFailed
16657 }
16658 WorkspaceFileReadErrorKind::RevisionConflict => {
16659 NodeFailureCode::RepositoryEntryCreateFailed
16660 }
16661 WorkspaceFileReadErrorKind::Canceled => {
16662 NodeFailureCode::RepositoryEntryCreateTimedOut
16663 }
16664 };
16665 failure(code, "repository entry create failed")
16666}
16667
16668fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
16669 let value = digest(&SHA256, bytes)
16670 .as_ref()
16671 .iter()
16672 .map(|byte| format!("{byte:02x}"))
16673 .collect();
16674 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
16675}
16676
16677fn managed_spawn_request_digest_v2(
16678 request: &ManagedWorktreeSpawnRequestV2,
16679) -> Result<String, NodeFailure> {
16680 let encoded = serde_json::to_vec(request).map_err(|_| {
16681 failure(
16682 NodeFailureCode::InvalidRequest,
16683 "managed worktree V2 request could not be canonicalized",
16684 )
16685 })?;
16686 let mut value = String::with_capacity(71);
16687 value.push_str("sha256:");
16688 for byte in digest(&SHA256, &encoded).as_ref() {
16689 use std::fmt::Write as _;
16690 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
16691 }
16692 Ok(value)
16693}
16694
16695fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
16696 let code = match error {
16697 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
16698 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
16699 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
16700 NativeSessionPreviewError::UnsupportedProvider
16701 | NativeSessionPreviewError::PreviewUnavailable
16702 | NativeSessionPreviewError::SessionNotFound
16703 | NativeSessionPreviewError::AmbiguousSession => {
16704 NodeFailureCode::BackendOperationFailed
16705 }
16706 };
16707 failure(code, "native session preview failed")
16708}
16709
16710fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
16711 let code = match error.kind() {
16712 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
16713 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
16714 };
16715 failure(code, "host directory browse failed")
16716}
16717
16718fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
16719 let code = match error {
16720 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
16721 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
16722 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
16723 NodeFailureCode::DeliveryStageConflict
16724 }
16725 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
16726 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
16727 NodeFailureCode::DeliveryChunkOutOfOrder
16728 }
16729 DeliveryStoreError::BlobDigestMismatch => {
16730 NodeFailureCode::DeliveryBlobDigestMismatch
16731 }
16732 DeliveryStoreError::BundleDigestMismatch => {
16733 NodeFailureCode::DeliveryBundleDigestMismatch
16734 }
16735 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
16736 DeliveryStoreError::Unavailable
16737 | DeliveryStoreError::Corrupt
16738 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
16739 };
16740 failure(code, "delivery operation failed")
16741}
16742
16743fn validate_workspace_root(
16744 workspace_id: &WorkspaceId,
16745 canonical_root: &str,
16746) -> Result<(), NodeServerError> {
16747 #[cfg(windows)]
16748 if canonical_root.starts_with(r"\\") {
16749 return Err(NodeServerError::InvalidWorkspaceRoot {
16750 workspace_id: workspace_id.clone(),
16751 path: canonical_root.to_owned(),
16752 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
16753 });
16754 }
16755 if !platform::workspace_root_supported(canonical_root)
16756 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
16757 {
16758 return Err(NodeServerError::InvalidWorkspaceRoot {
16759 workspace_id: workspace_id.clone(),
16760 path: canonical_root.to_owned(),
16761 message: "canonical path must be absolute, bounded, and free of control characters"
16762 .to_owned(),
16763 });
16764 }
16765 Ok(())
16766}
16767
16768fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
16769 NodeFailure {
16770 code,
16771 message: node_failure_category(code).to_owned(),
16772 }
16773}
16774
16775fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
16776 let code = match error {
16777 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
16778 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
16779 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
16780 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
16781 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
16782 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
16783 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
16784 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
16785 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
16786 };
16787 failure(code, "harness MCP proxy operation failed")
16788}
16789
16790static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
16794
16795fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
16802 let trace = launch.trace.as_ref()?;
16803 let trace_dir = std::env::var(&trace.dir_env).ok()?;
16804 if trace_dir.is_empty() {
16805 return None;
16806 }
16807 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
16808 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
16809 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
16810}
16811
16812fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
16813 let remaining = deadline.saturating_duration_since(Instant::now());
16814 if remaining.is_zero() {
16815 Err(failure(
16816 NodeFailureCode::SpawnDeadlineExceeded,
16817 "spawn deadline elapsed",
16818 ))
16819 } else {
16820 Ok(remaining)
16821 }
16822}
16823
16824fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
16825 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
16826 match deadline {
16827 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
16828 None => Ok(bounded),
16829 }
16830}
16831
16832fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
16833 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
16834 failure(
16835 NodeFailureCode::SpawnDeadlineExceeded,
16836 "spawn dispatch exceeded the spawn deadline",
16837 )
16838 } else {
16839 error
16840 }
16841}
16842
16843fn spawn_runtime_capabilities(
16844 capabilities: &SpawnRequiredCapabilities,
16845) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
16846 capabilities
16847 .iter()
16848 .map(|capability| match capability.as_str() {
16849 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
16850 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
16851 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
16852 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
16853 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
16854 }
16855 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
16856 _ => Err(failure(
16857 NodeFailureCode::UnsupportedSpawnCapability,
16858 "spawn required capability is unknown",
16859 )),
16860 })
16861 .collect()
16862}
16863
16864fn persistence_failure(_error: io::Error) -> NodeFailure {
16865 NodeFailure {
16866 code: NodeFailureCode::BackendOperationFailed,
16867 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
16868 }
16869}
16870
16871fn node_failure_category(code: NodeFailureCode) -> &'static str {
16872 match code {
16873 NodeFailureCode::InvalidRequest => "invalid-request",
16874 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
16875 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
16876 NodeFailureCode::ReservationNotFound => "reservation-not-found",
16877 NodeFailureCode::ReservationConflict => "reservation-conflict",
16878 NodeFailureCode::ReservationExpired => "reservation-expired",
16879 NodeFailureCode::BindingMismatch => "binding-mismatch",
16880 NodeFailureCode::NotActivated => "not-activated",
16881 NodeFailureCode::CallNotFound => "call-not-found",
16882 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
16883 NodeFailureCode::ResponseTooLarge => "response-too-large",
16884 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
16885 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
16886 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
16887 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
16888 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
16889 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
16890 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
16891 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
16892 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
16893 NodeFailureCode::Unauthorized => "unauthorized",
16894 NodeFailureCode::ObserverReadOnly => "observer-read-only",
16895 NodeFailureCode::ControllerBusy => "controller-busy",
16896 NodeFailureCode::ControllerRequired => "controller-required",
16897 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
16898 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
16899 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
16900 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
16901 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
16902 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
16903 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
16904 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
16905 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
16906 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
16907 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
16908 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
16909 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
16910 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
16911 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
16912 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
16913 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
16914 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
16915 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
16916 NodeFailureCode::GitReadFailed => "git-read-failed",
16917 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
16918 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
16919 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
16920 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
16921 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
16922 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
16923 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
16924 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
16925 NodeFailureCode::TurnInFlight => "turn-in-flight",
16926 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
16927 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
16928 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
16929 "unsupported-network-allowlist-mapping"
16930 }
16931 NodeFailureCode::BrowserStationProbeUnavailable => {
16932 "browser-station-probe-unavailable"
16933 }
16934 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
16935 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
16936 NodeFailureCode::EnvironmentProfileBindingMismatch => {
16937 "environment-profile-binding-mismatch"
16938 }
16939 NodeFailureCode::UnknownBundle => "unknown-bundle",
16940 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
16941 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
16942 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
16943 NodeFailureCode::ContextPackBusy => "context-pack-busy",
16944 NodeFailureCode::ContextPackMaterializationFailed => {
16945 "context-pack-materialization-failed"
16946 }
16947 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
16948 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
16949 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
16950 NodeFailureCode::WorkspaceBusy => "workspace-busy",
16951 NodeFailureCode::LastWorkspace => "last-workspace",
16952 NodeFailureCode::NotGitRepository => "not-git-repository",
16953 NodeFailureCode::WorktreeConflict => "worktree-conflict",
16954 NodeFailureCode::WorktreeProtected => "worktree-protected",
16955 NodeFailureCode::WorktreeDirty => "worktree-dirty",
16956 NodeFailureCode::WorktreeLocked => "worktree-locked",
16957 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
16958 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
16959 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
16960 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
16961 "managed-worktree-profile-revision-mismatch"
16962 }
16963 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
16964 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
16965 "standalone-workspace-recovery-required"
16966 }
16967 NodeFailureCode::UnknownSession => "unknown-session",
16968 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
16969 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
16970 NodeFailureCode::SessionRecordBusy => "session-record-busy",
16971 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
16972 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
16973 NodeFailureCode::WorkspaceRegistrationRequired => {
16974 "workspace-registration-required"
16975 }
16976 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
16977 NodeFailureCode::StaleGeneration => "stale-generation",
16978 NodeFailureCode::BackendBusy => "backend-busy",
16979 NodeFailureCode::BackendDisconnected => "backend-disconnected",
16980 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
16981 NodeFailureCode::ShuttingDown => "shutting-down",
16982 }
16983}
16984
16985fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
16986 NodeServerError::DurableState(io::Error::new(error.kind(), category))
16987}
16988
16989fn durable_state_load_error(error: io::Error) -> NodeServerError {
16990 let category = match session_registry::state_load_refusal(&error) {
16991 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
16992 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
16993 }
16994 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
16995 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
16996 }
16997 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
16998 DURABLE_STATE_CONFLICT_ERROR
16999 }
17000 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17001 };
17002 durable_state_server_error(error, category)
17003}
17004
17005fn unix_time_ms() -> u64 {
17006 std::time::SystemTime::now()
17007 .duration_since(std::time::UNIX_EPOCH)
17008 .unwrap_or_default()
17009 .as_millis()
17010 .min(u64::MAX as u128) as u64
17011}
17012
17013fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17014 if record.provider_session.is_some() {
17015 ManagedSessionState::Dormant
17016 } else {
17017 ManagedSessionState::Unavailable
17018 }
17019}
17020
17021fn active_record_state(
17022 identity_admitted: bool,
17023 provider_session_is_known: bool,
17024) -> ManagedSessionState {
17025 if identity_admitted && !provider_session_is_known {
17026 ManagedSessionState::IdentityPending
17027 } else {
17028 ManagedSessionState::Live
17029 }
17030}
17031
17032fn managed_worktree_record_holders(
17033 records: &[ManagedSessionRecord],
17034) -> Vec<(SessionRecordId, WorkspaceId)> {
17035 records
17036 .iter()
17037 .filter(|record| record.provider_session.is_some())
17038 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17039 .collect()
17040}
17041
17042fn managed_spawn_record_ownership(
17043 binding: &SessionBinding,
17044 effective_runtime_policy: ProviderRuntimePolicy,
17045) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17046 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17047 if effective_runtime_policy.provider_session_identity {
17048 (binding.record_id.clone(), None)
17049 } else {
17050 (None, binding.record_id.clone())
17051 }
17052}
17053
17054#[derive(Debug, Error)]
17055pub enum NodeServerError {
17056 #[error("node endpoint must be a bounded absolute local transport path")]
17057 InvalidEndpoint,
17058 #[error("node access token must contain 1..=4096 bytes")]
17059 InvalidAccessToken,
17060 #[error("node HTTP observer must listen on a loopback address: {0}")]
17061 InvalidApiListen(std::net::SocketAddr),
17062 #[error("node browser bridge must listen on a loopback address: {0}")]
17063 InvalidBridgeListen(std::net::SocketAddr),
17064 #[error("node browser bridge token must contain 1..=4096 bytes")]
17065 InvalidBridgeToken,
17066 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17067 BridgeUnderlayRequiresBridgeListen,
17068 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17069 BridgeUnderlayRequiresBridgeToken,
17070 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17071 InvalidBridgeUnderlayToken,
17072 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17073 InvalidBridgeUnderlayKey,
17074 #[error("node call-home relay must be a loopback address with a nonzero port: {0}")]
17075 InvalidCallHome(std::net::SocketAddr),
17076 #[error("node could not reach its call-home relay: {0}")]
17077 CallHomeTransport(#[source] std::io::Error),
17078 #[error("node durable state path must be an absolute file path: {0}")]
17079 InvalidStatePath(String),
17080 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17081 InvalidHarnessMcpHelper,
17082 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17083 InvalidNetworkAllowlistCatalogPath(String),
17084 #[error("network allowlist catalog could not be read: {0}")]
17085 NetworkAllowlistCatalogIo(#[source] std::io::Error),
17086 #[error("network allowlist catalog line {line}: {message}")]
17087 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17088 #[error("network allowlist catalog: {0}")]
17089 InvalidNetworkAllowlistCatalog(String),
17090 #[error("network allowlist catalog exceeds the {max}-entry limit")]
17091 NetworkAllowlistCatalogCapacity { max: usize },
17092 #[error("the local state directory is unavailable; supply an explicit state path")]
17093 LocalStateDirectoryUnavailable,
17094 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17095 LocalRuntimeDirectoryUnavailable,
17096 #[error("node durable state failed: {0}")]
17097 DurableState(io::Error),
17098 #[error("node requires at least one configured workspace")]
17099 NoWorkspaces,
17100 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17101 InvalidWorkspaceRoot {
17102 workspace_id: WorkspaceId,
17103 path: String,
17104 message: String,
17105 },
17106 #[error("duplicate workspace ID: {0}")]
17107 DuplicateWorkspaceId(WorkspaceId),
17108 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17109 DuplicateWorkspaceRoot {
17110 first: WorkspaceId,
17111 second: WorkspaceId,
17112 root: String,
17113 },
17114 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17115 ManagedWorktreeProfileRequired(WorkspaceId),
17116 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17117 ManagedWorktreeProfileModeMismatch(WorkspaceId),
17118 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17119 DuplicateManagedWorktreeProfile {
17120 workspace_id: WorkspaceId,
17121 profile_id: WorktreeProfileId,
17122 },
17123 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17124 ManagedWorktreeProfileCapacity {
17125 workspace_id: WorkspaceId,
17126 max: usize,
17127 },
17128 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17129 InvalidManagedWorktreeProfile {
17130 workspace_id: WorkspaceId,
17131 profile_id: WorktreeProfileId,
17132 message: String,
17133 },
17134 #[error("duplicate node environment profile: {0}")]
17135 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17136 #[error("node environment profile provider is unavailable: {0}")]
17137 EnvironmentProfileProviderUnavailable(AgentId),
17138 #[error("node environment profile capacity is {max}")]
17139 EnvironmentProfileCapacity { max: usize },
17140 #[error("node environment profiles reuse native profile ID '{0}'")]
17141 DuplicateNativeEnvironmentProfile(String),
17142 #[error("node native environment profile is invalid: {0}")]
17143 NativeEnvironmentProfile(String),
17144 #[error("node session-environment materialization root must be absolute")]
17145 InvalidSessionEnvironmentRoot,
17146 #[error("node session-environment materializer is unavailable")]
17147 SessionEnvironmentMaterializerRequired,
17148 #[error("node session-environment materializer failed to initialize")]
17149 SessionEnvironmentMaterializer,
17150 #[error("node bundle catalog is invalid: {0}")]
17151 BundleCatalog(String),
17152 #[error("node delivery store failed to initialize: {0}")]
17153 DeliveryStore(String),
17154 #[error("node context pack store failed to initialize")]
17155 ContextPackStore,
17156 #[error("active agent registry failed: {0}")]
17157 Registry(String),
17158 #[error("node provider contract manifest is invalid: {0}")]
17159 ProviderContractManifest(String),
17160 #[error("named pipe I/O failed: {0}")]
17161 Io(#[from] io::Error),
17162 #[error("node HTTP observer failed: {0}")]
17163 HttpApi(io::Error),
17164 #[error("node browser bridge failed: {0}")]
17165 BridgeApi(io::Error),
17166 #[error(transparent)]
17167 Frame(#[from] FrameError),
17168 #[error("node handshake failed: {0}")]
17169 Handshake(String),
17170 #[error("node authentication frame was not received before the bounded deadline")]
17171 AuthenticationTimedOut,
17172 #[error("node authentication primitive failed: {0}")]
17173 Authentication(String),
17174 #[error("node incarnation identity generation failed: {0}")]
17175 IncarnationIdentity(String),
17176 #[error("node pre-authentication limiter is closed")]
17177 PreauthClosed,
17178 #[error("node authenticated connection limit was reached")]
17179 AuthenticatedConnectionLimit,
17180 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17181 SignalStreamClosed,
17182 #[error("node stop-all dispatch failed: {0:?}")]
17183 ShutdownDispatch(NodeFailure),
17184 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17185 ShutdownTimedOut { active_native_sessions: usize },
17186}
17187
17188impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17189 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17190 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17191 match error {
17192 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17193 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17194 E::InvalidEntry { line, message } => {
17195 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17196 }
17197 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17198 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17199 }
17200 }
17201}
17202
17203#[cfg(test)]
17204mod agent_stream_blocked_tests {
17205 use super::*;
17206 use gate4agent_types::{
17207 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17208 ProviderSource,
17209 };
17210
17211 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17212 ProviderSource {
17213 family,
17214 binding: AdapterBinding::new(
17215 AdapterId::new(adapter).unwrap(),
17216 "fixture/v1",
17217 AdapterVerification::SyntheticFixture,
17218 )
17219 .unwrap(),
17220 }
17221 }
17222
17223 fn provider_control_event_at(
17224 family: AdapterFamily,
17225 adapter: &str,
17226 source_sequence: u64,
17227 provider_event: ProviderEvent,
17228 ) -> ControlEvent {
17229 ControlEvent {
17230 sequence: 41,
17231 command_id: None,
17232 instance_id: AgentInstanceId(7),
17233 generation: SessionGeneration(3),
17234 event: ControlEventKind::ProviderEvent {
17235 sequence: 8,
17236 source: provider_source(family, adapter),
17237 source_sequence,
17238 event: provider_event,
17239 },
17240 }
17241 }
17242
17243 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17244 provider_control_event_at(family, "fixture", 9, provider_event)
17245 }
17246
17247 fn denied_host_request(
17248 by: ProviderHostDecisionAuthority,
17249 reason: Option<String>,
17250 ) -> ControlEvent {
17251 provider_control_event_at(
17252 AdapterFamily::Acp,
17253 "claude-code",
17254 2,
17255 ProviderEvent::HostRequestObserved {
17256 method: "terminal/create".to_owned(),
17257 params_json: String::new(),
17258 decision: ProviderHostRequestDecision::Denied { by },
17259 outcome: ProviderHostRequestOutcome::Executed,
17260 reason,
17261 },
17262 )
17263 }
17264
17265 fn blocked(
17266 event: &ControlEvent,
17267 ) -> (
17268 Option<String>,
17269 String,
17270 BlockAuthorityV1,
17271 Option<String>,
17272 String,
17273 Option<String>,
17274 ) {
17275 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17276 chunk.validate().expect("minted chunk must validate");
17277 let AgentStreamChunkKindV1::Blocked {
17278 correlation_id,
17279 tool_class,
17280 authority,
17281 reason_kind,
17282 reason,
17283 help,
17284 } = chunk.kind
17285 else {
17286 panic!("expected AgentStreamChunkKindV1::Blocked");
17287 };
17288 (correlation_id, tool_class, authority, reason_kind, reason, help)
17289 }
17290
17291 #[test]
17295 fn the_enabled_provider_matrix_is_exact() {
17296 let registry = active_registry().unwrap();
17297 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
17298 assert_eq!(
17299 providers
17300 .iter()
17301 .map(|contract| contract.provider.as_str())
17302 .collect::<Vec<_>>(),
17303 vec!["claude", "codex", "grok", "kimi"],
17304 );
17305 assert!(adapters.iter().all(|contract| {
17306 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
17307 }));
17308 }
17309
17310 #[test]
17313 fn gate_denied_host_request_mints_a_blocked_chunk() {
17314 let gate_text =
17315 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
17316 let event = denied_host_request(
17317 ProviderHostDecisionAuthority::Gate,
17318 Some(gate_text.to_owned()),
17319 );
17320 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
17321 assert_eq!(chunk.source_sequence, 2);
17322 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
17323 assert_eq!(authority, BlockAuthorityV1::HostGate);
17324 assert_eq!(reason, gate_text);
17325 assert_eq!(correlation_id, None);
17326 assert_eq!(reason_kind, None);
17327 assert_eq!(help, None);
17328 assert_eq!(tool_class, "Write");
17329 }
17330
17331 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.";
17332
17333 #[test]
17339 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
17340 let event = provider_control_event(
17341 AdapterFamily::Acp,
17342 ProviderEvent::ToolCompleted {
17343 id: "toolu_9".to_owned(),
17344 output: CLASSIFIER_SAMPLE.to_owned(),
17345 is_error: true,
17346 duration_ms: None,
17347 agent_id: None,
17348 non_execution_kind: Some("permission-rule".to_owned()),
17349 },
17350 );
17351 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
17352 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
17353 assert_eq!(reason, "Blocked by classifier");
17354 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
17355 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
17356 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
17357 }
17358
17359 #[test]
17364 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
17365 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.";
17366 let event = provider_control_event(
17367 AdapterFamily::Acp,
17368 ProviderEvent::SessionEnded {
17369 result: "JSON-RPC error -32603: Internal error".to_owned(),
17370 cost_usd: None,
17371 is_error: true,
17372 stop_reason: Some(ProviderStopReason::ProviderError {
17373 code: -32603,
17374 message: message.to_owned(),
17375 vendor_code: Some("usageLimitExceeded".to_owned()),
17376 }),
17377 },
17378 );
17379 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
17380 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
17381 assert_eq!(reason, message);
17382 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
17383 assert_eq!(tool_class, "Session");
17384 }
17385
17386 #[test]
17389 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
17390 let event = provider_control_event(
17391 AdapterFamily::Acp,
17392 ProviderEvent::SessionEnded {
17393 result: "refusal".to_owned(),
17394 cost_usd: None,
17395 is_error: true,
17396 stop_reason: Some(ProviderStopReason::Refusal),
17397 },
17398 );
17399 let (_, _, authority, _, _, _) = blocked(&event);
17400 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
17401 }
17402
17403 #[test]
17407 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
17408 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
17409 let event = provider_control_event(
17410 AdapterFamily::Acp,
17411 ProviderEvent::ToolCompleted {
17412 id: "toolu_1".to_owned(),
17413 output: "command exited with status 1".to_owned(),
17414 is_error: true,
17415 duration_ms: Some(12),
17416 agent_id: None,
17417 non_execution_kind,
17418 },
17419 );
17420 assert!(agent_stream_chunk(&event).is_none());
17421 }
17422 let narrative = provider_control_event(
17423 AdapterFamily::Acp,
17424 ProviderEvent::ToolCompleted {
17425 id: "toolu_2".to_owned(),
17426 output: "the harness blocked the write, approval needed".to_owned(),
17427 is_error: false,
17428 duration_ms: Some(3),
17429 agent_id: None,
17430 non_execution_kind: None,
17431 },
17432 );
17433 assert!(agent_stream_chunk(&narrative).is_none());
17434 }
17435
17436 #[test]
17440 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
17441 for (decision, outcome) in [
17442 (
17443 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17444 ProviderHostRequestOutcome::Executed,
17445 ),
17446 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
17447 (
17448 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17449 ProviderHostRequestOutcome::Failed {
17450 error: "terminal/create spawn failed: os error 3".to_owned(),
17451 },
17452 ),
17453 ] {
17454 let event = provider_control_event_at(
17455 AdapterFamily::Acp,
17456 "claude-code",
17457 3,
17458 ProviderEvent::HostRequestObserved {
17459 method: "fs/read_text_file".to_owned(),
17460 params_json: String::new(),
17461 decision: decision.clone(),
17462 outcome,
17463 reason: None,
17464 },
17465 );
17466 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
17467 }
17468 }
17469
17470 #[test]
17474 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
17475 for (by, expected_authority, expected_reason) in [
17476 (
17477 ProviderHostDecisionAuthority::Operator,
17478 BlockAuthorityV1::Operator,
17479 "declined by the operator",
17480 ),
17481 (
17482 ProviderHostDecisionAuthority::DeadlinePolicy,
17483 BlockAuthorityV1::HostDeadline,
17484 "denied by host policy after the operator deadline expired",
17485 ),
17486 ] {
17487 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
17488 assert_eq!(authority, expected_authority);
17489 assert_eq!(reason, expected_reason);
17490 }
17491 }
17492
17493 #[test]
17497 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
17498 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
17499 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
17500 ProviderHostDecisionAuthority::Policy,
17501 Some(oversized),
17502 ));
17503 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
17504 }
17505}
17506
17507#[cfg(test)]
17508mod standalone_capability_platform_tests {
17509 use super::*;
17510
17511 #[test]
17512 fn standalone_workspace_capability_requires_windows_identity_guards() {
17513 let capabilities = baseline_capabilities().unwrap();
17514 let advertised = capabilities.iter().any(|capability| {
17515 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
17516 });
17517 assert_eq!(advertised, cfg!(windows));
17518
17519 let request = NodeRequest::CreateStandaloneWorkspace {
17520 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
17521 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
17522 initial_branch: None,
17523 };
17524 assert_eq!(
17525 request_uses_unnegotiated_capability(&request, &capabilities),
17526 !cfg!(windows),
17527 );
17528 }
17529}
17530
17531
17532#[cfg(test)]
17533mod station_knobs_resolve_tests {
17534 use super::*;
17535 use gate4agent_catalog::EnvMutation;
17536 use gate4agent_runtime_native::{
17537 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
17538 NativeLaunchProfileId,
17539 };
17540 use std::ffi::OsString;
17541 use std::sync::Arc;
17542
17543 struct EmptyEnvironmentResolver;
17544
17545 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
17546 fn resolve_child_environment(
17547 &self,
17548 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
17549 Ok(vec![EnvMutation {
17550 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
17551 value: None,
17552 }])
17553 }
17554 }
17555
17556 fn agent(value: &str) -> AgentId {
17557 AgentId::new(value).unwrap()
17558 }
17559
17560 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
17561 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
17562 let profile_revision =
17563 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
17564 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
17565 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
17566 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
17567 provider: agent("claude"),
17568 mode: SessionMode::Pty,
17569 terminal_size: gate4agent_types::TerminalSize {
17570 rows: 24,
17571 columns: 80,
17572 },
17573 prompt: None,
17574 bundle_id: None,
17575 context_id: None,
17576 environment_profile_id: Some(profile_id.clone()),
17577 };
17578 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
17579 let catalog = active_registry().unwrap();
17580 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
17581 let control = runtime.native_launch_profile_control();
17582 let node_profile = NodeEnvironmentProfile::new(
17583 profile_id.clone(),
17584 profile_revision,
17585 agent("claude"),
17586 [NativeLaunchProfile::new(
17587 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
17588 agent("claude"),
17589 TransportKind::Pty,
17590 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
17591 Arc::new(EmptyEnvironmentResolver),
17592 )
17593 .unwrap()],
17594 )
17595 .unwrap();
17596 let (binding, native_profiles, _materialization) = node_profile.into_parts();
17597 for native_profile in native_profiles {
17598 runtime.upsert_native_launch_profile(native_profile).unwrap();
17599 }
17600 let workspace = WorkspaceConfig::new(
17601 WorkspaceId::new("primary").unwrap(),
17602 std::env::current_dir().unwrap(),
17603 )
17604 .unwrap();
17605 let shared = NodeShared::new_with_incarnation(
17606 handle,
17607 "fixture-token".to_owned(),
17608 NodeId::new("node-station-knobs-test").unwrap(),
17609 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
17610 vec![workspace],
17611 vec![agent("claude")],
17612 ProviderRuntimeStatuses::default(),
17613 None,
17614 Vec::new(),
17615 Vec::new(),
17616 spawn_profiles,
17617 Some(control),
17618 None,
17619 None,
17620 None,
17621 Vec::new(),
17622 Vec::new(),
17623 Vec::new(),
17624 Vec::new(),
17625 None,
17626 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
17627 );
17628 shared
17629 .environment_profiles
17630 .write()
17631 .unwrap_or_else(|poisoned| poisoned.into_inner())
17632 .insert(binding.id.clone(), binding);
17633 (shared, spawn_defaults)
17634 }
17635
17636 #[test]
17639 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
17640 let (shared, defaults) = station_knobs_fixture();
17641 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
17642 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17643 let spec = crate::protocol::SpawnSpec {
17644 target: crate::protocol::SpawnTarget {
17645 node_id: shared.node_id.clone(),
17646 workspace_id: WorkspaceId::new("primary").unwrap(),
17647 worktree_id: None,
17648 },
17649 profile_id: defaults.profile_id.clone(),
17650 expected_profile_revision: defaults.revision.clone(),
17651 overrides: crate::protocol::SpawnOverrides {
17652 network_allowlist: Some(unknown),
17653 browser_profile_id: Some(browser),
17654 ..crate::protocol::SpawnOverrides::default()
17655 },
17656 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17657 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17658 "station-knobs-unknown-allowlist",
17659 )
17660 .unwrap(),
17661 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17662 };
17663 let resolved = spec.resolve(&defaults).unwrap();
17664 let failure = shared
17665 .resolve_environment_profile(&resolved)
17666 .expect_err("unknown allowlist must refuse");
17667 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
17668 }
17669
17670 #[test]
17676 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
17677 let (shared, defaults) = station_knobs_fixture();
17678 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
17679 #[cfg(not(feature = "dig2-station-probe"))]
17680 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17681 shared
17682 .network_allowlist_catalog
17683 .write()
17684 .unwrap_or_else(|poisoned| poisoned.into_inner())
17685 .insert_id(allowlist.clone())
17686 .unwrap();
17687 let spec = crate::protocol::SpawnSpec {
17688 target: crate::protocol::SpawnTarget {
17689 node_id: shared.node_id.clone(),
17690 workspace_id: WorkspaceId::new("primary").unwrap(),
17691 worktree_id: None,
17692 },
17693 profile_id: defaults.profile_id.clone(),
17694 expected_profile_revision: defaults.revision.clone(),
17695 overrides: crate::protocol::SpawnOverrides {
17696 network_allowlist: Some(allowlist.clone()),
17697 #[cfg(not(feature = "dig2-station-probe"))]
17698 browser_profile_id: Some(browser.clone()),
17699 ..crate::protocol::SpawnOverrides::default()
17700 },
17701 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17702 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
17703 .unwrap(),
17704 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17705 };
17706 let resolved = spec.resolve(&defaults).unwrap();
17707 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
17708 #[cfg(not(feature = "dig2-station-probe"))]
17709 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
17710 let receipt = shared
17711 .resolve_environment_profile(&resolved)
17712 .expect("resolve ok")
17713 .expect("env profile present");
17714 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
17715 #[cfg(not(feature = "dig2-station-probe"))]
17716 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
17717 #[cfg(feature = "dig2-station-probe")]
17718 assert_eq!(receipt.browser_profile_id, None);
17719 assert_eq!(receipt.profile_id.as_str(), "local-claude");
17720
17721 let omitted = crate::protocol::SpawnSpec {
17722 overrides: crate::protocol::SpawnOverrides::default(),
17723 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
17724 .unwrap(),
17725 ..spec
17726 };
17727 let resolved_omit = omitted.resolve(&defaults).unwrap();
17728 let bare = shared
17729 .resolve_environment_profile(&resolved_omit)
17730 .expect("resolve ok")
17731 .expect("env profile present");
17732 assert_eq!(bare.network_allowlist, None);
17733 assert_eq!(bare.browser_profile_id, None);
17734 }
17735
17736 #[cfg(feature = "dig2-station-probe")]
17741 #[test]
17742 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
17743 let (shared, defaults) = station_knobs_fixture();
17744 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17745 let spec = crate::protocol::SpawnSpec {
17746 target: crate::protocol::SpawnTarget {
17747 node_id: shared.node_id.clone(),
17748 workspace_id: WorkspaceId::new("primary").unwrap(),
17749 worktree_id: None,
17750 },
17751 profile_id: defaults.profile_id.clone(),
17752 expected_profile_revision: defaults.revision.clone(),
17753 overrides: crate::protocol::SpawnOverrides {
17754 browser_profile_id: Some(browser),
17755 ..crate::protocol::SpawnOverrides::default()
17756 },
17757 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17758 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17759 "station-knobs-probe-refuse",
17760 )
17761 .unwrap(),
17762 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17763 };
17764 let resolved = spec.resolve(&defaults).unwrap();
17765 let failure = shared
17766 .resolve_environment_profile(&resolved)
17767 .expect_err("browser_profile with failing probe must refuse");
17768 #[cfg(windows)]
17769 {
17770 assert!(
17771 matches!(
17772 failure.code,
17773 NodeFailureCode::BrowserStationUnreachable
17774 | NodeFailureCode::BrowserStationProbeUnavailable
17775 ),
17776 "unexpected code {:?}",
17777 failure.code
17778 );
17779 }
17780 #[cfg(not(windows))]
17781 {
17782 assert_eq!(
17783 failure.code,
17784 NodeFailureCode::BrowserStationProbeUnavailable
17785 );
17786 }
17787 }
17788
17789 #[cfg(feature = "dig2-station-probe")]
17791 #[test]
17792 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
17793 let (shared, defaults) = station_knobs_fixture();
17794 let spec = crate::protocol::SpawnSpec {
17795 target: crate::protocol::SpawnTarget {
17796 node_id: shared.node_id.clone(),
17797 workspace_id: WorkspaceId::new("primary").unwrap(),
17798 worktree_id: None,
17799 },
17800 profile_id: defaults.profile_id.clone(),
17801 expected_profile_revision: defaults.revision.clone(),
17802 overrides: crate::protocol::SpawnOverrides::default(),
17803 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17804 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17805 "station-knobs-probe-omit",
17806 )
17807 .unwrap(),
17808 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17809 };
17810 let resolved = spec.resolve(&defaults).unwrap();
17811 let receipt = shared
17812 .resolve_environment_profile(&resolved)
17813 .expect("omit browser_profile must not probe")
17814 .expect("env profile present");
17815 assert_eq!(receipt.browser_profile_id, None);
17816 }
17817
17818 #[cfg(feature = "dig2-station-probe")]
17822 #[test]
17823 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
17824 let (shared, _defaults) = station_knobs_fixture();
17825 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17826 let workspace_id = WorkspaceId::new("primary").unwrap();
17827 let address_a = SessionAddress {
17828 workspace_id: workspace_id.clone(),
17829 session: SessionKey {
17830 instance_id: AgentInstanceId(101),
17831 generation: SessionGeneration(1),
17832 },
17833 };
17834 let address_b = SessionAddress {
17835 workspace_id: workspace_id.clone(),
17836 session: SessionKey {
17837 instance_id: AgentInstanceId(102),
17838 generation: SessionGeneration(1),
17839 },
17840 };
17841 let guard_a = crate::dig2_station_lease::acquire_lease(
17842 &shared.browser_station_leases,
17843 profile.clone(),
17844 address_a.session.instance_id,
17845 1,
17846 )
17847 .expect("first lease");
17848 let busy = crate::dig2_station_lease::acquire_lease(
17849 &shared.browser_station_leases,
17850 profile.clone(),
17851 address_b.session.instance_id,
17852 2,
17853 )
17854 .expect_err("second holder must refuse");
17855 assert!(matches!(
17856 busy,
17857 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
17858 ));
17859 shared.bind_session_with_policy(
17861 &address_a,
17862 ProviderRuntimePolicy::raw_pty(),
17863 None,
17864 );
17865 guard_a.retain();
17866 assert!(shared.remove_binding(&address_a).is_some());
17867 crate::dig2_station_lease::acquire_lease(
17868 &shared.browser_station_leases,
17869 profile,
17870 address_b.session.instance_id,
17871 3,
17872 )
17873 .expect("reacquire after clear")
17874 .retain();
17875 assert!(
17876 crate::dig2_station_lease::release_lease_for_holder(
17877 &shared.browser_station_leases,
17878 address_b.session.instance_id,
17879 )
17880 );
17881 }
17882
17883 #[cfg(not(feature = "dig2-station-probe"))]
17886 #[test]
17887 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
17888 let (shared, defaults) = station_knobs_fixture();
17889 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17890 let spec = crate::protocol::SpawnSpec {
17891 target: crate::protocol::SpawnTarget {
17892 node_id: shared.node_id.clone(),
17893 workspace_id: WorkspaceId::new("primary").unwrap(),
17894 worktree_id: None,
17895 },
17896 profile_id: defaults.profile_id.clone(),
17897 expected_profile_revision: defaults.revision.clone(),
17898 overrides: crate::protocol::SpawnOverrides {
17899 browser_profile_id: Some(browser.clone()),
17900 ..crate::protocol::SpawnOverrides::default()
17901 },
17902 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17903 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
17904 .unwrap(),
17905 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17906 };
17907 let resolved = spec.resolve(&defaults).unwrap();
17908 let receipt = shared
17909 .resolve_environment_profile(&resolved)
17910 .expect("stub resolve")
17911 .expect("env profile");
17912 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
17913 }
17914
17915
17916 #[test]
17919 fn network_allowlist_catalog_loads_from_temp_file() {
17920 let unique = std::time::SystemTime::now()
17921 .duration_since(std::time::UNIX_EPOCH)
17922 .unwrap()
17923 .as_nanos();
17924 let path = std::env::temp_dir().join(format!(
17925 "gate4agent-node-allowlist-config-{}-{unique}.txt",
17926 std::process::id(),
17927 ));
17928 std::fs::write(
17929 &path,
17930 "# opaque station allowlist ids\negress-default\nlab-egress\n",
17931 )
17932 .unwrap();
17933 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
17934 .expect("temp catalog file must load");
17935 let _ = std::fs::remove_file(&path);
17936 assert_eq!(loaded.len(), 2);
17937 let (shared, defaults) = station_knobs_fixture();
17938 {
17939 let mut catalog = shared
17940 .network_allowlist_catalog
17941 .write()
17942 .unwrap_or_else(|poisoned| poisoned.into_inner());
17943 *catalog = loaded;
17944 }
17945 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
17946 let spec = crate::protocol::SpawnSpec {
17947 target: crate::protocol::SpawnTarget {
17948 node_id: shared.node_id.clone(),
17949 workspace_id: WorkspaceId::new("primary").unwrap(),
17950 worktree_id: None,
17951 },
17952 profile_id: defaults.profile_id.clone(),
17953 expected_profile_revision: defaults.revision.clone(),
17954 overrides: crate::protocol::SpawnOverrides {
17955 network_allowlist: Some(allowlist.clone()),
17956 ..crate::protocol::SpawnOverrides::default()
17957 },
17958 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17959 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17960 "station-knobs-catalog-file",
17961 )
17962 .unwrap(),
17963 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17964 };
17965 let resolved = spec.resolve(&defaults).unwrap();
17966 let receipt = shared
17967 .resolve_environment_profile(&resolved)
17968 .expect("registered via file load")
17969 .expect("env profile present");
17970 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
17971 }
17972
17973 #[test]
17976 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
17977 let (shared, _defaults) = station_knobs_fixture();
17978 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
17979 shared
17980 .network_allowlist_catalog
17981 .write()
17982 .unwrap_or_else(|poisoned| poisoned.into_inner())
17983 .insert_id(allowlist.clone())
17984 .unwrap();
17985 let snapshot = shared.snapshot();
17986 let inventory = snapshot
17987 .launch_inventory
17988 .expect("launch inventory present");
17989 let ids = inventory
17990 .network_allowlists
17991 .expect("non-empty catalog surfaces ids");
17992 assert_eq!(ids, vec![allowlist]);
17993 }
17994
17995 #[test]
17999 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18000 let (shared, defaults) = station_knobs_fixture();
18001 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18002 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18003 id: allowlist.clone(),
18004 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18005 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18006 peer: "127.0.0.1:443".to_owned(),
18007 }],
18008 provider_native: Some(
18009 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18010 codex_network_access: Some(true),
18011 },
18012 ),
18013 };
18014 shared
18015 .network_allowlist_catalog
18016 .write()
18017 .unwrap_or_else(|poisoned| poisoned.into_inner())
18018 .insert(entry)
18019 .unwrap();
18020 let spec = crate::protocol::SpawnSpec {
18021 target: crate::protocol::SpawnTarget {
18022 node_id: shared.node_id.clone(),
18023 workspace_id: WorkspaceId::new("primary").unwrap(),
18024 worktree_id: None,
18025 },
18026 profile_id: defaults.profile_id.clone(),
18027 expected_profile_revision: defaults.revision.clone(),
18028 overrides: crate::protocol::SpawnOverrides {
18029 network_allowlist: Some(allowlist),
18030 ..crate::protocol::SpawnOverrides::default()
18031 },
18032 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18033 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18034 "station-knobs-unsupported-mapping",
18035 )
18036 .unwrap(),
18037 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18038 };
18039 let resolved = spec.resolve(&defaults).unwrap();
18040 assert_eq!(resolved.provider.as_str(), "claude");
18041 let failure = shared
18042 .resolve_environment_profile(&resolved)
18043 .expect_err("claude + codex_network_access must refuse");
18044 assert_eq!(
18045 failure.code,
18046 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18047 );
18048 }
18049
18050 #[test]
18054 fn resolve_codex_network_access_overlay_moderate_only() {
18055 let (shared, defaults) = station_knobs_fixture();
18056 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18057 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18058 id: allowlist.clone(),
18059 permits: Vec::new(),
18060 provider_native: Some(
18061 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18062 codex_network_access: Some(true),
18063 },
18064 ),
18065 };
18066 shared
18067 .network_allowlist_catalog
18068 .write()
18069 .unwrap_or_else(|poisoned| poisoned.into_inner())
18070 .insert(entry)
18071 .unwrap();
18072
18073 let overlay = shared
18074 .resolve_codex_network_access_overlay_args(
18075 &agent("codex"),
18076 SessionMode::Pty,
18077 ApprovalLevel::Moderate,
18078 Some(&allowlist),
18079 )
18080 .expect("Moderate Codex maps network_access");
18081 assert_eq!(
18082 overlay,
18083 vec![
18084 std::ffi::OsString::from("-c"),
18085 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18086 ]
18087 );
18088
18089 let acp = shared
18090 .resolve_codex_network_access_overlay_args(
18091 &agent("codex"),
18092 SessionMode::Acp,
18093 ApprovalLevel::Moderate,
18094 Some(&allowlist),
18095 )
18096 .unwrap();
18097 assert_eq!(acp, overlay);
18098
18099 let inline = shared
18100 .resolve_codex_network_access_overlay_args(
18101 &agent("codex"),
18102 SessionMode::Inline,
18103 ApprovalLevel::Moderate,
18104 Some(&allowlist),
18105 )
18106 .unwrap_err();
18107 assert_eq!(
18108 inline.code,
18109 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18110 );
18111
18112 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18113 let spec = crate::protocol::SpawnSpec {
18114 target: crate::protocol::SpawnTarget {
18115 node_id: shared.node_id.clone(),
18116 workspace_id: WorkspaceId::new("primary").unwrap(),
18117 worktree_id: None,
18118 },
18119 profile_id: defaults.profile_id.clone(),
18120 expected_profile_revision: defaults.revision.clone(),
18121 overrides: crate::protocol::SpawnOverrides {
18122 provider: crate::protocol::SpawnOverride::Set {
18123 value: agent("codex"),
18124 },
18125 approval_level: Some(level),
18126 network_allowlist: Some(allowlist.clone()),
18127 ..crate::protocol::SpawnOverrides::default()
18128 },
18129 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18130 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18131 "codex-net-refuse-{}",
18132 match level {
18133 ApprovalLevel::ReadOnly => "ro",
18134 ApprovalLevel::FullAuto => "fa",
18135 _ => "other",
18136 }
18137 ))
18138 .unwrap(),
18139 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18140 };
18141 let resolved = spec.resolve(&defaults).unwrap();
18142 let failure = shared
18143 .resolve_environment_profile(&resolved)
18144 .expect_err("RO/FullAuto must refuse");
18145 assert_eq!(
18146 failure.code,
18147 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18148 );
18149 }
18150 }
18151
18152 #[test]
18156 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18157 let (shared, _defaults) = station_knobs_fixture();
18158 let allowlist =
18159 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18160 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18161 id: allowlist.clone(),
18162 permits: vec![
18163 crate::network_allowlist_catalog::NetworkPermitSketch {
18164 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18165 peer: "github.com:443".to_owned(),
18166 },
18167 crate::network_allowlist_catalog::NetworkPermitSketch {
18168 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18169 peer: "*.npmjs.org".to_owned(),
18170 },
18171 ],
18172 provider_native: None,
18173 };
18174 shared
18175 .network_allowlist_catalog
18176 .write()
18177 .unwrap_or_else(|poisoned| poisoned.into_inner())
18178 .insert(entry)
18179 .unwrap();
18180
18181 let unique = std::time::SystemTime::now()
18182 .duration_since(std::time::UNIX_EPOCH)
18183 .unwrap()
18184 .as_nanos();
18185 let provider_home = std::env::temp_dir().join(format!(
18186 "gate4agent-claude-provider-home-{}-{unique}",
18187 std::process::id(),
18188 ));
18189 std::fs::create_dir_all(&provider_home).unwrap();
18190
18191 let overlay = shared
18192 .resolve_network_allowlist_overlay_args(
18193 &agent("claude"),
18194 SessionMode::Pty,
18195 ApprovalLevel::Moderate,
18196 Some(&allowlist),
18197 Some(provider_home.as_path()),
18198 )
18199 .expect("Claude PTY settings overlay on unix");
18200 let settings_path = provider_home.join(
18201 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18202 );
18203 assert_eq!(
18204 overlay,
18205 vec![
18206 std::ffi::OsString::from("--settings"),
18207 std::ffi::OsString::from(settings_path.as_os_str()),
18208 ]
18209 );
18210 let body = std::fs::read_to_string(&settings_path).unwrap();
18211 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18212 assert_eq!(value["sandbox"]["enabled"], true);
18213 assert_eq!(
18214 value["sandbox"]["network"]["allowedDomains"],
18215 serde_json::json!(["github.com", "*.npmjs.org"])
18216 );
18217 assert!(settings_path.starts_with(&provider_home));
18218
18219 let acp = shared
18220 .resolve_network_allowlist_overlay_args(
18221 &agent("claude"),
18222 SessionMode::Acp,
18223 ApprovalLevel::Moderate,
18224 Some(&allowlist),
18225 Some(provider_home.as_path()),
18226 )
18227 .expect_err("Claude ACP must refuse settings overlay");
18228 assert_eq!(
18229 acp.code,
18230 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18231 );
18232
18233 let no_home = shared
18234 .resolve_network_allowlist_overlay_args(
18235 &agent("claude"),
18236 SessionMode::Pty,
18237 ApprovalLevel::Moderate,
18238 Some(&allowlist),
18239 None,
18240 )
18241 .expect_err("Claude overlay requires ProviderHome");
18242 assert_eq!(
18243 no_home.code,
18244 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18245 );
18246
18247 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18248 shared
18249 .network_allowlist_catalog
18250 .write()
18251 .unwrap_or_else(|poisoned| poisoned.into_inner())
18252 .insert_id(bare.clone())
18253 .unwrap();
18254 let empty = shared
18255 .resolve_network_allowlist_overlay_args(
18256 &agent("claude"),
18257 SessionMode::Pty,
18258 ApprovalLevel::Moderate,
18259 Some(&bare),
18260 Some(provider_home.as_path()),
18261 )
18262 .unwrap();
18263 assert!(empty.is_empty());
18264
18265 let _ = std::fs::remove_dir_all(&provider_home);
18266 }
18267
18268 #[test]
18270 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18271 let (shared, _defaults) = station_knobs_fixture();
18272 let allowlist =
18273 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18274 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18275 id: allowlist.clone(),
18276 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18277 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18278 peer: "example.com:443".to_owned(),
18279 }],
18280 provider_native: None,
18281 };
18282 shared
18283 .network_allowlist_catalog
18284 .write()
18285 .unwrap_or_else(|poisoned| poisoned.into_inner())
18286 .insert(entry)
18287 .unwrap();
18288 for provider in ["kimi", "grok"] {
18289 let overlay = shared
18290 .resolve_network_allowlist_overlay_args(
18291 &agent(provider),
18292 SessionMode::Pty,
18293 ApprovalLevel::Moderate,
18294 Some(&allowlist),
18295 None,
18296 )
18297 .unwrap();
18298 assert!(
18299 overlay.is_empty(),
18300 "{provider} must not invent network argv from permits"
18301 );
18302 }
18303 }
18304
18305 #[test]
18307 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
18308 let unique = std::time::SystemTime::now()
18309 .duration_since(std::time::UNIX_EPOCH)
18310 .unwrap()
18311 .as_nanos();
18312 let path = std::env::temp_dir().join(format!(
18313 "gate4agent-node-allowlist-json-{}-{unique}.json",
18314 std::process::id(),
18315 ));
18316 std::fs::write(
18317 &path,
18318 r#"{
18319 "schema_version": 2,
18320 "entries": [
18321 {
18322 "id": "egress-default",
18323 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
18324 },
18325 { "id": "lab-egress" }
18326 ]
18327 }"#,
18328 )
18329 .unwrap();
18330 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18331 .expect("json v2 catalog must load");
18332 let _ = std::fs::remove_file(&path);
18333 assert_eq!(loaded.len(), 2);
18334 let entry = loaded
18335 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
18336 .unwrap();
18337 assert_eq!(entry.permits.len(), 1);
18338 let (shared, defaults) = station_knobs_fixture();
18339 {
18340 let mut catalog = shared
18341 .network_allowlist_catalog
18342 .write()
18343 .unwrap_or_else(|poisoned| poisoned.into_inner());
18344 *catalog = loaded;
18345 }
18346 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18347 let spec = crate::protocol::SpawnSpec {
18348 target: crate::protocol::SpawnTarget {
18349 node_id: shared.node_id.clone(),
18350 workspace_id: WorkspaceId::new("primary").unwrap(),
18351 worktree_id: None,
18352 },
18353 profile_id: defaults.profile_id.clone(),
18354 expected_profile_revision: defaults.revision.clone(),
18355 overrides: crate::protocol::SpawnOverrides {
18356 network_allowlist: Some(allowlist.clone()),
18357 ..crate::protocol::SpawnOverrides::default()
18358 },
18359 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18360 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18361 "station-knobs-catalog-json",
18362 )
18363 .unwrap(),
18364 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18365 };
18366 let resolved = spec.resolve(&defaults).unwrap();
18367 let receipt = shared
18368 .resolve_environment_profile(&resolved)
18369 .expect("permits-only entry ok for claude")
18370 .expect("env profile present");
18371 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18372 let inventory = shared
18373 .snapshot()
18374 .launch_inventory
18375 .expect("launch inventory present");
18376 let ids = inventory.network_allowlists.expect("ids surface");
18377 assert_eq!(
18378 ids,
18379 vec![
18380 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
18381 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
18382 ]
18383 );
18384 }
18385
18386
18387}
18388
18389
18390#[cfg(test)]
18391mod workspace_environment_collision_tests {
18392 use super::*;
18393 use crate::session_environment::{
18394 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
18395 };
18396 use gate4agent_catalog::EnvMutation;
18397 use gate4agent_runtime_native::{
18398 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18399 NativeLaunchProfileId,
18400 };
18401 use std::ffi::OsString;
18402 use std::sync::Arc;
18403
18404 struct EmptyEnvironmentResolver;
18405
18406 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18407 fn resolve_child_environment(
18408 &self,
18409 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18410 Ok(vec![EnvMutation {
18411 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18412 value: None,
18413 }])
18414 }
18415 }
18416
18417 fn agent(value: &str) -> AgentId {
18418 AgentId::new(value).unwrap()
18419 }
18420
18421 fn claude_config_dir_collision_fixture() -> (
18423 NodeShared,
18424 ResolvedEnvironmentProfileReceipt,
18425 ResolvedEnvironmentProfileReceipt,
18426 ) {
18427 let catalog = active_registry().unwrap();
18428 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18429 let control = runtime.native_launch_profile_control();
18430 let workspace = WorkspaceConfig::new(
18431 WorkspaceId::new("primary").unwrap(),
18432 std::env::current_dir().unwrap(),
18433 )
18434 .unwrap();
18435 let shared = NodeShared::new_with_incarnation(
18436 handle,
18437 "fixture-token".to_owned(),
18438 NodeId::new("node-env-collision-test").unwrap(),
18439 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18440 vec![workspace],
18441 vec![agent("claude")],
18442 ProviderRuntimeStatuses::default(),
18443 None,
18444 Vec::new(),
18445 Vec::new(),
18446 SpawnProfileRegistry::default(),
18447 Some(control),
18448 None,
18449 None,
18450 None,
18451 Vec::new(),
18452 Vec::new(),
18453 Vec::new(),
18454 Vec::new(),
18455 None,
18456 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18457 );
18458
18459 let mut receipts = Vec::new();
18460 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
18461 let profile_id =
18462 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
18463 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
18464 "claude-config-dir-{suffix}-r1"
18465 ))
18466 .unwrap();
18467 let materialization = NodeSessionMaterializationProfile::new(
18468 Vec::new(),
18469 vec![NodeSessionPathBinding::new(
18470 "CLAUDE_CONFIG_DIR",
18471 NodeSessionPathClass::ProviderHome,
18472 )
18473 .unwrap()],
18474 Vec::new(),
18475 )
18476 .unwrap();
18477 let node_profile = NodeEnvironmentProfile::new_with_materialization(
18478 profile_id.clone(),
18479 profile_revision.clone(),
18480 agent("claude"),
18481 [NativeLaunchProfile::new(
18482 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
18483 .unwrap(),
18484 agent("claude"),
18485 TransportKind::Pty,
18486 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18487 Arc::new(EmptyEnvironmentResolver),
18488 )
18489 .unwrap()],
18490 Some(materialization),
18491 )
18492 .unwrap();
18493 let (binding, native_profiles, materialization) = node_profile.into_parts();
18494 for native_profile in native_profiles {
18495 runtime.upsert_native_launch_profile(native_profile).unwrap();
18496 }
18497 shared
18498 .environment_profiles
18499 .write()
18500 .unwrap_or_else(|poisoned| poisoned.into_inner())
18501 .insert(binding.id.clone(), binding);
18502 shared
18503 .environment_materialization_profiles
18504 .write()
18505 .unwrap_or_else(|poisoned| poisoned.into_inner())
18506 .insert(profile_id.clone(), materialization.unwrap());
18507 receipts.push(ResolvedEnvironmentProfileReceipt {
18508 profile_id,
18509 profile_revision,
18510 network_allowlist: None,
18511 browser_profile_id: None,
18512 });
18513 }
18514 let profile_b = receipts.pop().unwrap();
18515 let profile_a = receipts.pop().unwrap();
18516 (shared, profile_a, profile_b)
18517 }
18518
18519 #[test]
18520 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
18521 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18522 let primary = WorkspaceId::new("primary").unwrap();
18523 let worktree = WorkspaceId::new("worktree-b").unwrap();
18524 let address = SessionAddress {
18525 workspace_id: primary.clone(),
18526 session: SessionKey {
18527 instance_id: AgentInstanceId(41),
18528 generation: SessionGeneration(1),
18529 },
18530 };
18531 shared.bind_session_with_policy(
18532 &address,
18533 ProviderRuntimePolicy::raw_pty(),
18534 Some(profile_a.clone()),
18535 );
18536
18537 let refused = shared
18538 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
18539 .unwrap_err();
18540 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
18541
18542 let refused_none = shared
18544 .ensure_workspace_environment_profile_compatible(&primary, None)
18545 .unwrap_err();
18546 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
18547
18548 shared
18550 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
18551 .unwrap();
18552
18553 shared
18555 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
18556 .unwrap();
18557 }
18558
18559 #[tokio::test]
18560 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
18561 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18562 let primary = WorkspaceId::new("primary").unwrap();
18563 let address = SessionAddress {
18564 workspace_id: primary.clone(),
18565 session: SessionKey {
18566 instance_id: AgentInstanceId(41),
18567 generation: SessionGeneration(1),
18568 },
18569 };
18570 shared.bind_session_with_policy(
18571 &address,
18572 ProviderRuntimePolicy::raw_pty(),
18573 Some(profile_a),
18574 );
18575
18576 let failure = shared
18577 .spawn_session_with_deadline(
18578 primary,
18579 agent("claude"),
18580 SessionMode::Pty,
18581 gate4agent_types::TerminalSize {
18582 rows: 24,
18583 columns: 80,
18584 },
18585 None,
18586 Some(profile_b),
18587 None,
18588 None,
18589 None,
18590 None,
18591 SpawnRecordPolicy::ProviderIdentityOnly,
18592 Some(Instant::now() + Duration::from_secs(30)),
18593 &[],
18594 None,
18595 ApprovalLevel::default(),
18596 None,
18597 None,
18598 )
18599 .await
18600 .unwrap_err();
18601 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
18602 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
18604 assert!(shared.handle.snapshot().sessions.is_empty());
18605 }
18606}
18607
18608#[cfg(all(test, windows))]
18609mod tests {
18610 use super::*;
18611 use gate4agent_types::ProviderActivity;
18612 use crate::session_environment::{
18613 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
18614 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
18615 NodeSessionPathClass,
18616 };
18617 use gate4agent_catalog::EnvMutation;
18618 use gate4agent_runtime_native::{
18619 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
18620 NativeLaunchProfile, NativeLaunchProfileId,
18621 };
18622 use std::ffi::OsString;
18623 use std::sync::Arc;
18624
18625 struct EmptyEnvironmentResolver;
18626
18627 struct FixtureSecretResolver {
18628 deny: Arc<AtomicBool>,
18629 }
18630
18631 impl NodeSecretResolver for FixtureSecretResolver {
18632 fn resolve(
18633 &self,
18634 _reference: &NodeSecretReference,
18635 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
18636 if self.deny.load(Ordering::Acquire) {
18637 Err(NodeSecretResolveError::Denied)
18638 } else {
18639 NodeSecretValue::text("fixture-secret")
18640 .map_err(|_| NodeSecretResolveError::Unavailable)
18641 }
18642 }
18643 }
18644
18645 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18646 fn resolve_child_environment(
18647 &self,
18648 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18649 Ok(vec![EnvMutation {
18650 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18651 value: None,
18652 }])
18653 }
18654 }
18655
18656 fn agent(value: &str) -> AgentId {
18657 AgentId::new(value).unwrap()
18658 }
18659
18660 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
18661 let catalog = active_registry().unwrap();
18662 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18663 let workspace = WorkspaceConfig::new(
18664 WorkspaceId::new("primary").unwrap(),
18665 std::env::current_dir().unwrap(),
18666 )
18667 .unwrap()
18668 .with_worktree_service_mode(mode);
18669 NodeShared::new(
18670 handle,
18671 "fixture-token".to_owned(),
18672 NodeId::new("node-terminal-test").unwrap(),
18673 vec![workspace],
18674 vec![agent("claude")],
18675 )
18676 }
18677
18678 fn terminal_test_shared() -> NodeShared {
18679 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
18680 }
18681
18682 #[cfg(feature = "fixture")]
18683 #[test]
18684 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
18685 let probe = SpawnManagedWorktreeV2FailureProbe::default();
18686 assert_eq!(probe.latest_code(), None);
18687
18688 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
18689 assert_eq!(
18690 probe.latest_code(),
18691 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
18692 );
18693
18694 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
18695 assert_eq!(
18696 probe.latest_code(),
18697 Some(NodeFailureCode::SpawnDeadlineExceeded),
18698 );
18699
18700 probe.record(None);
18701 assert_eq!(probe.latest_code(), None);
18702 }
18703
18704 #[test]
18705 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
18706 let source = temporary_workspace_root("launch-inventory-source");
18707 let allocation = temporary_workspace_root("launch-inventory-allocation");
18708 std::fs::create_dir_all(&source).unwrap();
18709 std::fs::create_dir_all(&allocation).unwrap();
18710 let workspace_id = WorkspaceId::new("primary").unwrap();
18711 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
18712 .unwrap()
18713 .with_worktree_service_mode(WorktreeServiceMode::Managed);
18714 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
18715 workspace = workspace
18716 .with_managed_worktree_profile(
18717 ManagedWorktreeProfile::new(
18718 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
18719 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
18720 &allocation,
18721 "gate4agent",
18722 "HEAD",
18723 ManagedWorktreeRetention::RemoveWhenReleased,
18724 )
18725 .unwrap(),
18726 )
18727 .unwrap();
18728 }
18729 let overflow = workspace.clone().with_managed_worktree_profile(
18730 ManagedWorktreeProfile::new(
18731 WorktreeProfileId::new("overflow").unwrap(),
18732 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
18733 &allocation,
18734 "gate4agent",
18735 "HEAD",
18736 ManagedWorktreeRetention::Retain,
18737 )
18738 .unwrap(),
18739 );
18740 assert!(matches!(
18741 overflow,
18742 Err(NodeServerError::ManagedWorktreeProfileCapacity {
18743 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
18744 ..
18745 }),
18746 ));
18747
18748 let catalog = active_registry().unwrap();
18749 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18750 let shared = NodeShared::new(
18751 handle,
18752 "fixture-token".to_owned(),
18753 NodeId::new("node-launch-inventory").unwrap(),
18754 vec![workspace],
18755 vec![agent("claude")],
18756 );
18757 let snapshot = shared.snapshot();
18758 let launch = snapshot.launch_inventory.as_ref().unwrap();
18759 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
18760 assert!(launch.bundles.as_ref().unwrap().is_empty());
18761 let profiles = snapshot.workspaces[0]
18762 .managed_worktree_profiles
18763 .as_ref()
18764 .unwrap();
18765 assert_eq!(
18766 snapshot.workspaces[0].worktree_service_mode,
18767 Some(WorktreeServiceMode::Managed),
18768 );
18769 assert_eq!(
18770 profiles.profiles.len(),
18771 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
18772 );
18773 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
18774 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
18775 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
18776 }
18777 let legacy = snapshot_for_wire(
18778 &shared,
18779 true,
18780 true,
18781 false,
18782 false,
18783 true,
18784 false,
18785 true,
18786 false,
18787 true,
18788 );
18789 assert!(legacy.launch_inventory.is_none());
18790 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
18791
18792 drop(shared);
18793 std::fs::remove_dir_all(source).unwrap();
18794 std::fs::remove_dir_all(allocation).unwrap();
18795 }
18796
18797 #[test]
18798 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
18799 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
18800 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
18801 }));
18802 let source = gate4agent_types::ProviderSource {
18803 family: AdapterFamily::PtySemantic,
18804 binding: AdapterBinding::new(
18805 gate4agent_types::AdapterId::new("claude").unwrap(),
18806 "fixture/v1",
18807 gate4agent_types::AdapterVerification::SyntheticFixture,
18808 )
18809 .unwrap(),
18810 };
18811 let provider = ProviderSnapshot {
18812 sequence: 17,
18813 session: Some(ProviderSessionIdentity {
18814 key: ProviderSessionKey::SessionId,
18815 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
18816 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
18817 }),
18818 model: Some("PRIVATE_MODEL".to_owned()),
18819 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
18820 completed_turns: 3,
18821 usage: gate4agent_types::TokenUsage {
18822 input_tokens: 10,
18823 output_tokens: 20,
18824 cache_read_tokens: 30,
18825 cache_write_tokens: 40,
18826 reasoning_tokens: 50,
18827 context_window: Some(200_000),
18828 },
18829 lead_activity: ProviderActivity::WaitingForInput,
18830 activity: ProviderActivity::WaitingForInput,
18831 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
18832 active_tools: (0..9)
18833 .map(|index| gate4agent_types::ActiveProviderTool {
18834 id: format!("private-tool-id-{index}"),
18835 name: if index == 0 {
18836 format!(" Read\u{0000}{}", "x".repeat(80))
18837 } else {
18838 format!("Tool-{index}")
18839 },
18840 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
18841 })
18842 .collect(),
18843 interactions: vec![gate4agent_types::ProviderInteraction {
18844 id: gate4agent_types::ProviderInteractionId(17),
18845 source: source.clone(),
18846 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
18847 interaction_kind: ProviderInteractionKind::Approval,
18848 tool_name: " Approve\u{0007}Tool ".to_owned(),
18849 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
18850 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
18851 resume_lead_activity: Some(ProviderActivity::Working),
18852 status: ProviderInteractionStatus::Pending,
18853 }],
18854 subagents: vec![gate4agent_types::ProviderSubagent {
18855 source: source.clone(),
18856 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
18857 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
18858 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
18859 }],
18860 sources: vec![gate4agent_types::ProviderSourceCursor {
18861 source,
18862 sequence: 17,
18863 gap_count: 2,
18864 stale: true,
18865 }],
18866 last_event: Some(ProviderEvent::Error {
18867 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
18868 }),
18869 gap_count: 2,
18870 stale: true,
18871 };
18872 let address = terminal_address(3);
18873 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
18874 assert_eq!(entry.address, address);
18875 assert_eq!(entry.progress.provider_sequence, 17);
18876 assert_eq!(entry.progress.active_tool_count, 9);
18877 assert_eq!(
18878 entry.progress.active_tool_labels.len(),
18879 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
18880 );
18881 assert_eq!(entry.progress.subagent_count, 1);
18882 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
18883 assert!(entry.progress.truncated);
18884 let encoded = serde_json::to_string(&entry).unwrap();
18885 for forbidden in [
18886 "PRIVATE_PROVIDER_SESSION",
18887 "PRIVATE_PROVIDER_PATH",
18888 "PRIVATE_MODEL",
18889 "PRIVATE_TOOL_INVENTORY",
18890 "PRIVATE_CURRENT_PROMPT",
18891 "PRIVATE_TOOL_INPUT_JSON",
18892 "PRIVATE_PROVIDER_REQUEST",
18893 "PRIVATE_INTERACTION_PROMPT",
18894 "PRIVATE_INTERACTION_AGENT",
18895 "PRIVATE_SUBAGENT_ID",
18896 "PRIVATE_SUBAGENT_TYPE",
18897 "PRIVATE_SUBAGENT_DESCRIPTION",
18898 "PRIVATE_PROVIDER_ERROR",
18899 "input_json",
18900 "current_prompt",
18901 "session_id",
18902 ] {
18903 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
18904 }
18905
18906 let snapshot = NodeSnapshot {
18907 node_id: NodeId::new("node-progress-test").unwrap(),
18908 enabled_providers: vec![agent("claude")],
18909 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
18910 workspaces: Vec::new(),
18911 session_records: Vec::new(),
18912 managed_worktrees: Vec::new(),
18913 launch_inventory: None,
18914 agent_progress: vec![entry],
18915 };
18916 let mut legacy_reply = ResponseEnvelope {
18917 request_id: 1,
18918 result: Ok(NodeResponse::Snapshot {
18919 event_sequence: 0,
18920 controller: None,
18921 snapshot: snapshot.clone(),
18922 }),
18923 };
18924 project_response_without_agent_progress(&mut legacy_reply);
18925 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
18926 panic!("agent progress projection changed response kind");
18927 };
18928 assert!(legacy.agent_progress.is_empty());
18929 assert_eq!(snapshot.agent_progress.len(), 1);
18930 }
18931
18932 fn terminal_address(generation: u64) -> SessionAddress {
18933 SessionAddress {
18934 workspace_id: WorkspaceId::new("primary").unwrap(),
18935 session: SessionKey {
18936 instance_id: AgentInstanceId(41),
18937 generation: SessionGeneration(generation),
18938 },
18939 }
18940 }
18941
18942 fn spawn_spec_fixture() -> SpawnSpec {
18943 SpawnSpec {
18944 target: crate::protocol::SpawnTarget {
18945 node_id: NodeId::new("node-terminal-test").unwrap(),
18946 workspace_id: WorkspaceId::new("primary").unwrap(),
18947 worktree_id: None,
18948 },
18949 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18950 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
18951 .unwrap(),
18952 overrides: crate::protocol::SpawnOverrides::default(),
18953 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18954 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
18955 required_capabilities: SpawnRequiredCapabilities::default(),
18956 }
18957 }
18958
18959 fn environment_profile_test_shared(
18960 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
18961 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18962 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
18963 "local-claude-r1",
18964 )
18965 .unwrap();
18966 let spawn_profiles = SpawnProfileRegistry::new([
18967 crate::protocol::SpawnProfileDefaults {
18968 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18969 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18970 provider: agent("claude"),
18971 mode: SessionMode::Pty,
18972 terminal_size: gate4agent_types::TerminalSize {
18973 rows: 24,
18974 columns: 80,
18975 },
18976 prompt: None,
18977 bundle_id: None,
18978 context_id: None,
18979 environment_profile_id: Some(profile_id.clone()),
18980 },
18981 ])
18982 .unwrap();
18983 let catalog = active_registry().unwrap();
18984 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18985 let control = runtime.native_launch_profile_control();
18986 let node_profile = NodeEnvironmentProfile::new(
18987 profile_id.clone(),
18988 profile_revision.clone(),
18989 agent("claude"),
18990 [NativeLaunchProfile::new(
18991 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18992 agent("claude"),
18993 TransportKind::Pty,
18994 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18995 Arc::new(EmptyEnvironmentResolver),
18996 )
18997 .unwrap()],
18998 )
18999 .unwrap();
19000 let (binding, native_profiles, _materialization) = node_profile.into_parts();
19001 for native_profile in native_profiles {
19002 runtime.upsert_native_launch_profile(native_profile).unwrap();
19003 }
19004 let workspace = WorkspaceConfig::new(
19005 WorkspaceId::new("primary").unwrap(),
19006 std::env::current_dir().unwrap(),
19007 )
19008 .unwrap();
19009 let shared = NodeShared::new_with_incarnation(
19010 handle,
19011 "fixture-token".to_owned(),
19012 NodeId::new("node-terminal-test").unwrap(),
19013 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19014 vec![workspace],
19015 vec![agent("claude")],
19016 ProviderRuntimeStatuses::default(),
19017 None,
19018 Vec::new(),
19019 Vec::new(),
19020 spawn_profiles,
19021 Some(control),
19022 None,
19023 None,
19024 None,
19025 Vec::new(),
19026 Vec::new(),
19027 Vec::new(),
19028 Vec::new(),
19029 None,
19030 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19031 );
19032 shared
19033 .environment_profiles
19034 .write()
19035 .unwrap_or_else(|poisoned| poisoned.into_inner())
19036 .insert(binding.id.clone(), binding);
19037 (
19038 shared,
19039 runtime,
19040 ResolvedEnvironmentProfileReceipt {
19041 profile_id,
19042 profile_revision,
19043 network_allowlist: None,
19044 browser_profile_id: None,
19045 },
19046 )
19047 }
19048
19049 fn materialization_test_shared(
19050 deny_secret: bool,
19051 ) -> (
19052 NodeShared,
19053 NativeRuntime,
19054 ResolvedEnvironmentProfileReceipt,
19055 PathBuf,
19056 Arc<AtomicBool>,
19057 ) {
19058 let workspace = WorkspaceConfig::new(
19059 WorkspaceId::new("primary").unwrap(),
19060 std::env::current_dir().unwrap(),
19061 )
19062 .unwrap();
19063 materialization_test_shared_with_workspace(deny_secret, workspace)
19064 }
19065
19066 fn materialization_test_shared_with_workspace(
19067 deny_secret: bool,
19068 workspace: WorkspaceConfig,
19069 ) -> (
19070 NodeShared,
19071 NativeRuntime,
19072 ResolvedEnvironmentProfileReceipt,
19073 PathBuf,
19074 Arc<AtomicBool>,
19075 ) {
19076 let root = temporary_workspace_root(if deny_secret {
19077 "materialization-denied"
19078 } else {
19079 "materialization-ready"
19080 });
19081 std::fs::create_dir_all(&root).unwrap();
19082 let materialization_root = root.join("session-environments");
19083 let deny = Arc::new(AtomicBool::new(deny_secret));
19084 let resolver = Arc::new(FixtureSecretResolver {
19085 deny: Arc::clone(&deny),
19086 });
19087 let materializer = SessionEnvironmentMaterializer::new(
19088 materialization_root,
19089 resolver,
19090 )
19091 .unwrap();
19092 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19093 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19094 "materialized-claude-r1",
19095 )
19096 .unwrap();
19097 let materialization = NodeSessionMaterializationProfile::new(
19098 vec![NodeSessionEnvironmentMutation::SetSecret {
19099 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19100 reference: NodeSecretReference::new("fixture-token").unwrap(),
19101 }],
19102 vec![NodeSessionPathBinding::new(
19103 "GATE4AGENT_PROVIDER_HOME",
19104 NodeSessionPathClass::ProviderHome,
19105 )
19106 .unwrap()],
19107 vec![NodeSessionFile::secret(
19108 NodeSessionPathClass::Config,
19109 "auth/token",
19110 NodeSecretReference::new("fixture-token").unwrap(),
19111 )
19112 .unwrap()],
19113 )
19114 .unwrap();
19115 let catalog = active_registry().unwrap();
19116 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19117 let control = runtime.native_launch_profile_control();
19118 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19119 profile_id.clone(),
19120 profile_revision.clone(),
19121 agent("claude"),
19122 [NativeLaunchProfile::new(
19123 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19124 agent("claude"),
19125 TransportKind::Pty,
19126 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19127 Arc::new(EmptyEnvironmentResolver),
19128 )
19129 .unwrap()],
19130 Some(materialization),
19131 )
19132 .unwrap();
19133 let (binding, native_profiles, materialization) = node_profile.into_parts();
19134 for native_profile in native_profiles {
19135 runtime.upsert_native_launch_profile(native_profile).unwrap();
19136 }
19137 let shared = NodeShared::new_with_incarnation(
19138 handle,
19139 "fixture-token".to_owned(),
19140 NodeId::new("node-materialization-test").unwrap(),
19141 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19142 vec![workspace],
19143 vec![agent("claude")],
19144 ProviderRuntimeStatuses::default(),
19145 None,
19146 Vec::new(),
19147 Vec::new(),
19148 SpawnProfileRegistry::default(),
19149 Some(control),
19150 None,
19151 Some(materializer),
19152 None,
19153 Vec::new(),
19154 Vec::new(),
19155 Vec::new(),
19156 Vec::new(),
19157 None,
19158 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19159 );
19160 shared
19161 .environment_profiles
19162 .write()
19163 .unwrap_or_else(|poisoned| poisoned.into_inner())
19164 .insert(binding.id.clone(), binding);
19165 shared
19166 .environment_materialization_profiles
19167 .write()
19168 .unwrap_or_else(|poisoned| poisoned.into_inner())
19169 .insert(profile_id.clone(), materialization.unwrap());
19170 (
19171 shared,
19172 runtime,
19173 ResolvedEnvironmentProfileReceipt {
19174 profile_id,
19175 profile_revision,
19176 network_allowlist: None,
19177 browser_profile_id: None,
19178 },
19179 root,
19180 deny,
19181 )
19182 }
19183
19184 #[cfg(feature = "fixture")]
19185 #[derive(Clone, Copy)]
19186 enum CodexHomeProfile {
19187 Missing,
19188 WrongClass,
19189 Exact,
19190 }
19191
19192 #[cfg(feature = "fixture")]
19193 fn codex_bundle_test_shared(
19194 home_profile: CodexHomeProfile,
19195 ) -> (
19196 NodeShared,
19197 NativeRuntime,
19198 ResolvedEnvironmentProfileReceipt,
19199 NodeBundle,
19200 PathBuf,
19201 ) {
19202 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
19203 const CLAUDE_MANIFEST: &[u8] =
19204 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
19205 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
19206 const DIGEST: &str =
19207 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
19208
19209 let (shared, mut runtime, _claude_receipt, root, _deny) =
19210 materialization_test_shared(false);
19211 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
19212 CodexHomeProfile::Missing => "codex-home-missing",
19213 CodexHomeProfile::WrongClass => "codex-home-wrong",
19214 CodexHomeProfile::Exact => "codex-home-exact",
19215 })
19216 .unwrap();
19217 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19218 "codex-home-r1",
19219 )
19220 .unwrap();
19221 let materialization = match home_profile {
19222 CodexHomeProfile::Missing => None,
19223 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
19224 Some(NodeSessionMaterializationProfile::new(
19225 Vec::new(),
19226 vec![NodeSessionPathBinding::new(
19227 "CODEX_HOME",
19228 if matches!(home_profile, CodexHomeProfile::Exact) {
19229 NodeSessionPathClass::ProviderHome
19230 } else {
19231 NodeSessionPathClass::Config
19232 },
19233 )
19234 .unwrap()],
19235 Vec::new(),
19236 )
19237 .unwrap())
19238 }
19239 };
19240 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19241 profile_id.clone(),
19242 profile_revision.clone(),
19243 agent("codex"),
19244 [NativeLaunchProfile::new(
19245 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
19246 agent("codex"),
19247 TransportKind::Pty,
19248 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19249 Arc::new(EmptyEnvironmentResolver),
19250 )
19251 .unwrap()],
19252 materialization,
19253 )
19254 .unwrap();
19255 let (binding, native_profiles, materialization) = node_profile.into_parts();
19256 for native_profile in native_profiles {
19257 runtime.upsert_native_launch_profile(native_profile).unwrap();
19258 }
19259 shared
19260 .environment_profiles
19261 .write()
19262 .unwrap_or_else(|poisoned| poisoned.into_inner())
19263 .insert(binding.id.clone(), binding);
19264 if let Some(materialization) = materialization {
19265 shared
19266 .environment_materialization_profiles
19267 .write()
19268 .unwrap_or_else(|poisoned| poisoned.into_inner())
19269 .insert(profile_id.clone(), materialization);
19270 }
19271
19272 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
19273 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
19274 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
19275 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
19276 std::fs::write(
19277 bundle_source.join(".claude-plugin/plugin.json"),
19278 CLAUDE_MANIFEST,
19279 )
19280 .unwrap();
19281 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
19282 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
19283 let bundle = NodeBundle::new(
19284 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
19285 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
19286 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
19287 &bundle_source,
19288 )
19289 .unwrap();
19290 *shared
19291 .bundle_catalog
19292 .write()
19293 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
19294 BundleCatalog::new([bundle.clone()]).unwrap();
19295 (
19296 shared,
19297 runtime,
19298 ResolvedEnvironmentProfileReceipt {
19299 profile_id,
19300 profile_revision,
19301 network_allowlist: None,
19302 browser_profile_id: None,
19303 },
19304 bundle,
19305 root,
19306 )
19307 }
19308
19309 #[cfg(feature = "fixture")]
19310 #[test]
19311 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
19312 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
19313 let (shared, runtime, receipt, bundle, root) =
19314 codex_bundle_test_shared(home_profile);
19315 let error = shared
19316 .bundle_layout(
19317 &agent("codex"),
19318 SessionMode::Pty,
19319 Some(&receipt),
19320 &bundle,
19321 )
19322 .unwrap_err();
19323 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
19324 assert!(shared
19325 .materializations
19326 .lock()
19327 .unwrap_or_else(|poisoned| poisoned.into_inner())
19328 .is_empty());
19329 let materialization_root = root.join("session-environments");
19330 assert!(!std::fs::read_dir(&materialization_root)
19331 .unwrap()
19332 .filter_map(Result::ok)
19333 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
19334 drop(shared);
19335 drop(runtime);
19336 let _ = std::fs::remove_dir_all(root);
19337 }
19338 }
19339
19340 #[cfg(feature = "fixture")]
19341 #[test]
19342 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
19343 let (shared, runtime, receipt, bundle, root) =
19344 codex_bundle_test_shared(CodexHomeProfile::Exact);
19345 let address = SessionAddress {
19346 workspace_id: WorkspaceId::new("primary").unwrap(),
19347 session: SessionKey {
19348 instance_id: AgentInstanceId(310),
19349 generation: SessionGeneration(1),
19350 },
19351 };
19352 let (overlay, guard) = shared
19353 .prepare_session_materialization(
19354 &address,
19355 &agent("codex"),
19356 SessionMode::Pty,
19357 Some(&receipt),
19358 Some(&bundle.receipt()),
19359 None,
19360 None,
19361 )
19362 .unwrap()
19363 .unwrap();
19364 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
19365 let ownership = shared
19366 .materializations
19367 .lock()
19368 .unwrap_or_else(|poisoned| poisoned.into_inner())
19369 .get(guard.id().unwrap())
19370 .unwrap()
19371 .clone();
19372 let (profile, _, _) = shared
19373 .materialization_profile(
19374 &agent("codex"),
19375 SessionMode::Pty,
19376 Some(&receipt),
19377 Some(&bundle.receipt()),
19378 None,
19379 )
19380 .unwrap()
19381 .unwrap();
19382 let environment = shared
19383 .session_environment_materializer
19384 .as_ref()
19385 .unwrap()
19386 .resolve_environment(&ownership, &profile)
19387 .unwrap();
19388 assert!(environment.iter().any(|mutation| {
19389 mutation.key == OsString::from("CODEX_HOME")
19390 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
19391 }));
19392 assert_eq!(
19393 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
19394 .unwrap(),
19395 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
19396 );
19397 assert!(std::fs::read_dir(ownership.bundle_root())
19398 .unwrap()
19399 .next()
19400 .is_none());
19401 drop(guard);
19402 drop(shared);
19403 drop(runtime);
19404 let _ = std::fs::remove_dir_all(root);
19405 }
19406
19407 #[cfg(feature = "fixture")]
19408 #[test]
19409 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
19410 let (shared, runtime, receipt, bundle, root) =
19411 codex_bundle_test_shared(CodexHomeProfile::Exact);
19412 let bundle_receipt = bundle.receipt();
19413 let address = SessionAddress {
19414 workspace_id: WorkspaceId::new("primary").unwrap(),
19415 session: SessionKey {
19416 instance_id: AgentInstanceId(311),
19417 generation: SessionGeneration(1),
19418 },
19419 };
19420 let (_overlay, guard) = shared
19421 .prepare_session_materialization(
19422 &address,
19423 &agent("codex"),
19424 SessionMode::Pty,
19425 Some(&receipt),
19426 Some(&bundle_receipt),
19427 None,
19428 None,
19429 )
19430 .unwrap()
19431 .unwrap();
19432 let materialization_id = guard.id().unwrap().clone();
19433 let skill_path = shared
19434 .materializations
19435 .lock()
19436 .unwrap_or_else(|poisoned| poisoned.into_inner())
19437 .get(&materialization_id)
19438 .unwrap()
19439 .provider_home()
19440 .join("skills/review-code/SKILL.md");
19441 let record_id = shared
19442 .bind_spawn_session_with_materialization(
19443 &address,
19444 agent("codex"),
19445 SessionMode::Pty,
19446 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
19447 SpawnRecordPolicy::ProviderIdentityOnly,
19448 Some(receipt.clone()),
19449 Some(bundle_receipt.clone()),
19450 None,
19451 Some(materialization_id.clone()),
19452 )
19453 .unwrap()
19454 .unwrap();
19455 guard.retain();
19456 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
19457
19458 let error = match shared.resolve_record_materialization(
19459 &record_id,
19460 &agent("codex"),
19461 SessionMode::Pty,
19462 Some(&receipt),
19463 Some(&bundle_receipt),
19464 None,
19465 ) {
19466 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
19467 Err(error) => error,
19468 };
19469 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
19470 assert_eq!(
19471 shared
19472 .materializations
19473 .lock()
19474 .unwrap_or_else(|poisoned| poisoned.into_inner())
19475 .get(&materialization_id)
19476 .unwrap()
19477 .state(),
19478 MaterializationState::RecoveryRequired,
19479 );
19480 drop(shared);
19481 drop(runtime);
19482 let _ = std::fs::remove_dir_all(root);
19483 }
19484
19485 fn bind_record_owned_materialization_fixture(
19486 shared: &NodeShared,
19487 receipt: &ResolvedEnvironmentProfileReceipt,
19488 instance_id: u64,
19489 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
19490 let address = SessionAddress {
19491 workspace_id: WorkspaceId::new("primary").unwrap(),
19492 session: SessionKey {
19493 instance_id: AgentInstanceId(instance_id),
19494 generation: SessionGeneration(1),
19495 },
19496 };
19497 let (overlay, materialization_guard) = shared
19498 .prepare_session_materialization(
19499 &address,
19500 &agent("claude"),
19501 SessionMode::Pty,
19502 Some(receipt),
19503 None,
19504 None,
19505 None,
19506 )
19507 .unwrap()
19508 .unwrap();
19509 let materialization_id = materialization_guard.id().unwrap().clone();
19510 let materialization_root = shared
19511 .materializations
19512 .lock()
19513 .unwrap_or_else(|poisoned| poisoned.into_inner())
19514 .get(&materialization_id)
19515 .unwrap()
19516 .root()
19517 .to_path_buf();
19518 let selection = shared
19519 .select_environment_profile(
19520 address.session.instance_id,
19521 &agent("claude"),
19522 SessionMode::Pty,
19523 Some(receipt),
19524 )
19525 .unwrap()
19526 .unwrap();
19527 assert!(shared
19528 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
19529 .unwrap()
19530 .is_none());
19531 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
19532 let record_id = shared
19533 .bind_spawn_session_with_materialization(
19534 &address,
19535 agent("claude"),
19536 SessionMode::Pty,
19537 policy,
19538 SpawnRecordPolicy::ProviderIdentityOnly,
19539 Some(receipt.clone()),
19540 None,
19541 None,
19542 Some(materialization_id.clone()),
19543 )
19544 .unwrap()
19545 .unwrap();
19546 selection.retain();
19547 materialization_guard.retain();
19548 (address, record_id, materialization_id, materialization_root)
19549 }
19550
19551 #[test]
19552 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
19553 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
19554 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19555 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
19556 }));
19557 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
19558 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
19559 let mut spec = spawn_spec_fixture();
19560 spec.expected_profile_revision =
19561 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
19562 assert!(matches!(
19563 &spec.overrides.environment_profile_id,
19564 crate::protocol::SpawnOverride::Inherit,
19565 ));
19566 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
19567 .requires_child_environment_profile_capability());
19568 assert!(request_requires_child_environment_profile(
19569 &shared,
19570 &NodeRequest::SpawnSpec { spec: spec.clone() },
19571 ));
19572
19573 let address = terminal_address(1);
19574 shared.bind_session_with_policy(
19575 &address,
19576 ProviderRuntimePolicy::raw_pty(),
19577 Some(environment_profile.clone()),
19578 );
19579 assert!(request_requires_child_environment_profile(
19580 &shared,
19581 &NodeRequest::Input {
19582 session: address.clone(),
19583 text: "x".to_owned(),
19584 },
19585 ));
19586
19587 let mut record = record("claude", "profile-record");
19588 record.active_session = Some(address.clone());
19589 record.environment_profile = Some(environment_profile.clone());
19590 let record_id = record.record_id.clone();
19591 shared.insert_record(record.clone()).unwrap();
19592 assert!(request_requires_child_environment_profile(
19593 &shared,
19594 &NodeRequest::ResumeSessionRecord {
19595 record_id,
19596 terminal_size: gate4agent_types::TerminalSize {
19597 rows: 24,
19598 columns: 80,
19599 },
19600 initial_prompt: None,
19601 },
19602 ));
19603
19604 let snapshot = snapshot_for_wire(
19605 &shared, false, true, true, true, false, false, false, false, true,
19606 );
19607 assert_eq!(snapshot.session_records.len(), 1);
19608 assert!(snapshot.session_records[0].environment_profile.is_none());
19609 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
19610 sequence: 1,
19611 event: NodeEvent::SessionRecordUpserted { record },
19612 });
19613 let NodeEvent::SessionRecordUpserted { record } = event.event else {
19614 panic!("record projection changed the event kind");
19615 };
19616 assert!(record.environment_profile.is_none());
19617
19618 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
19619 let mut reply = ResponseEnvelope {
19620 request_id: 1,
19621 result: Ok(NodeResponse::SpawnSpecAccepted {
19622 receipt: resolved.receipt_with_environment(
19623 shared.incarnation_id,
19624 address,
19625 Some(environment_profile),
19626 ),
19627 }),
19628 };
19629 project_response_without_child_environment_profile(&mut reply);
19630 assert_eq!(
19631 reply.result.unwrap_err().code,
19632 NodeFailureCode::UnsupportedCapability,
19633 );
19634 }
19635
19636 #[test]
19637 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
19638 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
19639 let snapshot = shared.snapshot();
19640 let profiles = snapshot
19641 .launch_inventory
19642 .as_ref()
19643 .and_then(|inventory| inventory.spawn_profiles.as_ref())
19644 .unwrap();
19645 assert_eq!(profiles.len(), 1);
19646 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
19647 assert!(snapshot.requires_child_environment_profile_capability());
19648
19649 let mut legacy = snapshot.clone();
19650 clear_snapshot_child_environment_profiles(&mut legacy);
19651 assert!(legacy
19652 .launch_inventory
19653 .as_ref()
19654 .unwrap()
19655 .spawn_profiles
19656 .as_ref()
19657 .unwrap()[0]
19658 .environment_profile
19659 .is_none());
19660 assert!(!legacy.requires_child_environment_profile_capability());
19661
19662 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
19663 let bindings = shared
19664 .environment_profiles
19665 .read()
19666 .unwrap_or_else(|poisoned| poisoned.into_inner())
19667 .clone();
19668 let mut provider_mismatch = bindings.clone();
19669 provider_mismatch
19670 .values_mut()
19671 .next()
19672 .unwrap()
19673 .provider = agent("codex");
19674 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
19675 let mut mode_mismatch = profile;
19676 mode_mismatch.mode = SessionMode::Inline;
19677 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
19678
19679 shared
19680 .environment_profiles
19681 .write()
19682 .unwrap_or_else(|poisoned| poisoned.into_inner())
19683 .clear();
19684 assert!(shared
19685 .snapshot()
19686 .launch_inventory
19687 .unwrap()
19688 .spawn_profiles
19689 .unwrap()
19690 .is_empty());
19691 }
19692
19693 #[test]
19694 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
19695 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19696 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
19697 }));
19698 let shared = terminal_test_shared();
19699 let bundle = ResolvedBundleReceipt {
19700 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
19701 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
19702 digest: crate::protocol::SpawnBundleDigest::new(format!(
19703 "sha256:{}",
19704 "0".repeat(64),
19705 ))
19706 .unwrap(),
19707 };
19708 let mut bundled = record("claude", "bundle-record");
19709 bundled.bundle = Some(bundle.clone());
19710 let record_id = bundled.record_id.clone();
19711 shared.insert_record(bundled.clone()).unwrap();
19712 assert!(request_requires_session_bundle(
19713 &shared,
19714 &NodeRequest::ForgetSessionRecord {
19715 record_id: record_id.clone(),
19716 },
19717 ));
19718
19719 let snapshot = snapshot_for_wire(
19720 &shared, false, true, true, true, true, false, false, false, true,
19721 );
19722 assert!(snapshot.session_records[0].bundle.is_none());
19723 let launch = snapshot.launch_inventory.as_ref().unwrap();
19724 assert!(launch.spawn_profiles.is_some());
19725 assert!(launch.bundles.is_none());
19726 let event = project_event_without_session_bundle(NodeEventEnvelope {
19727 sequence: 1,
19728 event: NodeEvent::SessionRecordUpserted { record: bundled },
19729 });
19730 let NodeEvent::SessionRecordUpserted { record } = event.event else {
19731 panic!("record projection changed the event kind");
19732 };
19733 assert!(record.bundle.is_none());
19734
19735 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
19736 let mut reply = ResponseEnvelope {
19737 request_id: 1,
19738 result: Ok(NodeResponse::SpawnSpecAccepted {
19739 receipt: resolved.receipt_with_materialization(
19740 shared.incarnation_id,
19741 terminal_address(17),
19742 None,
19743 Some(bundle),
19744 None,
19745 ),
19746 }),
19747 };
19748 project_response_without_session_bundle(&mut reply);
19749 assert_eq!(
19750 reply.result.unwrap_err().code,
19751 NodeFailureCode::UnsupportedCapability,
19752 );
19753 }
19754
19755 #[test]
19756 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
19757 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19758 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
19759 }));
19760 let context_id = SpawnContextId::new("context-review").unwrap();
19761 let context = ResolvedContextPackReceipt {
19762 id: context_id.clone(),
19763 digest: crate::protocol::SpawnContextDigest::new(format!(
19764 "sha256:{}",
19765 "c".repeat(64),
19766 ))
19767 .unwrap(),
19768 lineage: ContextPackLineageReceipt {
19769 source_node_id: NodeId::new("node-source").unwrap(),
19770 source_session: terminal_address(1),
19771 source_provider: agent("kimi"),
19772 },
19773 source_message_count: 2,
19774 retained_message_count: 2,
19775 byte_len: 256,
19776 truncated: false,
19777 };
19778 assert!(context.is_valid());
19779 let mut bound_record = record("codex", "context-record");
19780 bound_record.context_id = Some(context_id);
19781 bound_record.context = Some(context.clone());
19782 let session = gate4agent_types::SessionSnapshot {
19783 instance_id: AgentInstanceId(91),
19784 agent_id: agent("kimi"),
19785 transport: TransportKind::Pty,
19786 generation: SessionGeneration(3),
19787 status: SessionStatus::Running,
19788 pending_operation: None,
19789 pending_input: None,
19790 process_id: Some(9001),
19791 terminal_size: None,
19792 terminal_frame: None,
19793 terminal_stale: None,
19794 session_options: None,
19795 capabilities: gate4agent_types::CapabilitySnapshot::default(),
19796 history: gate4agent_types::HistorySnapshot {
19797 pending: None,
19798 candidates: vec![HistoryCandidateSummary {
19799 id: "candidate-opaque".to_owned(),
19800 session_id_hint: "session-hint".to_owned(),
19801 modified_at_unix_ms: Some(1),
19802 }],
19803 loaded_candidate_id: Some("candidate-opaque".to_owned()),
19804 loaded: Some(HistorySessionRecord {
19805 session_id: "private-vendor-session".to_owned(),
19806 title: Some("private title".to_owned()),
19807 cwd: Some(r"C:\private\history-root".to_owned()),
19808 model: Some("private-model".to_owned()),
19809 message_count: 2,
19810 completed_turn_count: None,
19811 total_tokens: 19,
19812 messages: vec![
19813 gate4agent_types::HistoryMessageRecord {
19814 role: gate4agent_types::HistoryMessageRole::User,
19815 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
19816 },
19817 gate4agent_types::HistoryMessageRecord {
19818 role: gate4agent_types::HistoryMessageRole::Assistant,
19819 text: "private answer".to_owned(),
19820 },
19821 ],
19822 }),
19823 last_error: None,
19824 },
19825 resume: gate4agent_types::ResumeSnapshot::default(),
19826 foreground: gate4agent_types::ForegroundSnapshot::default(),
19827 provider: gate4agent_types::ProviderSnapshot::default(),
19828 screen_state: gate4agent_types::PtyScreenState::default(),
19829 };
19830 let original = NodeSnapshot {
19831 node_id: NodeId::new("node-terminal-test").unwrap(),
19832 enabled_providers: vec![agent("kimi"), agent("codex")],
19833 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19834 workspaces: vec![WorkspaceSnapshot {
19835 workspace_id: WorkspaceId::new("primary").unwrap(),
19836 canonical_root: opaque_windows_path(
19837 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
19838 ),
19839 sessions: vec![session],
19840 worktree_service_mode: None,
19841 managed_worktree_profiles: None,
19842 }],
19843 session_records: vec![bound_record],
19844 managed_worktrees: Vec::new(),
19845 launch_inventory: None,
19846 agent_progress: Vec::new(),
19847 };
19848
19849 let mut negotiated = original.clone();
19850 project_snapshot_history_for_wire(&mut negotiated, true);
19851 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
19852 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
19853 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
19854 assert!(!serde_json::to_string(&negotiated)
19855 .unwrap()
19856 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
19857
19858 let mut legacy = original;
19859 project_snapshot_history_for_wire(&mut legacy, false);
19860 clear_snapshot_context_packs(&mut legacy);
19861 assert_eq!(
19862 legacy.workspaces[0].sessions[0].history,
19863 gate4agent_types::HistorySnapshot::default(),
19864 );
19865 assert!(legacy.session_records[0].context_id.is_none());
19866 assert!(legacy.session_records[0].context.is_none());
19867 let encoded = serde_json::to_string(&legacy).unwrap();
19868 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
19869 assert!(!encoded.contains("context-review"));
19870
19871 let mut reply = ResponseEnvelope {
19872 request_id: 7,
19873 result: Ok(NodeResponse::ContextPackExported { context }),
19874 };
19875 project_response_without_context_pack(&mut reply);
19876 assert_eq!(
19877 reply.result.unwrap_err().code,
19878 NodeFailureCode::UnsupportedCapability,
19879 );
19880 }
19881
19882 #[test]
19883 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
19884 let shared = terminal_test_shared();
19885 let history = HistorySessionRecord {
19886 session_id: "fixture-history".to_owned(),
19887 title: None,
19888 cwd: None,
19889 model: None,
19890 message_count: 1,
19891 completed_turn_count: None,
19892 total_tokens: 0,
19893 messages: vec![gate4agent_types::HistoryMessageRecord {
19894 role: gate4agent_types::HistoryMessageRole::User,
19895 text: "bounded handoff".to_owned(),
19896 }],
19897 };
19898 let pack = NodeContextPack::export(
19899 ContextPackLineageReceipt {
19900 source_node_id: shared.node_id.clone(),
19901 source_session: terminal_address(1),
19902 source_provider: agent("claude"),
19903 },
19904 &history,
19905 )
19906 .unwrap();
19907 let context = shared
19908 .context_catalog
19909 .write()
19910 .unwrap_or_else(|poisoned| poisoned.into_inner())
19911 .insert(pack)
19912 .unwrap();
19913 let mut retained = record("codex", "context-retained-record");
19914 retained.context_id = Some(context.id.clone());
19915 retained.context = Some(context.clone());
19916 let record_id = retained.record_id.clone();
19917 shared.insert_record(retained).unwrap();
19918 assert_eq!(
19919 shared.forget_context_pack(&context.id).unwrap_err().code,
19920 NodeFailureCode::ContextPackBusy,
19921 );
19922 assert!(shared
19923 .context_catalog
19924 .read()
19925 .unwrap_or_else(|poisoned| poisoned.into_inner())
19926 .get(&context.id)
19927 .is_some());
19928 shared
19929 .session_records
19930 .lock()
19931 .unwrap_or_else(|poisoned| poisoned.into_inner())
19932 .records
19933 .remove(&record_id);
19934
19935 let mut spec = spawn_spec_fixture();
19936 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
19937 value: context.id.clone(),
19938 };
19939 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
19940 let receipt = resolved.receipt_with_materialization(
19941 shared.incarnation_id,
19942 terminal_address(2),
19943 None,
19944 None,
19945 Some(context.clone()),
19946 );
19947 assert!(receipt.context_binding_is_valid());
19948 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
19949
19950 shared.forget_context_pack(&context.id).unwrap();
19951 assert!(shared
19952 .context_catalog
19953 .read()
19954 .unwrap_or_else(|poisoned| poisoned.into_inner())
19955 .get(&context.id)
19956 .is_none());
19957 assert!(shared
19958 .replay_spawn_spec(&spec, Instant::now())
19959 .unwrap()
19960 .is_none());
19961 }
19962
19963 #[test]
19964 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
19965 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
19966 let address = terminal_address(1);
19967 let selection = shared
19968 .select_environment_profile(
19969 address.session.instance_id,
19970 &agent("claude"),
19971 SessionMode::Pty,
19972 Some(&environment_profile),
19973 )
19974 .unwrap()
19975 .unwrap();
19976 shared.bind_session_with_policy(
19977 &address,
19978 ProviderRuntimePolicy::raw_pty(),
19979 Some(environment_profile.clone()),
19980 );
19981 selection.retain();
19982 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
19983 assert!(matches!(
19984 runtime.remove_native_launch_profile(&native_id),
19985 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
19986 ));
19987 assert!(shared.remove_binding(&address).is_some());
19988 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
19989
19990 let mismatched = ResolvedEnvironmentProfileReceipt {
19991 profile_id: environment_profile.profile_id,
19992 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
19993 "local-claude-r2",
19994 )
19995 .unwrap(),
19996 network_allowlist: None,
19997 browser_profile_id: None,
19998 };
19999 let error = match shared.select_environment_profile(
20000 AgentInstanceId(42),
20001 &agent("claude"),
20002 SessionMode::Pty,
20003 Some(&mismatched),
20004 ) {
20005 Ok(_) => panic!("mismatched environment profile revision was selected"),
20006 Err(error) => error,
20007 };
20008 assert_eq!(
20009 error.code,
20010 NodeFailureCode::EnvironmentProfileBindingMismatch,
20011 );
20012 assert!(!shared
20013 .session_bindings
20014 .lock()
20015 .unwrap_or_else(|poisoned| poisoned.into_inner())
20016 .contains_key(&AgentInstanceId(42)));
20017 }
20018
20019 #[test]
20020 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
20021 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20022 let address = terminal_address(1);
20023 let selection = shared
20024 .select_environment_profile(
20025 address.session.instance_id,
20026 &agent("claude"),
20027 SessionMode::Pty,
20028 Some(&environment_profile),
20029 )
20030 .unwrap()
20031 .unwrap();
20032 let overlay = NativeInstanceLaunchOverlay::new(
20033 agent("claude"),
20034 TransportKind::Pty,
20035 vec![EnvMutation {
20036 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
20037 value: Some(OsString::from("fixture-secret")),
20038 }],
20039 vec![
20040 OsString::from("--plugin-dir"),
20041 std::env::current_dir().unwrap().into_os_string(),
20042 ],
20043 )
20044 .unwrap();
20045 let instance_overlay = shared
20046 .install_prepared_launch_overlay(
20047 address.session.instance_id,
20048 PreparedNativeLaunchOverlay::Instance(overlay),
20049 )
20050 .unwrap()
20051 .unwrap();
20052 let bundle = ResolvedBundleReceipt {
20053 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
20054 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
20055 digest: crate::protocol::SpawnBundleDigest::new(format!(
20056 "sha256:{}",
20057 "0".repeat(64),
20058 ))
20059 .unwrap(),
20060 };
20061 shared.bind_session_with_materialization(
20062 &address,
20063 ProviderRuntimePolicy::raw_pty(),
20064 Some(environment_profile),
20065 Some(bundle),
20066 None,
20067 None,
20068 );
20069 selection.retain();
20070 instance_overlay.retain();
20071
20072 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20073 assert!(matches!(
20074 runtime.remove_native_launch_profile(&native_id),
20075 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20076 ));
20077 assert!(shared.remove_binding(&address).is_some());
20078 assert!(!shared
20079 .native_launch_profile_control
20080 .as_ref()
20081 .unwrap()
20082 .clear_native_instance_launch_overlay(address.session.instance_id));
20083 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20084 }
20085
20086 #[test]
20087 fn session_environment_materialization_failure_is_pre_child_and_durable() {
20088 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20089 let address = SessionAddress {
20090 workspace_id: WorkspaceId::new("primary").unwrap(),
20091 session: SessionKey {
20092 instance_id: AgentInstanceId(201),
20093 generation: SessionGeneration(1),
20094 },
20095 };
20096 let error = match shared.prepare_session_materialization(
20097 &address,
20098 &agent("claude"),
20099 SessionMode::Pty,
20100 Some(&receipt),
20101 None,
20102 None,
20103 None,
20104 ) {
20105 Ok(_) => panic!("denied local reference unexpectedly materialized"),
20106 Err(error) => error,
20107 };
20108 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20109 assert!(shared.handle.snapshot().sessions.is_empty());
20110 assert!(shared
20111 .session_bindings
20112 .lock()
20113 .unwrap_or_else(|poisoned| poisoned.into_inner())
20114 .is_empty());
20115 assert!(shared
20116 .materializations
20117 .lock()
20118 .unwrap_or_else(|poisoned| poisoned.into_inner())
20119 .is_empty());
20120 drop(shared);
20121 drop(runtime);
20122 std::fs::remove_dir_all(root).unwrap();
20123 }
20124
20125 #[test]
20126 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
20127 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20128 let address = SessionAddress {
20129 workspace_id: WorkspaceId::new("primary").unwrap(),
20130 session: SessionKey {
20131 instance_id: AgentInstanceId(202),
20132 generation: SessionGeneration(1),
20133 },
20134 };
20135 let (overlay, materialization_guard) = shared
20136 .prepare_session_materialization(
20137 &address,
20138 &agent("claude"),
20139 SessionMode::Pty,
20140 Some(&receipt),
20141 None,
20142 None,
20143 None,
20144 )
20145 .unwrap()
20146 .unwrap();
20147 let materialization_id = materialization_guard.id().unwrap().clone();
20148 let materialization_root = shared
20149 .materializations
20150 .lock()
20151 .unwrap_or_else(|poisoned| poisoned.into_inner())
20152 .get(&materialization_id)
20153 .unwrap()
20154 .root()
20155 .to_path_buf();
20156 let selection = shared
20157 .select_environment_profile(
20158 address.session.instance_id,
20159 &agent("claude"),
20160 SessionMode::Pty,
20161 Some(&receipt),
20162 )
20163 .unwrap()
20164 .unwrap();
20165 assert!(shared
20166 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20167 .unwrap()
20168 .is_none());
20169 shared.bind_session_with_materialization(
20170 &address,
20171 ProviderRuntimePolicy::raw_pty(),
20172 Some(receipt),
20173 None,
20174 None,
20175 Some(materialization_id),
20176 );
20177 selection.retain();
20178 materialization_guard.retain();
20179
20180 assert!(materialization_root.is_dir());
20181 assert!(shared
20182 .session_bindings
20183 .lock()
20184 .unwrap_or_else(|poisoned| poisoned.into_inner())
20185 .contains_key(&address.session.instance_id));
20186
20187 let removed = shared.remove_binding(&address).unwrap();
20188 shared
20189 .cleanup_session_owned_materialization(&address, &removed)
20190 .unwrap();
20191 assert!(!materialization_root.exists());
20192 assert!(shared
20193 .materializations
20194 .lock()
20195 .unwrap_or_else(|poisoned| poisoned.into_inner())
20196 .is_empty());
20197 drop(shared);
20198 drop(runtime);
20199 std::fs::remove_dir_all(root).unwrap();
20200 }
20201
20202 #[tokio::test]
20203 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
20204 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20205 let address = SessionAddress {
20206 workspace_id: WorkspaceId::new("primary").unwrap(),
20207 session: SessionKey {
20208 instance_id: AgentInstanceId(203),
20209 generation: SessionGeneration(1),
20210 },
20211 };
20212 let (overlay, materialization_guard) = shared
20213 .prepare_session_materialization(
20214 &address,
20215 &agent("claude"),
20216 SessionMode::Pty,
20217 Some(&receipt),
20218 None,
20219 None,
20220 None,
20221 )
20222 .unwrap()
20223 .unwrap();
20224 let materialization_id = materialization_guard.id().unwrap().clone();
20225 let materialization_root = shared
20226 .materializations
20227 .lock()
20228 .unwrap_or_else(|poisoned| poisoned.into_inner())
20229 .get(&materialization_id)
20230 .unwrap()
20231 .root()
20232 .to_path_buf();
20233 let selection = shared
20234 .select_environment_profile(
20235 address.session.instance_id,
20236 &agent("claude"),
20237 SessionMode::Pty,
20238 Some(&receipt),
20239 )
20240 .unwrap()
20241 .unwrap();
20242 assert!(shared
20243 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20244 .unwrap()
20245 .is_none());
20246 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20247 let record_id = shared
20248 .bind_spawn_session_with_materialization(
20249 &address,
20250 agent("claude"),
20251 SessionMode::Pty,
20252 policy,
20253 SpawnRecordPolicy::ProviderIdentityOnly,
20254 Some(receipt.clone()),
20255 None,
20256 None,
20257 Some(materialization_id.clone()),
20258 )
20259 .unwrap()
20260 .unwrap();
20261 selection.retain();
20262 materialization_guard.retain();
20263 assert_eq!(
20264 shared
20265 .materializations
20266 .lock()
20267 .unwrap_or_else(|poisoned| poisoned.into_inner())
20268 .get(&materialization_id)
20269 .unwrap()
20270 .owner(),
20271 &MaterializationOwner::Record {
20272 record_id: record_id.clone(),
20273 },
20274 );
20275
20276 let removed = shared.remove_binding(&address).unwrap();
20277 shared
20278 .cleanup_session_owned_materialization(&address, &removed)
20279 .unwrap();
20280 assert!(materialization_root.is_dir());
20281 assert!(shared
20282 .resolve_record_materialization(
20283 &record_id,
20284 &agent("claude"),
20285 SessionMode::Pty,
20286 Some(&receipt),
20287 None,
20288 None,
20289 )
20290 .unwrap()
20291 .is_some());
20292 {
20293 let mut records = shared
20294 .session_records
20295 .lock()
20296 .unwrap_or_else(|poisoned| poisoned.into_inner());
20297 let record = records.records.get_mut(&record_id).unwrap();
20298 record.active_session = None;
20299 record.state = ManagedSessionState::Unavailable;
20300 }
20301 shared.forget_session_record(&record_id).await.unwrap();
20302 assert!(!materialization_root.exists());
20303 assert!(shared
20304 .materializations
20305 .lock()
20306 .unwrap_or_else(|poisoned| poisoned.into_inner())
20307 .is_empty());
20308 drop(shared);
20309 drop(runtime);
20310 std::fs::remove_dir_all(root).unwrap();
20311 }
20312
20313 #[test]
20314 fn record_materialization_revalidation_failure_persists_recovery_required() {
20315 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20316 let state_path = root.join("node-state-v6.json");
20317 shared.state_path = Some(state_path.clone());
20318 let (_address, _record_id, materialization_id, materialization_root) =
20319 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
20320 std::fs::write(
20321 materialization_root.join(".gate4agent-materialization-owner"),
20322 b"tampered",
20323 )
20324 .unwrap();
20325
20326 let error = match shared.resolve_record_materialization(
20327 &_record_id,
20328 &agent("claude"),
20329 SessionMode::Pty,
20330 Some(&receipt),
20331 None,
20332 None,
20333 ) {
20334 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
20335 Err(error) => error,
20336 };
20337 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20338 assert_eq!(
20339 shared
20340 .materializations
20341 .lock()
20342 .unwrap_or_else(|poisoned| poisoned.into_inner())
20343 .get(&materialization_id)
20344 .unwrap()
20345 .state(),
20346 MaterializationState::RecoveryRequired,
20347 );
20348 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
20349 assert_eq!(loaded.materializations.len(), 1);
20350 assert_eq!(
20351 loaded.materializations[0].state(),
20352 MaterializationState::RecoveryRequired,
20353 );
20354 drop(shared);
20355 drop(runtime);
20356 std::fs::remove_dir_all(root).unwrap();
20357 }
20358
20359 #[test]
20360 fn transient_record_materialization_resolution_failure_remains_ready() {
20361 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
20362 let (_address, record_id, materialization_id, _materialization_root) =
20363 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
20364 deny.store(true, Ordering::Release);
20365
20366 let error = match shared.resolve_record_materialization(
20367 &record_id,
20368 &agent("claude"),
20369 SessionMode::Pty,
20370 Some(&receipt),
20371 None,
20372 None,
20373 ) {
20374 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
20375 Err(error) => error,
20376 };
20377 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20378 assert_eq!(
20379 shared
20380 .materializations
20381 .lock()
20382 .unwrap_or_else(|poisoned| poisoned.into_inner())
20383 .get(&materialization_id)
20384 .unwrap()
20385 .state(),
20386 MaterializationState::Ready,
20387 );
20388 drop(shared);
20389 drop(runtime);
20390 std::fs::remove_dir_all(root).unwrap();
20391 }
20392
20393 #[test]
20394 fn startup_missing_record_materialization_is_not_resumable() {
20395 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20396 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
20397 shared
20398 .insert_record(ManagedSessionRecord {
20399 record_id: record_id.clone(),
20400 display_name: "missing materialization".to_owned(),
20401 provider: agent("claude"),
20402 mode: SessionMode::Pty,
20403 state: ManagedSessionState::Dormant,
20404 workspace_id: WorkspaceId::new("primary").unwrap(),
20405 canonical_root: opaque_windows_path(
20406 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20407 ),
20408 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
20409 key: gate4agent_types::ProviderSessionKey::SessionId,
20410 id: "provider-session-missing-materialization".to_owned(),
20411 transcript_path: None,
20412 }),
20413 active_session: None,
20414 environment_profile: Some(receipt),
20415 bundle: None,
20416 context_id: None,
20417 context: None,
20418 exported_context: None,
20419 task_binding: None,
20420 created_at_unix_ms: 1,
20421 updated_at_unix_ms: 1,
20422 last_error: None,
20423 })
20424 .unwrap();
20425
20426 shared.reconcile_materializations();
20427 let record = shared.record(&record_id).unwrap();
20428 assert_eq!(record.state, ManagedSessionState::Unavailable);
20429 assert!(record.provider_session.is_none());
20430 assert_eq!(
20431 record.last_error.as_deref(),
20432 Some("environment-profile-unavailable"),
20433 );
20434 drop(shared);
20435 drop(runtime);
20436 std::fs::remove_dir_all(root).unwrap();
20437 }
20438
20439 #[tokio::test]
20440 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
20441 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20442 let lease = install_managed_lease(&shared);
20443 let address = SessionAddress {
20444 workspace_id: WorkspaceId::new("primary").unwrap(),
20445 session: SessionKey {
20446 instance_id: AgentInstanceId(204),
20447 generation: SessionGeneration(1),
20448 },
20449 };
20450 let (overlay, materialization_guard) = shared
20451 .prepare_session_materialization(
20452 &address,
20453 &agent("claude"),
20454 SessionMode::Pty,
20455 Some(&receipt),
20456 None,
20457 None,
20458 Some(lease.lease_id.clone()),
20459 )
20460 .unwrap()
20461 .unwrap();
20462 let materialization_id = materialization_guard.id().unwrap().clone();
20463 let materialization_root = shared
20464 .materializations
20465 .lock()
20466 .unwrap_or_else(|poisoned| poisoned.into_inner())
20467 .get(&materialization_id)
20468 .unwrap()
20469 .root()
20470 .to_path_buf();
20471 let selection = shared
20472 .select_environment_profile(
20473 address.session.instance_id,
20474 &agent("claude"),
20475 SessionMode::Pty,
20476 Some(&receipt),
20477 )
20478 .unwrap()
20479 .unwrap();
20480 assert!(shared
20481 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20482 .unwrap()
20483 .is_none());
20484 shared.bind_session_with_materialization(
20485 &address,
20486 ProviderRuntimePolicy::raw_pty(),
20487 Some(receipt),
20488 None,
20489 None,
20490 Some(materialization_id),
20491 );
20492 selection.retain();
20493 materialization_guard.retain();
20494
20495 let blocked = shared
20496 .cleanup_managed_worktree(&lease.lease_id, false)
20497 .await
20498 .unwrap_err();
20499 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
20500 assert!(materialization_root.is_dir());
20501 let removed = shared.remove_binding(&address).unwrap();
20502 shared
20503 .cleanup_session_owned_materialization(&address, &removed)
20504 .unwrap();
20505 assert!(!materialization_root.exists());
20506 drop(shared);
20507 drop(runtime);
20508 std::fs::remove_dir_all(root).unwrap();
20509 }
20510
20511 #[tokio::test]
20512 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
20513 let git_root = temporary_workspace_root("raw-managed-inventory");
20514 let source = git_root.join("source");
20515 let allocation = git_root.join("allocation");
20516 std::fs::create_dir_all(&source).unwrap();
20517 std::fs::create_dir_all(&allocation).unwrap();
20518 run_test_git(&source, &["init"]);
20519 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
20520 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
20521 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
20522 run_test_git(&source, &["add", "seed.txt"]);
20523 run_test_git(&source, &["commit", "-m", "seed"]);
20524 let profile = ManagedWorktreeProfile::new(
20525 WorktreeProfileId::new("default").unwrap(),
20526 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20527 &allocation,
20528 "gate4agent",
20529 "HEAD",
20530 ManagedWorktreeRetention::RemoveWhenReleased,
20531 )
20532 .unwrap();
20533 let workspace = WorkspaceConfig::new(
20534 WorkspaceId::new("primary").unwrap(),
20535 &source,
20536 )
20537 .unwrap()
20538 .with_worktree_service_mode(WorktreeServiceMode::Managed)
20539 .with_managed_worktree_profile(profile.clone())
20540 .unwrap();
20541 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
20542 materialization_test_shared_with_workspace(false, workspace);
20543 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
20544 let source_text = source.to_string_lossy().into_owned();
20545 let target_text = target.to_string_lossy().into_owned();
20546 let created = create_git_worktree(
20547 &source_text,
20548 &target_text,
20549 "gate4agent/mw-raw-inventory",
20550 Some("HEAD"),
20551 )
20552 .await
20553 .unwrap();
20554 let lease = ManagedWorktreeLeaseRecord {
20555 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
20556 source_workspace_id: WorkspaceId::new("primary").unwrap(),
20557 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
20558 profile_id: profile.profile_id().clone(),
20559 profile_revision: profile.revision().clone(),
20560 target_root: created.path.clone(),
20561 branch: "gate4agent/mw-raw-inventory".to_owned(),
20562 base_commit: created.head.clone(),
20563 expected_head: Some(created.head.clone()),
20564 retention: ManagedWorktreeRetention::RemoveWhenReleased,
20565 state: ManagedWorktreeLeaseState::Ready,
20566 session_holders: Vec::new(),
20567 record_holders: Vec::new(),
20568 cleanup_failure: None,
20569 created_at_unix_ms: 1,
20570 updated_at_unix_ms: 1,
20571 };
20572 shared
20573 .workspaces
20574 .write()
20575 .unwrap_or_else(|poisoned| poisoned.into_inner())
20576 .insert(lease.workspace_id.clone(), created.path.clone());
20577 *shared
20578 .managed_worktrees
20579 .lock()
20580 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20581 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
20582 let address = SessionAddress {
20583 workspace_id: lease.workspace_id.clone(),
20584 session: SessionKey {
20585 instance_id: AgentInstanceId(205),
20586 generation: SessionGeneration(1),
20587 },
20588 };
20589 let (overlay, materialization_guard) = shared
20590 .prepare_session_materialization(
20591 &address,
20592 &agent("claude"),
20593 SessionMode::Pty,
20594 Some(&receipt),
20595 None,
20596 None,
20597 Some(lease.lease_id.clone()),
20598 )
20599 .unwrap()
20600 .unwrap();
20601 let materialization_id = materialization_guard.id().unwrap().clone();
20602 let materialization_root = shared
20603 .materializations
20604 .lock()
20605 .unwrap_or_else(|poisoned| poisoned.into_inner())
20606 .get(&materialization_id)
20607 .unwrap()
20608 .root()
20609 .to_path_buf();
20610 let selection = shared
20611 .select_environment_profile(
20612 address.session.instance_id,
20613 &agent("claude"),
20614 SessionMode::Pty,
20615 Some(&receipt),
20616 )
20617 .unwrap()
20618 .unwrap();
20619 assert!(shared
20620 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20621 .unwrap()
20622 .is_none());
20623 let record_id = shared
20624 .bind_spawn_session_with_materialization(
20625 &address,
20626 agent("claude"),
20627 SessionMode::Pty,
20628 ProviderRuntimePolicy::raw_pty(),
20629 SpawnRecordPolicy::Always,
20630 Some(receipt.clone()),
20631 None,
20632 None,
20633 Some(materialization_id.clone()),
20634 )
20635 .unwrap()
20636 .unwrap();
20637 selection.retain();
20638 materialization_guard.retain();
20639 let record = shared.record(&record_id).unwrap();
20640 assert_eq!(record.state, ManagedSessionState::Live);
20641 assert!(record.provider_session.is_none());
20642 assert!(record.environment_profile.is_none());
20643 assert!(record.bundle.is_none());
20644 assert!(record.context_id.is_none());
20645 assert!(record.context.is_none());
20646 assert_eq!(record.active_session.as_ref(), Some(&address));
20647 assert_eq!(
20648 shared
20649 .materializations
20650 .lock()
20651 .unwrap_or_else(|poisoned| poisoned.into_inner())
20652 .get(&materialization_id)
20653 .unwrap()
20654 .owner(),
20655 &MaterializationOwner::Session {
20656 incarnation_id: shared.incarnation_id,
20657 instance_id: address.session.instance_id,
20658 generation: address.session.generation,
20659 },
20660 );
20661 {
20662 let mut bindings = shared
20663 .session_bindings
20664 .lock()
20665 .unwrap_or_else(|poisoned| poisoned.into_inner());
20666 bindings
20667 .get_mut(&address.session.instance_id)
20668 .unwrap()
20669 .managed_worktree_lease_id = Some(lease.lease_id.clone());
20670 }
20671 let lease_snapshot = shared
20672 .managed_worktrees
20673 .lock()
20674 .unwrap_or_else(|poisoned| poisoned.into_inner())
20675 .bind_session(
20676 &lease.lease_id,
20677 ManagedWorktreeSessionHolder {
20678 incarnation_id: shared.incarnation_id,
20679 instance_id: address.session.instance_id,
20680 generation: address.session.generation,
20681 },
20682 None,
20683 unix_time_ms(),
20684 )
20685 .unwrap();
20686 assert_eq!(lease_snapshot.active_session_count, 1);
20687 assert_eq!(lease_snapshot.managed_record_count, 0);
20688 shared.persist_state().unwrap();
20689 assert!(shared
20690 .managed_worktrees
20691 .lock()
20692 .unwrap_or_else(|poisoned| poisoned.into_inner())
20693 .get(&lease.lease_id)
20694 .unwrap()
20695 .record_holders
20696 .is_empty());
20697 shared
20698 .handle
20699 .dispatch(shared.prepare_command(ControlCommand::Register {
20700 instance_id: address.session.instance_id,
20701 agent_id: agent("claude"),
20702 transport: TransportKind::Pty,
20703 }))
20704 .unwrap();
20705 runtime.tick().await;
20706 shared.reconcile_managed_record(
20707 &record_id,
20708 &address,
20709 &ControlEvent {
20710 sequence: 1,
20711 command_id: None,
20712 instance_id: address.session.instance_id,
20713 generation: address.session.generation,
20714 event: ControlEventKind::Removed,
20715 },
20716 false,
20717 false,
20718 );
20719 let remove = shared.remove_session(&address);
20720 let drive_runtime = async {
20721 runtime.tick().await;
20722 };
20723 let (removed, ()) = tokio::join!(remove, drive_runtime);
20724 removed.unwrap();
20725
20726 assert!(!materialization_root.exists());
20727 assert!(!Path::new(&created.path).exists());
20728 assert_eq!(
20729 shared
20730 .managed_worktrees
20731 .lock()
20732 .unwrap_or_else(|poisoned| poisoned.into_inner())
20733 .get(&lease.lease_id)
20734 .unwrap()
20735 .state,
20736 ManagedWorktreeLeaseState::Removed,
20737 );
20738 let renamed = shared
20739 .rename_session_record(&record_id, "raw managed inventory".to_owned())
20740 .unwrap();
20741 assert_eq!(renamed.record_id, record_id);
20742 assert_eq!(renamed.display_name, "raw managed inventory");
20743 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
20744 assert!(renamed.active_session.is_none());
20745
20746 drop(shared);
20747 drop(runtime);
20748 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
20749 std::fs::remove_dir_all(git_root).unwrap();
20750 }
20751
20752 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
20753 let root = std::env::current_dir().unwrap()
20754 .join("managed-test-target")
20755 .to_string_lossy()
20756 .into_owned();
20757 let lease = ManagedWorktreeLeaseRecord {
20758 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
20759 source_workspace_id: WorkspaceId::new("primary").unwrap(),
20760 workspace_id: WorkspaceId::new("managed-test").unwrap(),
20761 profile_id: WorktreeProfileId::new("default").unwrap(),
20762 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20763 target_root: root.clone(),
20764 branch: "gate4agent/mw-test".to_owned(),
20765 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
20766 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
20767 retention: ManagedWorktreeRetention::RemoveWhenReleased,
20768 state: ManagedWorktreeLeaseState::Ready,
20769 session_holders: Vec::new(),
20770 record_holders: Vec::new(),
20771 cleanup_failure: None,
20772 created_at_unix_ms: 1,
20773 updated_at_unix_ms: 1,
20774 };
20775 shared.workspaces.write()
20776 .unwrap_or_else(|poisoned| poisoned.into_inner())
20777 .insert(lease.workspace_id.clone(), root);
20778 *shared.managed_worktrees.lock()
20779 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20780 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
20781 lease
20782 }
20783
20784 #[cfg(feature = "fixture")]
20785 #[test]
20786 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
20787 let mut shared = terminal_test_shared();
20788 shared.fixture_semantic_hook_policy = true;
20789 let lease = install_managed_lease(&shared);
20790 let address = SessionAddress {
20791 workspace_id: lease.workspace_id.clone(),
20792 session: SessionKey {
20793 instance_id: AgentInstanceId(206),
20794 generation: SessionGeneration(1),
20795 },
20796 };
20797 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
20798 let effective_runtime_policy =
20799 shared.effective_spawn_runtime_policy(pre_admission_policy);
20800 assert_ne!(effective_runtime_policy, pre_admission_policy);
20801 assert!(effective_runtime_policy.provider_session_identity);
20802
20803 let record_id = shared
20804 .bind_spawn_session(
20805 &address,
20806 agent("claude"),
20807 SessionMode::Pty,
20808 effective_runtime_policy,
20809 SpawnRecordPolicy::Always,
20810 None,
20811 )
20812 .unwrap()
20813 .unwrap();
20814 assert_eq!(
20815 shared.record(&record_id).unwrap().state,
20816 ManagedSessionState::IdentityPending,
20817 );
20818 let (record_holder, raw_inventory_record_id) = {
20819 let mut bindings = shared
20820 .session_bindings
20821 .lock()
20822 .unwrap_or_else(|poisoned| poisoned.into_inner());
20823 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
20824 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
20825 managed_spawn_record_ownership(binding, effective_runtime_policy)
20826 };
20827 assert_eq!(record_holder.as_ref(), Some(&record_id));
20828 assert!(raw_inventory_record_id.is_none());
20829 let snapshot = shared
20830 .managed_worktrees
20831 .lock()
20832 .unwrap_or_else(|poisoned| poisoned.into_inner())
20833 .bind_session(
20834 &lease.lease_id,
20835 ManagedWorktreeSessionHolder {
20836 incarnation_id: shared.incarnation_id,
20837 instance_id: address.session.instance_id,
20838 generation: address.session.generation,
20839 },
20840 record_holder,
20841 unix_time_ms(),
20842 )
20843 .unwrap();
20844 assert_eq!(snapshot.managed_record_count, 1);
20845 assert_eq!(
20846 shared
20847 .managed_worktrees
20848 .lock()
20849 .unwrap_or_else(|poisoned| poisoned.into_inner())
20850 .get(&lease.lease_id)
20851 .unwrap()
20852 .record_holders,
20853 vec![record_id.clone()],
20854 );
20855
20856 let identity = ProviderSessionIdentity {
20857 key: ProviderSessionKey::SessionId,
20858 id: "monitoring-fixture-managed-session".to_owned(),
20859 transcript_path: None,
20860 };
20861 shared.publish_control(ControlEvent {
20862 sequence: 1,
20863 command_id: None,
20864 instance_id: address.session.instance_id,
20865 generation: address.session.generation,
20866 event: ControlEventKind::ProviderEvent {
20867 sequence: 1,
20868 source: gate4agent_types::ProviderSource {
20869 family: AdapterFamily::Hook,
20870 binding: AdapterBinding::new(
20871 gate4agent_types::AdapterId::new("claude-code").unwrap(),
20872 "fixture/v1",
20873 gate4agent_types::AdapterVerification::SyntheticFixture,
20874 )
20875 .unwrap(),
20876 },
20877 source_sequence: 1,
20878 event: ProviderEvent::SessionIdentityObserved {
20879 identity: identity.clone(),
20880 },
20881 },
20882 });
20883
20884 let live = shared.record(&record_id).unwrap();
20885 assert_eq!(live.state, ManagedSessionState::Live);
20886 assert_eq!(live.provider_session.as_ref(), Some(&identity));
20887 assert_eq!(live.active_session.as_ref(), Some(&address));
20888 assert_eq!(
20889 shared
20890 .managed_worktrees
20891 .lock()
20892 .unwrap_or_else(|poisoned| poisoned.into_inner())
20893 .get(&lease.lease_id)
20894 .unwrap()
20895 .record_holders,
20896 vec![record_id],
20897 );
20898 }
20899
20900 fn managed_v2_test_shared(
20901 allocation_root: &Path,
20902 state_path: Option<PathBuf>,
20903 profile_revision: &str,
20904 ) -> NodeShared {
20905 let profile = ManagedWorktreeProfile::new(
20906 WorktreeProfileId::new("default").unwrap(),
20907 WorktreeProfileRevision::new(profile_revision).unwrap(),
20908 allocation_root,
20909 "gate4agent",
20910 "HEAD",
20911 ManagedWorktreeRetention::RemoveWhenReleased,
20912 )
20913 .unwrap();
20914 let workspace = WorkspaceConfig::new(
20915 WorkspaceId::new("primary").unwrap(),
20916 std::env::current_dir().unwrap(),
20917 )
20918 .unwrap()
20919 .with_worktree_service_mode(WorktreeServiceMode::Managed)
20920 .with_managed_worktree_profile(profile)
20921 .unwrap();
20922 let catalog = active_registry().unwrap();
20923 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20924 let mut shared = NodeShared::new(
20925 handle,
20926 "fixture-token".to_owned(),
20927 NodeId::new("node-terminal-test").unwrap(),
20928 vec![workspace],
20929 vec![agent("claude")],
20930 );
20931 shared.state_path = state_path;
20932 shared
20933 }
20934
20935 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
20936 let mut spawn_spec = spawn_spec_fixture();
20937 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
20938 ManagedWorktreeSpawnRequestV2 {
20939 spawn_spec,
20940 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
20941 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
20942 }
20943 }
20944
20945 #[test]
20946 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
20947 let shared = terminal_test_shared();
20948 let mut lease = install_managed_lease(&shared);
20949 let session = SessionAddress {
20950 workspace_id: lease.workspace_id.clone(),
20951 session: SessionKey {
20952 instance_id: AgentInstanceId(73),
20953 generation: SessionGeneration(4),
20954 },
20955 };
20956 lease.state = ManagedWorktreeLeaseState::InUse;
20957 lease.session_holders.push(ManagedWorktreeSessionHolder {
20958 incarnation_id: shared.incarnation_id,
20959 instance_id: session.session.instance_id,
20960 generation: session.session.generation,
20961 });
20962 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20963 assert!(resolved.target.worktree_id.is_none());
20964
20965 let receipt = managed_spawn_receipt(
20966 &resolved,
20967 shared.incarnation_id,
20968 session.clone(),
20969 lease.snapshot(),
20970 None,
20971 None,
20972 None,
20973 );
20974 assert!(resolved.target.worktree_id.is_none());
20975 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
20976 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
20977 assert_eq!(receipt.spawn.session, session);
20978 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
20979 let encoded = serde_json::to_vec(&receipt).unwrap();
20980 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
20981 assert_eq!(decoded, receipt);
20982 }
20983
20984 #[tokio::test]
20985 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
20986 let root = temporary_workspace_root("managed-v2-revision");
20987 let allocation = root.join("allocation");
20988 std::fs::create_dir_all(&allocation).unwrap();
20989 let shared = managed_v2_test_shared(
20990 &allocation,
20991 Some(root.join("node-state.json")),
20992 "v1",
20993 );
20994 let mut request = managed_v2_request("managed-v2-revision");
20995 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
20996
20997 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
20998
20999 assert_eq!(
21000 failure.code,
21001 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21002 );
21003 assert!(shared
21004 .managed_worktrees
21005 .lock()
21006 .unwrap_or_else(|poisoned| poisoned.into_inner())
21007 .records()
21008 .is_empty());
21009 assert!(shared
21010 .managed_spawn_replays
21011 .lock()
21012 .unwrap_or_else(|poisoned| poisoned.into_inner())
21013 .is_empty());
21014 std::fs::remove_dir_all(root).unwrap();
21015 }
21016
21017 #[test]
21018 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
21019 let root = temporary_workspace_root("managed-v2-concurrent");
21020 let allocation = root.join("allocation");
21021 std::fs::create_dir_all(&allocation).unwrap();
21022 let shared = Arc::new(managed_v2_test_shared(
21023 &allocation,
21024 Some(root.join("node-state.json")),
21025 "v1",
21026 ));
21027 let request = managed_v2_request("managed-v2-concurrent");
21028 let barrier = Arc::new(std::sync::Barrier::new(3));
21029 let mut joins = Vec::new();
21030 for _ in 0..2 {
21031 let shared = Arc::clone(&shared);
21032 let request = request.clone();
21033 let barrier = Arc::clone(&barrier);
21034 joins.push(std::thread::spawn(move || {
21035 let digest = managed_spawn_request_digest_v2(&request).unwrap();
21036 barrier.wait();
21037 shared.acquire_durable_managed_spawn_v2(&request, digest)
21038 }));
21039 }
21040 barrier.wait();
21041 let results = joins
21042 .into_iter()
21043 .map(|join| join.join().unwrap())
21044 .collect::<Vec<_>>();
21045 assert_eq!(
21046 results
21047 .iter()
21048 .filter(|result| {
21049 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
21050 })
21051 .count(),
21052 1,
21053 );
21054 assert_eq!(
21055 results
21056 .iter()
21057 .filter(|result| {
21058 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
21059 })
21060 .count(),
21061 1,
21062 );
21063 assert_eq!(
21064 shared
21065 .managed_spawn_replays
21066 .lock()
21067 .unwrap_or_else(|poisoned| poisoned.into_inner())
21068 .len(),
21069 1,
21070 );
21071 assert!(shared
21072 .managed_worktrees
21073 .lock()
21074 .unwrap_or_else(|poisoned| poisoned.into_inner())
21075 .records()
21076 .is_empty());
21077 drop(shared);
21078 std::fs::remove_dir_all(root).unwrap();
21079 }
21080
21081 #[tokio::test]
21082 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
21083 let root = temporary_workspace_root("managed-v2-linked-pending");
21084 let allocation = root.join("allocation");
21085 std::fs::create_dir_all(&allocation).unwrap();
21086 let state_path = root.join("node-state.json");
21087 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21088 let lease = install_managed_lease(&shared);
21089 let request = managed_v2_request("managed-v2-linked-pending");
21090 assert!(matches!(
21091 shared
21092 .acquire_durable_managed_spawn_v2(
21093 &request,
21094 managed_spawn_request_digest_v2(&request).unwrap(),
21095 )
21096 .unwrap(),
21097 DurableManagedSpawnReplayDecision::Reserved,
21098 ));
21099 {
21100 let _transaction = shared
21101 .state_transaction
21102 .lock()
21103 .unwrap_or_else(|poisoned| poisoned.into_inner());
21104 shared
21105 .link_durable_managed_spawn_v2_lease_locked(
21106 &request.spawn_spec.idempotency_key,
21107 &lease.lease_id,
21108 )
21109 .unwrap();
21110 shared.persist_state_locked().unwrap();
21111 }
21112 drop(shared);
21113
21114 let mut loaded = session_registry::load(
21115 Some(&state_path),
21116 &NodeId::new("node-terminal-test").unwrap(),
21117 )
21118 .unwrap();
21119 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21120 *reopened
21121 .managed_worktrees
21122 .lock()
21123 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21124 ManagedWorktreeRegistry::from_records(
21125 std::mem::take(&mut loaded.managed_worktrees),
21126 std::mem::take(&mut loaded.managed_worktree_tombstones),
21127 )
21128 .unwrap();
21129 *reopened
21130 .managed_spawn_replays
21131 .lock()
21132 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21133 .managed_spawn_replays
21134 .into_iter()
21135 .map(|record| (record.idempotency_key.clone(), record))
21136 .collect();
21137 let lease_count = reopened
21138 .managed_worktrees
21139 .lock()
21140 .unwrap_or_else(|poisoned| poisoned.into_inner())
21141 .records()
21142 .len();
21143
21144 let failure = reopened
21145 .spawn_managed_worktree_v2(request)
21146 .await
21147 .unwrap_err();
21148
21149 assert_eq!(
21150 failure.code,
21151 NodeFailureCode::ManagedWorktreeRecoveryRequired,
21152 );
21153 assert_eq!(
21154 reopened
21155 .managed_worktrees
21156 .lock()
21157 .unwrap_or_else(|poisoned| poisoned.into_inner())
21158 .records()
21159 .len(),
21160 lease_count,
21161 );
21162 std::fs::remove_dir_all(root).unwrap();
21163 }
21164
21165 #[tokio::test]
21166 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
21167 let root = temporary_workspace_root("managed-v2-reopen");
21168 let allocation = root.join("allocation");
21169 std::fs::create_dir_all(&allocation).unwrap();
21170 let state_path = root.join("node-state.json");
21171 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21172 let lease = install_managed_lease(&shared);
21173 let request = managed_v2_request("managed-v2-reopen");
21174 let session = SessionAddress {
21175 workspace_id: lease.workspace_id.clone(),
21176 session: SessionKey {
21177 instance_id: AgentInstanceId(91),
21178 generation: SessionGeneration(1),
21179 },
21180 };
21181 let snapshot = {
21182 let mut registry = shared
21183 .managed_worktrees
21184 .lock()
21185 .unwrap_or_else(|poisoned| poisoned.into_inner());
21186 let current = registry.get_mut(&lease.lease_id).unwrap();
21187 current.state = ManagedWorktreeLeaseState::InUse;
21188 current.session_holders.push(ManagedWorktreeSessionHolder {
21189 incarnation_id: shared.incarnation_id,
21190 instance_id: session.session.instance_id,
21191 generation: session.session.generation,
21192 });
21193 current.snapshot()
21194 };
21195 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
21196 let receipt = managed_spawn_receipt(
21197 &resolved,
21198 shared.incarnation_id,
21199 session,
21200 snapshot,
21201 None,
21202 None,
21203 None,
21204 );
21205 assert!(matches!(
21206 shared
21207 .acquire_durable_managed_spawn_v2(
21208 &request,
21209 managed_spawn_request_digest_v2(&request).unwrap(),
21210 )
21211 .unwrap(),
21212 DurableManagedSpawnReplayDecision::Reserved,
21213 ));
21214 {
21215 let _transaction = shared
21216 .state_transaction
21217 .lock()
21218 .unwrap_or_else(|poisoned| poisoned.into_inner());
21219 shared
21220 .link_durable_managed_spawn_v2_lease_locked(
21221 &request.spawn_spec.idempotency_key,
21222 &lease.lease_id,
21223 )
21224 .unwrap();
21225 shared.persist_state_locked().unwrap();
21226 }
21227 shared
21228 .commit_durable_managed_spawn_v2(&request, receipt.clone())
21229 .unwrap();
21230 let legacy_same_key = ManagedWorktreeSpawnRequest {
21231 spawn_spec: request.spawn_spec.clone(),
21232 worktree_profile_id: request.worktree_profile_id.clone(),
21233 };
21234 shared.remember_managed_spawn_attempt(
21235 Some(&request.spawn_spec.idempotency_key),
21236 legacy_same_key.clone(),
21237 Ok(receipt.clone()),
21238 Instant::now(),
21239 );
21240 assert!(shared
21241 .replay_managed_spawn(&legacy_same_key, Instant::now())
21242 .unwrap()
21243 .is_none());
21244 drop(shared);
21245
21246 let mut loaded = session_registry::load(
21247 Some(&state_path),
21248 &NodeId::new("node-terminal-test").unwrap(),
21249 )
21250 .unwrap();
21251 assert_eq!(loaded.managed_spawn_replays.len(), 1);
21252 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21253 *reopened
21254 .managed_worktrees
21255 .lock()
21256 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21257 ManagedWorktreeRegistry::from_records(
21258 std::mem::take(&mut loaded.managed_worktrees),
21259 std::mem::take(&mut loaded.managed_worktree_tombstones),
21260 )
21261 .unwrap();
21262 *reopened
21263 .managed_spawn_replays
21264 .lock()
21265 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21266 .managed_spawn_replays
21267 .into_iter()
21268 .map(|record| (record.idempotency_key.clone(), record))
21269 .collect();
21270 let lease_count_before = reopened
21271 .managed_worktrees
21272 .lock()
21273 .unwrap_or_else(|poisoned| poisoned.into_inner())
21274 .records()
21275 .len();
21276
21277 assert_eq!(
21278 reopened
21279 .spawn_managed_worktree_v2(request.clone())
21280 .await
21281 .unwrap(),
21282 receipt,
21283 );
21284 assert_eq!(
21285 reopened
21286 .managed_worktrees
21287 .lock()
21288 .unwrap_or_else(|poisoned| poisoned.into_inner())
21289 .records()
21290 .len(),
21291 lease_count_before,
21292 );
21293 let mut changed = request;
21294 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
21295 let failure = reopened
21296 .spawn_managed_worktree_v2(changed)
21297 .await
21298 .unwrap_err();
21299 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
21300 assert_eq!(
21301 reopened
21302 .managed_worktrees
21303 .lock()
21304 .unwrap_or_else(|poisoned| poisoned.into_inner())
21305 .records()
21306 .len(),
21307 lease_count_before,
21308 );
21309 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
21310 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
21311 let failure = reopened
21312 .spawn_managed_worktree_v2(new_key)
21313 .await
21314 .unwrap_err();
21315 assert_eq!(
21316 failure.code,
21317 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21318 );
21319 assert_eq!(
21320 reopened
21321 .managed_worktrees
21322 .lock()
21323 .unwrap_or_else(|poisoned| poisoned.into_inner())
21324 .records()
21325 .len(),
21326 lease_count_before,
21327 );
21328 assert_eq!(
21329 reopened
21330 .managed_spawn_replays
21331 .lock()
21332 .unwrap_or_else(|poisoned| poisoned.into_inner())
21333 .len(),
21334 1,
21335 );
21336 std::fs::remove_dir_all(root).unwrap();
21337 }
21338
21339 #[test]
21340 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
21341 let shared = terminal_test_shared();
21342 let request = ManagedWorktreeSpawnRequest {
21343 spawn_spec: spawn_spec_fixture(),
21344 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21345 };
21346 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
21347 shared.remember_managed_spawn(
21348 request.clone(),
21349 Err(expected.clone()),
21350 Instant::now(),
21351 );
21352
21353 assert_eq!(
21354 shared
21355 .replay_managed_spawn(&request, Instant::now())
21356 .unwrap()
21357 .unwrap(),
21358 Err(expected),
21359 );
21360 assert!(shared
21361 .managed_spawn_replays
21362 .lock()
21363 .unwrap_or_else(|poisoned| poisoned.into_inner())
21364 .is_empty());
21365 }
21366
21367 #[tokio::test]
21368 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
21369 let shared = terminal_test_shared();
21370 let lease = install_managed_lease(&shared);
21371 let raw = shared.spawn_session(
21372 lease.workspace_id.clone(),
21373 agent("claude"),
21374 SessionMode::Pty,
21375 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
21376 None,
21377 ).await.unwrap_err();
21378 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21379 let mut spec = spawn_spec_fixture();
21380 spec.target.workspace_id = lease.workspace_id.clone();
21381 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
21382 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21383 let mut selected = spawn_spec_fixture();
21384 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
21385 selected.target.worktree_id = Some(lease.workspace_id);
21386 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
21387 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21388 assert!(shared.handle.snapshot().sessions.is_empty());
21389 }
21390
21391 #[test]
21392 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
21393 let shared = terminal_test_shared();
21394 let lease = install_managed_lease(&shared);
21395 let snapshot = snapshot_for_wire(
21396 &shared, true, true, true, false, true, true, false, false, true,
21397 );
21398 assert!(snapshot.managed_worktrees.is_empty());
21399 assert!(snapshot.workspaces.iter().all(|workspace| {
21400 workspace.managed_worktree_profiles.is_none()
21401 }));
21402 let mut reply = ResponseEnvelope {
21403 request_id: 1,
21404 result: Ok(NodeResponse::Resync {
21405 event_sequence: 1,
21406 oldest_available_sequence: 1,
21407 snapshot: shared.snapshot(),
21408 events: vec![NodeEventEnvelope {
21409 sequence: 1,
21410 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
21411 }],
21412 }),
21413 };
21414 project_response_without_managed_worktrees(&mut reply);
21415 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
21416 panic!("projection changed the response kind");
21417 };
21418 assert!(snapshot.managed_worktrees.is_empty());
21419 assert!(events.is_empty());
21420 let mut git = git_snapshot_for_parser();
21421 git.branch = Some(lease.branch.clone());
21422 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
21423 lease_id: lease.lease_id.clone(),
21424 source_workspace_id: lease.source_workspace_id.clone(),
21425 branch: lease.branch.clone(),
21426 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
21427 active_session_count: 1,
21428 managed_record_count: 0,
21429 });
21430 let mut inspection_reply = ResponseEnvelope {
21431 request_id: 2,
21432 result: Ok(NodeResponse::WorkspaceInspected {
21433 inspection: WorkspaceInspection {
21434 workspace_id: lease.workspace_id.clone(),
21435 entries: Vec::new(),
21436 tree_truncated: false,
21437 git,
21438 truncation: None,
21439 },
21440 }),
21441 };
21442 project_response_without_managed_worktrees(&mut inspection_reply);
21443 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
21444 panic!("projection changed the inspection response kind");
21445 };
21446 assert!(inspection.git.managed_worktree.is_none());
21447 let failure = managed_git_worktree_failure(GitWorktreeError {
21448 kind: GitWorktreeErrorKind::Failed,
21449 message: r"git failed at C:\private\managed-target".to_owned(),
21450 });
21451 assert!(!failure.message.contains("private"));
21452 assert!(!failure.message.contains("managed-target"));
21453 }
21454
21455 #[tokio::test]
21456 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
21457 let root = temporary_workspace_root("managed-restart");
21458 let source = root.join("source");
21459 let allocation = root.join("allocation");
21460 std::fs::create_dir_all(&source).unwrap();
21461 std::fs::create_dir_all(&allocation).unwrap();
21462 run_test_git(&source, &["init"]);
21463 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21464 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21465 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21466 run_test_git(&source, &["add", "seed.txt"]);
21467 run_test_git(&source, &["commit", "-m", "seed"]);
21468 let profile = ManagedWorktreeProfile::new(
21469 WorktreeProfileId::new("default").unwrap(),
21470 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21471 &allocation,
21472 "gate4agent",
21473 "HEAD",
21474 ManagedWorktreeRetention::Retain,
21475 ).unwrap();
21476 let workspace = WorkspaceConfig::new(
21477 WorkspaceId::new("primary").unwrap(),
21478 &source,
21479 ).unwrap()
21480 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21481 .with_managed_worktree_profile(profile.clone()).unwrap();
21482 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
21483 let source_text = workspace.canonical_root().to_owned();
21484 let target_text = target.to_string_lossy().into_owned();
21485 let created = create_git_worktree(
21486 &source_text,
21487 &target_text,
21488 "gate4agent/mw-restart",
21489 Some("HEAD"),
21490 ).await.unwrap();
21491 let lease = ManagedWorktreeLeaseRecord {
21492 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
21493 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21494 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
21495 profile_id: profile.profile_id().clone(),
21496 profile_revision: profile.revision().clone(),
21497 target_root: created.path.clone(),
21498 branch: "gate4agent/mw-restart".to_owned(),
21499 base_commit: created.head.clone(),
21500 expected_head: None,
21501 retention: ManagedWorktreeRetention::Retain,
21502 state: ManagedWorktreeLeaseState::Allocating,
21503 session_holders: Vec::new(),
21504 record_holders: Vec::new(),
21505 cleanup_failure: None,
21506 created_at_unix_ms: 1,
21507 updated_at_unix_ms: 1,
21508 };
21509 let catalog = active_registry().unwrap();
21510 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21511 let shared = NodeShared::new_with_incarnation(
21512 handle,
21513 "fixture-token".to_owned(),
21514 NodeId::new("node-managed-restart").unwrap(),
21515 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
21516 vec![workspace],
21517 vec![agent("claude")],
21518 ProviderRuntimeStatuses::default(),
21519 None,
21520 Vec::new(),
21521 Vec::new(),
21522 SpawnProfileRegistry::default(),
21523 None,
21524 None,
21525 None,
21526 None,
21527 Vec::new(),
21528 vec![lease.clone()],
21529 Vec::new(),
21530 Vec::new(),
21531 None,
21532 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
21533 );
21534 shared.reconcile_managed_worktrees().await;
21535 let retained = shared.managed_worktrees.lock()
21536 .unwrap_or_else(|poisoned| poisoned.into_inner())
21537 .get(&lease.lease_id).unwrap().clone();
21538 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
21539 assert!(shared.workspaces.read()
21540 .unwrap_or_else(|poisoned| poisoned.into_inner())
21541 .contains_key(&lease.workspace_id));
21542 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
21543 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
21544 assert!(!Path::new(&created.path).exists());
21545 std::fs::remove_dir_all(root).unwrap();
21546 }
21547
21548 fn run_test_git(root: &Path, arguments: &[&str]) {
21549 let output = std::process::Command::new("git")
21550 .arg("-C").arg(root).args(arguments).output().unwrap();
21551 assert!(
21552 output.status.success(),
21553 "git {:?} failed: {}",
21554 arguments,
21555 String::from_utf8_lossy(&output.stderr),
21556 );
21557 }
21558
21559 #[tokio::test]
21560 async fn spawn_spec_validation_fails_before_session_mutation() {
21561 let shared = terminal_test_shared();
21562 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21563 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
21564 }));
21565 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21566 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
21567 }));
21568 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21569 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
21570 }));
21571
21572 let mut target_mismatch = spawn_spec_fixture();
21573 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
21574 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
21575 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
21576
21577 let mut unknown_profile = spawn_spec_fixture();
21578 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
21579 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
21580 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
21581 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
21582
21583 let mut revision_mismatch = spawn_spec_fixture();
21584 revision_mismatch.idempotency_key =
21585 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
21586 revision_mismatch.expected_profile_revision =
21587 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
21588 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
21589 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
21590
21591 let mut unsupported_materializer = spawn_spec_fixture();
21592 unsupported_materializer.idempotency_key =
21593 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
21594 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
21595 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
21596 };
21597 let failure = shared
21598 .spawn_from_spec(unsupported_materializer)
21599 .await
21600 .unwrap_err();
21601 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
21602
21603 let failure = shared
21604 .spawn_session_with_deadline(
21605 WorkspaceId::new("primary").unwrap(),
21606 agent("claude"),
21607 SessionMode::Pty,
21608 gate4agent_types::TerminalSize {
21609 rows: 24,
21610 columns: 80,
21611 },
21612 None,
21613 None,
21614 None,
21615 None,
21616 None,
21617 None,
21618 SpawnRecordPolicy::ProviderIdentityOnly,
21619 Some(Instant::now()),
21620 &[],
21621 None,
21622 ApprovalLevel::default(),
21623 None,
21624 None,
21625 )
21626 .await
21627 .unwrap_err();
21628 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
21629 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
21630 assert!(shared.handle.snapshot().sessions.is_empty());
21631 }
21632
21633 #[test]
21634 fn worktree_selection_requires_its_separate_negotiated_capability() {
21635 let mut spec = spawn_spec_fixture();
21636 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
21637 let request = NodeRequest::SpawnSpec { spec };
21638 let spawn_spec_capability =
21639 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
21640 assert!(request_uses_unnegotiated_capability(
21641 &request,
21642 &[spawn_spec_capability.clone()],
21643 ));
21644 let worktree_capability =
21645 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
21646 assert!(request_uses_unnegotiated_capability(
21647 &request,
21648 &[spawn_spec_capability.clone(), worktree_capability.clone()],
21649 ));
21650 let profile_revision_capability =
21651 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
21652 assert!(request_uses_unnegotiated_capability(
21653 &request,
21654 &[
21655 spawn_spec_capability.clone(),
21656 worktree_capability.clone(),
21657 profile_revision_capability.clone(),
21658 ],
21659 ));
21660 let bundle_materialization_capability =
21667 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
21668 assert!(request_uses_unnegotiated_capability(
21669 &request,
21670 &[
21671 spawn_spec_capability.clone(),
21672 worktree_capability.clone(),
21673 profile_revision_capability.clone(),
21674 bundle_materialization_capability.clone(),
21675 ],
21676 ));
21677 let history_context_pack_capability =
21678 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
21679 assert!(!request_uses_unnegotiated_capability(
21680 &request,
21681 &[
21682 spawn_spec_capability,
21683 worktree_capability,
21684 profile_revision_capability,
21685 bundle_materialization_capability,
21686 history_context_pack_capability,
21687 ],
21688 ));
21689 }
21690
21691 #[tokio::test]
21692 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
21693 let mut shared = terminal_test_shared();
21694 let spec = spawn_spec_fixture();
21695 let receipt = shared
21696 .resolve_spawn_spec(&spec)
21697 .unwrap()
21698 .receipt(shared.incarnation_id, terminal_address(1));
21699 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
21700
21701 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
21702 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
21703 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
21704
21705 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
21706
21707 let mut revision_conflicting = spec.clone();
21708 revision_conflicting.expected_profile_revision =
21709 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
21710 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
21711 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
21712
21713 let mut stale_cache_miss = spec.clone();
21714 stale_cache_miss.idempotency_key =
21715 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
21716 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
21717 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
21718
21719 let mut conflicting = spec;
21720 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
21721 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
21722 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
21723 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
21724 assert!(shared.handle.snapshot().sessions.is_empty());
21725 }
21726
21727 #[tokio::test]
21728 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
21729 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
21730 let mut spec = spawn_spec_fixture();
21731 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
21732 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
21733 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
21734
21735 let failure = shared
21736 .create_worktree(
21737 WorkspaceId::new("primary").unwrap(),
21738 WorkspaceId::new("new-worktree").unwrap(),
21739 "not-an-absolute-path".to_owned(),
21740 "invalid branch".to_owned(),
21741 None,
21742 )
21743 .await
21744 .unwrap_err();
21745 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
21746
21747 let failure = shared
21748 .remove_worktree(
21749 WorkspaceId::new("primary").unwrap(),
21750 "not-an-absolute-path".to_owned(),
21751 )
21752 .await
21753 .unwrap_err();
21754 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
21755 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
21756 assert!(shared.handle.snapshot().sessions.is_empty());
21757 }
21758
21759 #[tokio::test]
21760 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
21761 let root = temporary_workspace_root("manual-worktree-compensation");
21762 let source = root.join("source");
21763 let target = root.join("target");
21764 let invalid_state_parent = root.join("state-parent-is-a-file");
21765 let invalid_state_path = invalid_state_parent.join("state.json");
21766 std::fs::create_dir_all(&source).unwrap();
21767 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
21768 run_test_git(&source, &["init"]);
21769 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21770 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21771 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21772 run_test_git(&source, &["add", "seed.txt"]);
21773 run_test_git(&source, &["commit", "-m", "seed"]);
21774
21775 let catalog = active_registry().unwrap();
21776 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21777 let workspace = WorkspaceConfig::new(
21778 WorkspaceId::new("primary").unwrap(),
21779 &source,
21780 ).unwrap();
21781 let mut shared = NodeShared::new(
21782 handle,
21783 "fixture-token".to_owned(),
21784 NodeId::new("node-worktree-compensation").unwrap(),
21785 vec![workspace],
21786 vec![agent("claude")],
21787 );
21788 shared.state_path = Some(invalid_state_path);
21789
21790 let error = shared.create_worktree(
21791 WorkspaceId::new("primary").unwrap(),
21792 WorkspaceId::new("compensated").unwrap(),
21793 target.to_string_lossy().into_owned(),
21794 "gate4agent/compensated".to_owned(),
21795 Some("HEAD".to_owned()),
21796 ).await.unwrap_err();
21797
21798 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21799 assert!(!target.exists());
21800 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
21801 .iter()
21802 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
21803 std::fs::remove_dir_all(root).unwrap();
21804 }
21805
21806 #[test]
21807 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
21808 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
21809 NativeGitWorktreeSnapshot {
21810 path: path.to_owned(),
21811 head: "0123456789abcdef".to_owned(),
21812 branch: Some("refs/heads/test".to_owned()),
21813 is_bare,
21814 is_main,
21815 locked: false,
21816 lock_reason: None,
21817 prunable: false,
21818 prunable_reason: None,
21819 workspace_id: None,
21820 }
21821 }
21822
21823 let source = r"C:\repo";
21824 let selected = r"C:\repo-worktree";
21825 let listed = vec![
21826 worktree(source, true, false),
21827 worktree(selected, false, false),
21828 ];
21829 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
21830
21831 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
21832 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
21833
21834 let bare = vec![
21835 worktree(source, true, false),
21836 worktree(selected, false, true),
21837 ];
21838 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
21839 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
21840
21841 let foreign_failure =
21842 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
21843 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
21844 }
21845
21846 fn terminal_frame(sequence: u64) -> TerminalFrame {
21847 TerminalFrame {
21848 sequence,
21849 size: gate4agent_types::TerminalSize {
21850 rows: 24,
21851 columns: 80,
21852 },
21853 cursor_row: 2,
21854 cursor_column: 3,
21855 contents: format!("frame-{sequence}"),
21856 formatted: vec![1, 2, 3],
21857 scrollback_formatted: Vec::new(),
21858 alternate_screen: false,
21859 mouse_protocol_enabled: false,
21860 mouse_protocol_encoding: Default::default(),
21861 produced_at_unix_ms: 0,
21862 screen_state: gate4agent_types::PtyScreenState::default(),
21863 bracketed_paste: None,
21864 }
21865 }
21866
21867 #[test]
21868 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
21869 let shared = terminal_test_shared();
21870 let mut receiver = shared.terminal_event_tx.subscribe();
21871 let original = terminal_address(1);
21872
21873 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
21874 let first = receiver.try_recv().unwrap();
21875 assert_eq!(first.len(), 1);
21876 assert!(matches!(
21877 &first[0].event,
21878 NodeEvent::TerminalFrame { address, frame }
21879 if address == &original && frame.sequence == 7
21880 ));
21881
21882 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
21883 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
21884 assert!(matches!(
21885 receiver.try_recv(),
21886 Err(broadcast::error::TryRecvError::Empty)
21887 ));
21888
21889 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
21890 let advanced = receiver.try_recv().unwrap();
21891 assert_eq!(advanced[0].sequence, 2);
21892
21893 let resumed = terminal_address(2);
21894 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
21895 let reset = receiver.try_recv().unwrap();
21896 assert_eq!(reset[0].sequence, 3);
21897 assert!(matches!(
21898 &reset[0].event,
21899 NodeEvent::TerminalFrame { address, frame }
21900 if address == &resumed && frame.sequence == 1
21901 ));
21902 shared.clear_terminal_frame_watermark(&resumed);
21903 assert!(!shared
21904 .terminal_frame_watermarks
21905 .lock()
21906 .unwrap_or_else(|poisoned| poisoned.into_inner())
21907 .contains_key(&resumed.session.instance_id));
21908 }
21909
21910 #[test]
21911 fn terminal_frame_batches_replace_without_entering_node_event_history() {
21912 let shared = terminal_test_shared();
21913 let mut receiver = shared.terminal_event_tx.subscribe();
21914 let address = terminal_address(1);
21915 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
21916
21917 for sequence in 1..=count {
21918 shared.publish_terminal_frame_candidates(vec![(
21919 address.clone(),
21920 terminal_frame(sequence),
21921 )]);
21922 }
21923
21924 let history = shared
21925 .history
21926 .lock()
21927 .unwrap_or_else(|poisoned| poisoned.into_inner());
21928 assert!(history.events.is_empty());
21929 assert_eq!(history.last_sequence, count);
21930 assert_eq!(history.replay_floor_sequence, 1);
21931 drop(history);
21932 let NodeResponse::Resync {
21933 event_sequence,
21934 oldest_available_sequence,
21935 events,
21936 ..
21937 } = shared.resync(0) else {
21938 unreachable!("resync helper returned another response")
21939 };
21940 assert_eq!(event_sequence, count);
21941 assert_eq!(oldest_available_sequence, 1);
21942 assert!(events.is_empty());
21943 assert!(matches!(
21944 resync_required_event(&shared).event,
21945 NodeEvent::ResyncRequired {
21946 oldest_available_sequence: 1,
21947 }
21948 ));
21949 assert!(matches!(
21950 receiver.try_recv(),
21951 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
21952 ));
21953 let latest = receiver.try_recv().unwrap();
21954 assert_eq!(latest.len(), 1);
21955 assert_eq!(latest[0].sequence, count);
21956 }
21957
21958 #[test]
21959 fn real_durable_eviction_advances_authoritative_replay_floor() {
21960 let shared = terminal_test_shared();
21961 for _ in 0..=NODE_EVENT_HISTORY_MAX {
21962 shared.publish(NodeEvent::ControllerChanged { controller: None });
21963 }
21964
21965 let NodeResponse::Resync {
21966 event_sequence,
21967 oldest_available_sequence,
21968 events,
21969 ..
21970 } = shared.resync(0) else {
21971 unreachable!("resync helper returned another response")
21972 };
21973 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
21974 assert_eq!(oldest_available_sequence, 2);
21975 assert_eq!(events.first().map(|event| event.sequence), Some(2));
21976 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
21977 assert!(matches!(
21978 resync_required_event(&shared).event,
21979 NodeEvent::ResyncRequired {
21980 oldest_available_sequence: 2,
21981 }
21982 ));
21983 }
21984
21985 #[test]
21986 fn legacy_terminal_frame_connection_has_no_live_subscription() {
21987 let shared = terminal_test_shared();
21988 let without_capability = terminal_event_subscription(&shared, false);
21989 let with_capability = terminal_event_subscription(&shared, true);
21990 assert!(without_capability.is_none());
21991 assert!(with_capability.is_some());
21992 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21993 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
21994 }));
21995 }
21996
21997 #[test]
21998 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
21999 let mut pending = Vec::new();
22000 let mut event_sequence = 0_u64;
22001 for frame_sequence in 1..=4 {
22002 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
22003 event_sequence += 1;
22004 let mut address = terminal_address(1);
22005 address.session.instance_id = AgentInstanceId(instance);
22006 queue_connection_event(
22007 &mut pending,
22008 NodeEventEnvelope {
22009 sequence: event_sequence,
22010 event: NodeEvent::TerminalFrame {
22011 address,
22012 frame: terminal_frame(frame_sequence),
22013 },
22014 },
22015 0,
22016 );
22017 }
22018 }
22019 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
22020 pending.sort_unstable_by_key(|event| event.sequence);
22021
22022 let mut uninterrupted = pending.clone();
22023 let first_burst = connection_event_burst_len(uninterrupted.len());
22024 uninterrupted.drain(..first_burst);
22025 let second_burst = connection_event_burst_len(uninterrupted.len());
22026 uninterrupted.drain(..second_burst);
22027 assert!(uninterrupted.is_empty());
22028
22029 let (frame_tx, mut frame_rx) =
22030 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
22031 frame_tx
22032 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
22033 request_id: 77,
22034 request: NodeRequest::Snapshot,
22035 })))
22036 .unwrap();
22037 let burst_len = connection_event_burst_len(pending.len());
22038 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
22039 pending.drain(..burst_len);
22040 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
22041 assert!(matches!(
22042 frame_rx.try_recv(),
22043 Ok(Ok(ClientFrame::Request(RequestEnvelope {
22044 request_id: 77,
22045 request: NodeRequest::Snapshot,
22046 })))
22047 ));
22048 }
22049
22050 #[test]
22051 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
22052 let mut pending = Vec::new();
22053 for sequence in [7_u64, 8] {
22054 let mut address = terminal_address(1);
22055 address.session.instance_id = AgentInstanceId(sequence);
22056 queue_connection_event(
22057 &mut pending,
22058 NodeEventEnvelope {
22059 sequence,
22060 event: NodeEvent::TerminalFrame {
22061 address,
22062 frame: terminal_frame(sequence),
22063 },
22064 },
22065 0,
22066 );
22067 }
22068 let mut discard_through = 0;
22069 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
22070 assert!(pending.is_empty());
22071
22072 let replayed = NodeEventEnvelope {
22073 sequence: 8,
22074 event: NodeEvent::TerminalFrame {
22075 address: terminal_address(1),
22076 frame: terminal_frame(8),
22077 },
22078 };
22079 let advanced = NodeEventEnvelope {
22080 sequence: 9,
22081 event: NodeEvent::TerminalFrame {
22082 address: terminal_address(2),
22083 frame: terminal_frame(1),
22084 },
22085 };
22086 queue_connection_event_batch(
22087 &mut pending,
22088 &[replayed, advanced.clone()],
22089 discard_through,
22090 );
22091 assert_eq!(pending, vec![advanced]);
22092 }
22093
22094 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
22095 ManagedSessionRecord {
22096 record_id: SessionRecordId::new(record_id).unwrap(),
22097 display_name: format!("{provider} record"),
22098 provider: agent(provider),
22099 mode: SessionMode::Pty,
22100 state: ManagedSessionState::Unavailable,
22101 workspace_id: WorkspaceId::new("primary").unwrap(),
22102 canonical_root: opaque_windows_path(
22103 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22104 ),
22105 provider_session: None,
22106 active_session: None,
22107 environment_profile: None,
22108 bundle: None,
22109 context_id: None,
22110 context: None,
22111 exported_context: None,
22112 task_binding: None,
22113 created_at_unix_ms: 1,
22114 updated_at_unix_ms: 1,
22115 last_error: None,
22116 }
22117 }
22118
22119 fn retention_test_record(
22120 workspace_id: &str,
22121 record_id: &str,
22122 state: ManagedSessionState,
22123 updated_at_unix_ms: u64,
22124 ) -> ManagedSessionRecord {
22125 ManagedSessionRecord {
22126 record_id: SessionRecordId::new(record_id).unwrap(),
22127 display_name: format!("{record_id} display"),
22128 provider: agent("claude"),
22129 mode: SessionMode::Pty,
22130 state,
22131 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
22132 canonical_root: opaque_windows_path(
22133 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22134 ),
22135 provider_session: None,
22136 active_session: None,
22137 environment_profile: None,
22138 bundle: None,
22139 context_id: None,
22140 context: None,
22141 exported_context: None,
22142 task_binding: None,
22143 created_at_unix_ms: updated_at_unix_ms,
22144 updated_at_unix_ms,
22145 last_error: None,
22146 }
22147 }
22148
22149 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
22157 vec![
22158 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
22159 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
22160 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
22161 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
22162 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
22163 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
22164 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
22165 retention_test_record(
22166 "workspace-a",
22167 "decoy-pending",
22168 ManagedSessionState::IdentityPending,
22169 now - 500_000,
22170 ),
22171 retention_test_record(
22172 "workspace-a",
22173 "decoy-dormant",
22174 ManagedSessionState::Dormant,
22175 now - 500_000,
22176 ),
22177 ]
22178 }
22179
22180 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
22181 let root = temporary_workspace_root("session-record-retention");
22182 std::fs::create_dir_all(&root).unwrap();
22183 let catalog = active_registry().unwrap();
22184 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22185 let workspace_a =
22186 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
22187 let workspace_b =
22188 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
22189 let mut shared = NodeShared::new(
22190 handle,
22191 "fixture-token".to_owned(),
22192 NodeId::new("node-retention-test").unwrap(),
22193 vec![workspace_a, workspace_b],
22194 vec![agent("claude")],
22195 );
22196 shared.state_path = Some(root.join("node-state-v10.json"));
22197 let now = unix_time_ms();
22198 for record in retention_fixture_records(now) {
22199 shared.insert_record(record).unwrap();
22200 }
22201 shared
22202 .persist_state_locked()
22203 .expect("fixture-only, empty-history persist against a fresh temp state path");
22204 (shared, runtime, root, now)
22205 }
22206
22207 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22208 shared
22209 .history
22210 .lock()
22211 .unwrap_or_else(|poisoned| poisoned.into_inner())
22212 .events
22213 .iter()
22214 .filter_map(|envelope| match &envelope.event {
22215 NodeEvent::SessionRecordRemoved { record_id } => {
22216 Some(record_id.as_str().to_owned())
22217 }
22218 _ => None,
22219 })
22220 .collect()
22221 }
22222
22223 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22224 shared
22225 .session_records
22226 .lock()
22227 .unwrap_or_else(|poisoned| poisoned.into_inner())
22228 .records
22229 .keys()
22230 .map(|record_id| record_id.as_str().to_owned())
22231 .collect()
22232 }
22233
22234 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
22235 values.iter().map(|value| (*value).to_owned()).collect()
22236 }
22237
22238 #[tokio::test]
22239 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
22240 let (mut shared, runtime, root, _now) = retention_fixture_shared();
22241 shared.session_record_retention = SessionRecordRetentionConfig {
22242 age_ms: 0,
22243 keep_per_workspace: 1,
22244 };
22245 shared.retire_unavailable_session_records().await;
22246
22247 assert_eq!(
22248 retention_remaining_record_ids(&shared),
22249 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22250 );
22251 assert_eq!(
22252 retention_removed_record_ids(&shared),
22253 owned(&["a1", "a2", "b1", "b2"]),
22254 );
22255 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
22256
22257 drop(shared);
22258 drop(runtime);
22259 std::fs::remove_dir_all(root).unwrap();
22260 }
22261
22262 #[tokio::test]
22268 async fn session_record_retention_both_set_retires_only_the_intersection() {
22269 let (mut shared, runtime, root, _now) = retention_fixture_shared();
22270 shared.session_record_retention = SessionRecordRetentionConfig {
22271 age_ms: 250_000,
22272 keep_per_workspace: 1,
22273 };
22274 shared.retire_unavailable_session_records().await;
22275
22276 assert_eq!(
22277 retention_remaining_record_ids(&shared),
22278 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22279 );
22280 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
22281 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
22282
22283 drop(shared);
22284 drop(runtime);
22285 std::fs::remove_dir_all(root).unwrap();
22286 }
22287
22288 #[tokio::test]
22289 async fn session_record_retention_both_disabled_is_a_no_op() {
22290 let (mut shared, runtime, root, _now) = retention_fixture_shared();
22291 shared.session_record_retention = SessionRecordRetentionConfig::default();
22292 shared.retire_unavailable_session_records().await;
22293
22294 assert_eq!(
22295 retention_remaining_record_ids(&shared),
22296 owned(&[
22297 "a1", "a2", "a3", "b1", "b2", "b3",
22298 "decoy-live", "decoy-pending", "decoy-dormant",
22299 ]),
22300 );
22301 assert!(retention_removed_record_ids(&shared).is_empty());
22302 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
22303
22304 drop(shared);
22305 drop(runtime);
22306 std::fs::remove_dir_all(root).unwrap();
22307 }
22308
22309 #[test]
22310 fn session_record_context_export_selects_one_exact_history_identity() {
22311 let candidates = vec![
22312 HistoryCandidateSummary {
22313 id: "candidate-other".to_owned(),
22314 session_id_hint: "provider-other".to_owned(),
22315 modified_at_unix_ms: Some(1),
22316 },
22317 HistoryCandidateSummary {
22318 id: "candidate-exact".to_owned(),
22319 session_id_hint: "provider-exact".to_owned(),
22320 modified_at_unix_ms: Some(2),
22321 },
22322 ];
22323 assert_eq!(
22324 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
22325 "candidate-exact",
22326 );
22327 }
22328
22329 #[test]
22330 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
22331 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
22332 id: id.to_owned(),
22333 session_id_hint: hint.to_owned(),
22334 modified_at_unix_ms: None,
22335 };
22336 let missing = select_history_candidate(
22337 &[candidate("candidate-other", "provider-other")],
22338 Some("provider-exact"),
22339 )
22340 .unwrap_err();
22341 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
22342 let ambiguous = select_history_candidate(
22343 &[
22344 candidate("candidate-a", "provider-exact"),
22345 candidate("candidate-b", "provider-exact"),
22346 ],
22347 Some("provider-exact"),
22348 )
22349 .unwrap_err();
22350 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
22351 }
22352
22353 #[test]
22361 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
22362 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
22363 id: id.to_owned(),
22364 session_id_hint: hint.to_owned(),
22365 modified_at_unix_ms: None,
22366 };
22367 assert_eq!(
22368 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
22369 .unwrap(),
22370 "candidate-sole",
22371 );
22372 let missing = select_history_candidate(&[], None).unwrap_err();
22373 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
22374 let ambiguous = select_history_candidate(
22375 &[
22376 candidate("candidate-a", "sidecar-session-a"),
22377 candidate("candidate-b", "sidecar-session-b"),
22378 ],
22379 None,
22380 )
22381 .unwrap_err();
22382 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
22383 }
22384
22385 #[test]
22386 fn session_record_context_export_revalidation_rejects_record_change() {
22387 let session = terminal_address(1);
22388 let identity = ProviderSessionIdentity {
22389 key: ProviderSessionKey::SessionId,
22390 id: "provider-exact".to_owned(),
22391 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
22392 };
22393 let mut expected = record("claude", "record-context-export");
22394 expected.state = ManagedSessionState::Live;
22395 expected.provider_session = Some(identity.clone());
22396 expected.active_session = Some(session.clone());
22397 let binding = SessionBinding {
22398 workspace_id: session.workspace_id.clone(),
22399 generation: session.session.generation,
22400 runtime_policy: ProviderRuntimePolicy::raw_pty(),
22401 pending_resume: None,
22402 record_id: Some(expected.record_id.clone()),
22403 managed_worktree_lease_id: None,
22404 environment_profile: None,
22405 bundle: None,
22406 context: None,
22407 materialization_id: None,
22408 };
22409 let current_root = windows_path_text(&expected.canonical_root).to_owned();
22410 assert!(session_record_context_export_binding_is_exact(
22411 &expected,
22412 &expected,
22413 Some(&identity),
22414 &session,
22415 &binding,
22416 &expected.provider,
22417 ¤t_root,
22418 false,
22419 ));
22420 let mut changed = expected.clone();
22421 changed.updated_at_unix_ms += 1;
22422 assert!(!session_record_context_export_binding_is_exact(
22423 &expected,
22424 &changed,
22425 Some(&identity),
22426 &session,
22427 &binding,
22428 &expected.provider,
22429 ¤t_root,
22430 false,
22431 ));
22432 assert!(!session_record_context_export_binding_is_exact(
22437 &expected,
22438 &changed,
22439 Some(&identity),
22440 &session,
22441 &binding,
22442 &expected.provider,
22443 ¤t_root,
22444 true,
22445 ));
22446 let mut detached = expected.clone();
22447 detached.state = ManagedSessionState::Dormant;
22448 detached.active_session = None;
22449 detached.updated_at_unix_ms += 1;
22450 assert!(!session_record_context_export_binding_is_exact(
22451 &expected,
22452 &detached,
22453 Some(&identity),
22454 &session,
22455 &binding,
22456 &expected.provider,
22457 ¤t_root,
22458 false,
22459 ));
22460 assert!(session_record_context_export_binding_is_exact(
22461 &expected,
22462 &detached,
22463 Some(&identity),
22464 &session,
22465 &binding,
22466 &expected.provider,
22467 ¤t_root,
22468 true,
22469 ));
22470 }
22471
22472 #[test]
22479 fn session_record_context_export_binding_is_exact_without_identity() {
22480 let session = terminal_address(1);
22481 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
22482 expected.state = ManagedSessionState::Live;
22483 expected.active_session = Some(session.clone());
22484 let binding = SessionBinding {
22485 workspace_id: session.workspace_id.clone(),
22486 generation: session.session.generation,
22487 runtime_policy: ProviderRuntimePolicy::raw_pty(),
22488 pending_resume: None,
22489 record_id: Some(expected.record_id.clone()),
22490 managed_worktree_lease_id: None,
22491 environment_profile: None,
22492 bundle: None,
22493 context: None,
22494 materialization_id: None,
22495 };
22496 let current_root = windows_path_text(&expected.canonical_root).to_owned();
22497 assert!(session_record_context_export_binding_is_exact(
22498 &expected,
22499 &expected,
22500 None,
22501 &session,
22502 &binding,
22503 &expected.provider,
22504 ¤t_root,
22505 false,
22506 ));
22507 let mut drifted = expected.clone();
22511 drifted.provider_session = Some(ProviderSessionIdentity {
22512 key: ProviderSessionKey::SessionId,
22513 id: "unexpected-identity".to_owned(),
22514 transcript_path: None,
22515 });
22516 assert!(!session_record_context_export_binding_is_exact(
22517 &expected,
22518 &drifted,
22519 None,
22520 &session,
22521 &binding,
22522 &expected.provider,
22523 ¤t_root,
22524 false,
22525 ));
22526 }
22527
22528 #[test]
22529 fn session_record_context_export_preflight_requires_live_usable_source() {
22530 let mut source = record("claude", "record-context-export-preflight");
22531 source.state = ManagedSessionState::Live;
22532 assert!(session_record_context_export_source_is_usable(
22533 &source,
22534 &SessionStatus::Running,
22535 false,
22536 ));
22537 assert!(session_record_context_export_source_is_usable(
22538 &source,
22539 &SessionStatus::Exited { exit_code: Some(0) },
22540 false,
22541 ));
22542
22543 source.state = ManagedSessionState::Dormant;
22544 assert!(!session_record_context_export_source_is_usable(
22545 &source,
22546 &SessionStatus::Running,
22547 false,
22548 ));
22549 assert!(session_record_context_export_source_is_usable(
22553 &source,
22554 &SessionStatus::Exited { exit_code: Some(0) },
22555 true,
22556 ));
22557 assert!(!session_record_context_export_source_is_usable(
22558 &source,
22559 &SessionStatus::Exited { exit_code: Some(0) },
22560 false,
22561 ));
22562 source.state = ManagedSessionState::Live;
22563 assert!(!session_record_context_export_source_is_usable(
22564 &source,
22565 &SessionStatus::Starting,
22566 false,
22567 ));
22568 }
22569
22570 #[test]
22571 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
22572 let catalog = active_registry().unwrap();
22573 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22574 let workspace = WorkspaceConfig::new(
22575 WorkspaceId::new("primary").unwrap(),
22576 std::env::current_dir().unwrap(),
22577 )
22578 .unwrap();
22579 let shared = NodeShared::new(
22580 handle,
22581 "fixture-token".to_owned(),
22582 NodeId::new("node-context-export-conflict").unwrap(),
22583 vec![workspace],
22584 vec![agent("claude")],
22585 );
22586 let history = HistorySessionRecord {
22587 session_id: "provider-exact".to_owned(),
22588 title: None,
22589 cwd: None,
22590 model: None,
22591 message_count: 1,
22592 completed_turn_count: None,
22593 total_tokens: 0,
22594 messages: vec![gate4agent_types::HistoryMessageRecord {
22595 role: gate4agent_types::HistoryMessageRole::User,
22596 text: "bounded context".to_owned(),
22597 }],
22598 };
22599 let pack = NodeContextPack::export(
22600 ContextPackLineageReceipt {
22601 source_node_id: shared.node_id.clone(),
22602 source_session: terminal_address(1),
22603 source_provider: agent("claude"),
22604 },
22605 &history,
22606 )
22607 .unwrap();
22608 let context_id = pack.receipt().id.clone();
22609 let session = terminal_address(1);
22610 let identity = ProviderSessionIdentity {
22611 key: ProviderSessionKey::SessionId,
22612 id: history.session_id.clone(),
22613 transcript_path: None,
22614 };
22615 let mut expected = record("claude", "record-context-export-final-conflict");
22616 expected.state = ManagedSessionState::Live;
22617 expected.provider_session = Some(identity.clone());
22618 expected.active_session = Some(session.clone());
22619 let mut changed = expected.clone();
22620 changed.updated_at_unix_ms += 1;
22621 shared.insert_record(changed).unwrap();
22622
22623 let error = shared
22624 .commit_context_pack_for_session_record(
22625 &expected,
22626 Some(&identity),
22627 &session,
22628 "candidate-exact",
22629 &history,
22630 pack,
22631 false,
22632 )
22633 .unwrap_err();
22634
22635 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
22636 assert!(shared
22637 .context_catalog
22638 .read()
22639 .unwrap_or_else(|poisoned| poisoned.into_inner())
22640 .get(&context_id)
22641 .is_none());
22642 }
22643
22644 #[tokio::test]
22645 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
22646 let root = temporary_workspace_root("session-task-binding");
22647 std::fs::create_dir_all(&root).unwrap();
22648 let state_path = root.join("node-state.json");
22649 let catalog = active_registry().unwrap();
22650 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22651 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
22652 let node_id = NodeId::new("node-1").unwrap();
22653 let mut shared = NodeShared::new(
22654 handle,
22655 "fixture-token".to_owned(),
22656 node_id.clone(),
22657 vec![workspace],
22658 vec![agent("claude")],
22659 );
22660 shared.state_path = Some(state_path.clone());
22661 let record = record("claude", "sr-task");
22662 let record_id = record.record_id.clone();
22663 shared.insert_record(record).unwrap();
22664 shared.persist_state().unwrap();
22665 shared
22666 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
22667 .unwrap();
22668
22669 let minted = process_request_inner(
22670 &shared,
22671 77,
22672 ClientRole::Operator,
22673 NodeRequest::SetSessionTask {
22674 record_id: record_id.clone(),
22675 expected_revision: 0,
22676 target: SessionTaskTargetV1::New,
22677 },
22678 )
22679 .await
22680 .unwrap();
22681 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
22682 panic!("session task mutation returned another response");
22683 };
22684 let minted_binding = minted.task_binding.clone().unwrap();
22685 let minted_task_id = minted_binding.task_id.clone().unwrap();
22686 assert_eq!(minted_binding.revision, 1);
22687 assert!(minted_task_id.as_str().starts_with("task-"));
22688 assert_eq!(minted_task_id.as_str().len(), 29);
22689
22690 let event_count = shared.current_sequence();
22691 let repeated = process_request_inner(
22692 &shared,
22693 77,
22694 ClientRole::Operator,
22695 NodeRequest::SetSessionTask {
22696 record_id: record_id.clone(),
22697 expected_revision: 1,
22698 target: SessionTaskTargetV1::Existing {
22699 task_id: minted_task_id.clone(),
22700 },
22701 },
22702 )
22703 .await
22704 .unwrap();
22705 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
22706 assert_eq!(shared.current_sequence(), event_count);
22707
22708 let stale = process_request_inner(
22709 &shared,
22710 77,
22711 ClientRole::Operator,
22712 NodeRequest::SetSessionTask {
22713 record_id: record_id.clone(),
22714 expected_revision: 0,
22715 target: SessionTaskTargetV1::Existing {
22716 task_id: minted_task_id,
22717 },
22718 },
22719 )
22720 .await
22721 .unwrap_err();
22722 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
22723
22724 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
22725 let assigned = process_request_inner(
22726 &shared,
22727 77,
22728 ClientRole::Operator,
22729 NodeRequest::SetSessionTask {
22730 record_id: record_id.clone(),
22731 expected_revision: 1,
22732 target: SessionTaskTargetV1::Existing {
22733 task_id: assigned_id.clone(),
22734 },
22735 },
22736 )
22737 .await
22738 .unwrap();
22739 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
22740 panic!("session task assignment returned another response");
22741 };
22742 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
22743 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
22744
22745 let cleared = process_request_inner(
22746 &shared,
22747 77,
22748 ClientRole::Operator,
22749 NodeRequest::SetSessionTask {
22750 record_id: record_id.clone(),
22751 expected_revision: 2,
22752 target: SessionTaskTargetV1::Clear,
22753 },
22754 )
22755 .await
22756 .unwrap();
22757 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
22758 panic!("session task clear returned another response");
22759 };
22760 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
22761 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
22762 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
22763 assert_eq!(
22764 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
22765 cleared.updated_at_unix_ms,
22766 );
22767
22768 let persisted = std::fs::read(&state_path).unwrap();
22769 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
22770 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
22771 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
22772 std::fs::remove_dir_all(root).unwrap();
22773 }
22774
22775 #[test]
22776 fn session_task_capability_strips_snapshots_events_and_replies() {
22777 let mut bound = record("claude", "sr-task-projection");
22778 bound.updated_at_unix_ms = 2;
22779 bound.task_binding = Some(SessionTaskBindingV1 {
22780 revision: 1,
22781 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
22782 changed_at_unix_ms: 2,
22783 });
22784 let request = NodeRequest::SetSessionTask {
22785 record_id: bound.record_id.clone(),
22786 expected_revision: 1,
22787 target: SessionTaskTargetV1::Clear,
22788 };
22789 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22790 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
22791 }));
22792 assert!(!request_uses_unnegotiated_capability(
22793 &request,
22794 &baseline_capabilities().unwrap(),
22795 ));
22796 assert!(request_uses_unnegotiated_capability(&request, &[]));
22797
22798 let mut snapshot = terminal_test_shared().snapshot();
22799 snapshot.session_records.push(bound.clone());
22800 let mut reply = ResponseEnvelope {
22801 request_id: 1,
22802 result: Ok(NodeResponse::Resync {
22803 event_sequence: 1,
22804 oldest_available_sequence: 1,
22805 snapshot,
22806 events: vec![NodeEventEnvelope {
22807 sequence: 1,
22808 event: NodeEvent::SessionRecordUpserted {
22809 record: bound.clone(),
22810 },
22811 }],
22812 }),
22813 };
22814 project_response_without_session_task_binding(&mut reply);
22815 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22816 panic!("task projection changed the response kind");
22817 };
22818 assert!(snapshot.session_records[0].task_binding.is_none());
22819 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
22820 panic!("task projection changed the event kind");
22821 };
22822 assert!(record.task_binding.is_none());
22823
22824 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
22825 sequence: 2,
22826 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
22827 });
22828 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
22829 panic!("task event projection changed the event kind");
22830 };
22831 assert!(record.task_binding.is_none());
22832
22833 let mut direct = ResponseEnvelope {
22834 request_id: 2,
22835 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
22836 };
22837 project_response_without_session_task_binding(&mut direct);
22838 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
22839 panic!("task reply projection changed the response kind");
22840 };
22841 assert!(record.task_binding.is_none());
22842 }
22843
22844 #[test]
22845 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
22846 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
22847 let workspace = WorkspaceConfig::new(
22848 WorkspaceId::new("primary").unwrap(),
22849 std::env::current_dir().unwrap(),
22850 )
22851 .unwrap();
22852 let config = NodeServerConfig::new(
22853 DEFAULT_NODE_ENDPOINT,
22854 "fixture-token",
22855 NodeId::new("node-1").unwrap(),
22856 [workspace],
22857 )
22858 .unwrap();
22859 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
22860 }
22861
22862 #[cfg(all(feature = "fixture", windows))]
22863 #[test]
22864 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
22865 let root = temporary_workspace_root("provider-bundle-argv-hold");
22866 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
22867 std::fs::create_dir_all(&root).unwrap();
22868 std::fs::create_dir_all(&outside).unwrap();
22869 let proof_path = root.join("provider.proof");
22870 let release_signal = root.join("release.signal");
22871 let config = NodeServerConfig::new(
22872 format!(
22873 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
22874 std::process::id(),
22875 ),
22876 "fixture-token",
22877 NodeId::new("provider-bundle-hold-node").unwrap(),
22878 [WorkspaceConfig::new(
22879 WorkspaceId::new("primary").unwrap(),
22880 std::env::current_dir().unwrap(),
22881 )
22882 .unwrap()],
22883 )
22884 .unwrap()
22885 .with_spawn_profiles(SpawnProfileRegistry::new([
22886 crate::protocol::SpawnProfileDefaults {
22887 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
22888 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
22889 .unwrap(),
22890 provider: agent("kimi"),
22891 mode: SessionMode::Pty,
22892 terminal_size: gate4agent_types::TerminalSize {
22893 rows: 24,
22894 columns: 80,
22895 },
22896 prompt: None,
22897 bundle_id: None,
22898 context_id: None,
22899 environment_profile_id: None,
22900 },
22901 ])
22902 .unwrap());
22903 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
22904 config,
22905 agent("kimi"),
22906 proof_path.clone(),
22907 release_signal.clone(),
22908 )
22909 .unwrap();
22910 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
22911
22912 let spec = NodeServer::provider_bundle_argv_fixture_spec(
22913 agent("kimi"),
22914 proof_path.clone(),
22915 Some(release_signal.clone()),
22916 )
22917 .unwrap();
22918 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
22919 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
22920 let release_wait = wrapper
22921 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
22922 .unwrap();
22923 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
22924 assert!(proof_write < release_wait);
22925 assert!(release_wait < fixture_ready);
22926 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
22927 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
22928 assert_eq!(
22929 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
22930 &[
22931 proof_path.to_string_lossy().into_owned(),
22932 release_signal.to_string_lossy().into_owned(),
22933 ],
22934 );
22935
22936 let error = NodeServer::provider_bundle_argv_fixture_spec(
22937 agent("kimi"),
22938 proof_path,
22939 Some(outside.join("release.signal")),
22940 )
22941 .unwrap_err();
22942 assert!(matches!(
22943 error,
22944 NodeServerError::Registry(message)
22945 if message == "provider bundle argv release path must share the proof parent"
22946 ));
22947 drop(server);
22948 std::fs::remove_dir_all(root).unwrap();
22949 std::fs::remove_dir_all(outside).unwrap();
22950 }
22951
22952 #[cfg(feature = "fixture")]
22953 #[test]
22954 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
22955 let root = temporary_workspace_root("context-fixture-registry");
22956 std::fs::create_dir_all(&root).unwrap();
22957 let proof_path = root.join("context.proof");
22958 let config = NodeServerConfig::new(
22959 format!(
22960 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
22961 std::process::id(),
22962 ),
22963 "fixture-token",
22964 NodeId::new("context-fixture-node").unwrap(),
22965 [WorkspaceConfig::new(
22966 WorkspaceId::new("primary").unwrap(),
22967 std::env::current_dir().unwrap(),
22968 )
22969 .unwrap()],
22970 )
22971 .unwrap();
22972 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
22973 let mut enabled = server
22974 .shared
22975 .enabled_providers
22976 .iter()
22977 .map(AgentId::as_str)
22978 .collect::<Vec<_>>();
22979 enabled.sort_unstable();
22980 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
22981 for provider in &server.shared.enabled_providers {
22982 assert!(server
22983 .shared
22984 .provider_adapter_contracts
22985 .iter()
22986 .any(|contract| {
22987 &contract.provider == provider
22988 && contract.family == AdapterFamily::History
22989 }));
22990 }
22991 drop(server);
22992 std::fs::remove_dir_all(root).unwrap();
22993 }
22994
22995 #[cfg(feature = "fixture")]
22996 #[test]
22997 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
22998 let root = temporary_workspace_root("monitoring-context-fixture");
22999 std::fs::create_dir_all(&root).unwrap();
23000 let proof_path = root.join("context.proof");
23001 let standard = NodeServer::context_pack_fixture_catalog(
23002 proof_path.clone(),
23003 None,
23004 false,
23005 ).unwrap();
23006 let monitoring = NodeServer::context_pack_fixture_catalog(
23007 proof_path.clone(),
23008 None,
23009 true,
23010 ).unwrap();
23011 let claude = AgentId::new("claude").unwrap();
23012 let standard_claude = standard.get(&claude).unwrap();
23013 let monitoring_claude = monitoring.get(&claude).unwrap();
23014 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
23018 assert_eq!(
23019 monitoring_claude.capabilities.adapters.history,
23020 standard_claude.capabilities.adapters.history,
23021 );
23022 assert_ne!(monitoring_claude.launch, standard_claude.launch);
23023 let codex = AgentId::new("codex").unwrap();
23024 let standard_codex = standard.get(&codex).unwrap();
23025 let monitoring_codex = monitoring.get(&codex).unwrap();
23026 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
23027 assert_eq!(
23028 monitoring_codex.capabilities.adapters.history,
23029 standard_codex.capabilities.adapters.history,
23030 );
23031 assert_ne!(monitoring_codex.launch, standard_codex.launch);
23032 let codex_validation = monitoring_codex.launch.fixed_args.iter()
23033 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
23034 .unwrap();
23035 for required in [
23036 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
23037 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
23038 "@('role', 'text')",
23039 "g4a-private-provider-session-canary",
23040 "private-provider-login-identity",
23041 "$rawProof",
23042 "$env:GATE4AGENT_HOOK_URL",
23043 "g4a-private-provider-session-canary",
23044 ] {
23045 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
23046 }
23047 for provider in ["grok", "kimi"] {
23048 let provider = AgentId::new(provider).unwrap();
23049 assert_eq!(monitoring.get(&provider), standard.get(&provider));
23050 }
23051
23052 let config = NodeServerConfig::new(
23053 format!(
23054 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
23055 std::process::id(),
23056 ),
23057 "fixture-token",
23058 NodeId::new("monitoring-context-fixture-node").unwrap(),
23059 [WorkspaceConfig::new(
23060 WorkspaceId::new("primary").unwrap(),
23061 std::env::current_dir().unwrap(),
23062 )
23063 .unwrap()],
23064 )
23065 .unwrap();
23066 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
23067 assert!(server.shared.fixture_semantic_hook_policy);
23068 drop(server);
23069 std::fs::remove_dir_all(root).unwrap();
23070 }
23071
23072 #[cfg(feature = "fixture")]
23073 #[test]
23074 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
23075 let root = temporary_workspace_root("kimi-context-only-fixture");
23076 std::fs::create_dir_all(&root).unwrap();
23077 let proof_path = root.join("context.proof");
23078 let standard = NodeServer::context_pack_fixture_catalog(
23079 proof_path.clone(),
23080 None,
23081 false,
23082 ).unwrap();
23083 let kimi = AgentId::new("kimi").unwrap();
23084 let context_only = NodeServer::context_pack_fixture_catalog(
23085 proof_path,
23086 Some(&kimi),
23087 false,
23088 )
23089 .unwrap();
23090
23091 let codex = AgentId::new("codex").unwrap();
23092 assert_eq!(
23093 standard.get(&codex).unwrap().launch.fixed_args,
23094 context_only.get(&codex).unwrap().launch.fixed_args,
23095 );
23096 let script = context_only
23097 .get(&kimi)
23098 .unwrap()
23099 .launch
23100 .fixed_args
23101 .iter()
23102 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
23103 .expect("Kimi context-only validation script is missing");
23104 for required in [
23105 "$bundleArgs.Count -ne 0",
23106 "GATE4AGENT_CONTEXT_ROOT",
23107 "context-pack.json",
23108 "$entries.Count -ne 1",
23109 "g4a-context-pack-v1",
23110 "$document.PSObject.Properties['cwd']",
23111 "'claude', 'codex', 'grok', 'kimi'",
23112 "$hasUser",
23113 "$hasAssistant",
23114 "SHA256",
23115 "context-only",
23116 ] {
23117 assert!(script.contains(required), "missing validation: {required}");
23118 }
23119 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
23120 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
23121 }
23122 assert!(!standard
23123 .get(&kimi)
23124 .unwrap()
23125 .launch
23126 .fixed_args
23127 .iter()
23128 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
23129
23130 std::fs::remove_dir_all(root).unwrap();
23131 }
23132
23133 #[cfg(all(feature = "fixture", windows))]
23134 #[tokio::test]
23135 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
23136 let root = temporary_workspace_root("kimi-context-only-spawn");
23137 let workspace_root = root.join("workspace");
23138 std::fs::create_dir_all(&workspace_root).unwrap();
23139 let materialization_root = root.join("private-materializations");
23140 let proof_path = root.join("context.proof");
23141 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
23142 let workspace_id = WorkspaceId::new("primary").unwrap();
23143 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
23144 let endpoint = format!(
23145 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
23146 std::process::id(),
23147 unix_time_ms(),
23148 );
23149 let spawn_profiles = SpawnProfileRegistry::new([
23150 crate::protocol::SpawnProfileDefaults {
23151 profile_id: profile_id.clone(),
23152 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
23153 provider: agent("kimi"),
23154 mode: SessionMode::Pty,
23155 terminal_size: gate4agent_types::TerminalSize {
23156 rows: 30,
23157 columns: 120,
23158 },
23159 prompt: None,
23160 bundle_id: None,
23161 context_id: None,
23162 environment_profile_id: None,
23163 },
23164 ])
23165 .unwrap();
23166 let mut config = NodeServerConfig::new(
23167 &endpoint,
23168 "fixture-token",
23169 node_id.clone(),
23170 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
23171 )
23172 .unwrap()
23173 .with_spawn_profiles(spawn_profiles)
23174 .with_session_environment_materialization(
23175 materialization_root,
23176 Arc::new(FixtureSecretResolver {
23177 deny: Arc::new(AtomicBool::new(false)),
23178 }),
23179 )
23180 .unwrap();
23181 config.fixture_raw_pty_runtime = true;
23182 let server = NodeServer::new_context_only_proof_fixture(
23183 config,
23184 agent("kimi"),
23185 proof_path.clone(),
23186 )
23187 .unwrap();
23188 let shared = Arc::clone(&server.shared);
23189 let shutdown = server.shutdown_handle();
23190
23191 let history = HistorySessionRecord {
23192 session_id: "context-only-source-history".to_owned(),
23193 title: None,
23194 cwd: None,
23195 model: None,
23196 message_count: 2,
23197 completed_turn_count: None,
23198 total_tokens: 0,
23199 messages: vec![
23200 gate4agent_types::HistoryMessageRecord {
23201 role: gate4agent_types::HistoryMessageRole::User,
23202 text: "continue the bounded parent run through native C2".to_owned(),
23203 },
23204 gate4agent_types::HistoryMessageRecord {
23205 role: gate4agent_types::HistoryMessageRole::Assistant,
23206 text: "the exact parent context is ready for the child run".to_owned(),
23207 },
23208 ],
23209 };
23210 let pack = NodeContextPack::export(
23211 ContextPackLineageReceipt {
23212 source_node_id: node_id.clone(),
23213 source_session: SessionAddress {
23214 workspace_id: workspace_id.clone(),
23215 session: SessionKey {
23216 instance_id: AgentInstanceId(71),
23217 generation: SessionGeneration(1),
23218 },
23219 },
23220 source_provider: agent("codex"),
23221 },
23222 &history,
23223 )
23224 .unwrap();
23225 let expected_context_hash = digest(&SHA256, pack.bytes())
23226 .as_ref()
23227 .iter()
23228 .map(|byte| format!("{byte:02x}"))
23229 .collect::<String>();
23230 let context = shared
23231 .context_catalog
23232 .write()
23233 .unwrap_or_else(|poisoned| poisoned.into_inner())
23234 .insert(pack)
23235 .unwrap();
23236 let spec = SpawnSpec {
23237 target: crate::protocol::SpawnTarget {
23238 node_id,
23239 workspace_id: workspace_id.clone(),
23240 worktree_id: None,
23241 },
23242 profile_id,
23243 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
23244 "target-kimi-r1",
23245 ).unwrap(),
23246 overrides: crate::protocol::SpawnOverrides {
23247 context_id: crate::protocol::SpawnOverride::Set {
23248 value: context.id.clone(),
23249 },
23250 ..crate::protocol::SpawnOverrides::default()
23251 },
23252 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
23253 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
23254 required_capabilities: SpawnRequiredCapabilities::default(),
23255 };
23256 let resolved = match shared.resolve_spawn_spec(&spec) {
23257 Ok(resolved) => resolved,
23258 Err(error) => panic!(
23259 "Kimi context-only resolve rejected: exact_code={:?} message={}",
23260 error.code,
23261 error.message,
23262 ),
23263 };
23264 let resolved_context = match shared.resolve_context(&resolved) {
23265 Ok(Some(resolved_context)) => resolved_context,
23266 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
23267 Err(error) => panic!(
23268 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
23269 error.code,
23270 error.message,
23271 ),
23272 };
23273 assert_eq!(resolved_context, context);
23274 let runtime_policy = match shared
23275 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
23276 .await
23277 {
23278 Ok(runtime_policy) => runtime_policy,
23279 Err(error) => panic!(
23280 "Kimi RawPty admission rejected: exact_code={:?} message={}",
23281 error.code,
23282 error.message,
23283 ),
23284 };
23285 assert!(runtime_policy.raw_pty_lifecycle);
23286
23287 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
23288 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
23289 agent("kimi"),
23290 TransportKind::Pty,
23291 vec![EnvMutation {
23292 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
23293 value: Some(OsString::from("must-remain-rejected")),
23294 }],
23295 )
23296 .unwrap();
23297 assert_eq!(
23298 launch_control
23299 .install_native_launch_environment_overlay(
23300 AgentInstanceId(72),
23301 generic_overlay,
23302 )
23303 .unwrap_err(),
23304 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
23305 );
23306
23307 let diagnostic_address = SessionAddress {
23308 workspace_id: workspace_id.clone(),
23309 session: SessionKey {
23310 instance_id: AgentInstanceId(73),
23311 generation: SessionGeneration(1),
23312 },
23313 };
23314 let (diagnostic_overlay, diagnostic_materialization) = match shared
23315 .prepare_session_materialization(
23316 &diagnostic_address,
23317 &agent("kimi"),
23318 SessionMode::Pty,
23319 None,
23320 None,
23321 Some(&resolved_context),
23322 None,
23323 )
23324 {
23325 Ok(Some(prepared)) => prepared,
23326 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
23327 Err(error) => panic!(
23328 "Kimi context-only materialization rejected: exact_code={:?} message={}",
23329 error.code,
23330 error.message,
23331 ),
23332 };
23333 let diagnostic_overlay = diagnostic_overlay
23334 .expect("Kimi context-only materialization omitted its environment overlay");
23335 assert!(matches!(
23336 &diagnostic_overlay,
23337 PreparedNativeLaunchOverlay::Environment(_),
23338 ));
23339 match shared.install_prepared_launch_overlay(
23340 diagnostic_address.session.instance_id,
23341 diagnostic_overlay,
23342 ) {
23343 Ok(None) => {}
23344 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
23345 Err(error) => panic!(
23346 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
23347 error.code,
23348 error.message,
23349 ),
23350 }
23351 assert!(launch_control.clear_native_instance_launch_overlay(
23352 diagnostic_address.session.instance_id,
23353 ));
23354 drop(diagnostic_materialization);
23355 assert!(shared
23356 .materializations
23357 .lock()
23358 .unwrap_or_else(|poisoned| poisoned.into_inner())
23359 .is_empty());
23360
23361 let node_task = tokio::spawn(server.run());
23362 let ready_client = timeout(Duration::from_secs(5), async {
23363 loop {
23364 match gate4agent_node_wire::LocalNodeClient::connect(
23365 &endpoint,
23366 &shared.node_id,
23367 ClientRole::Observer,
23368 "fixture-token",
23369 )
23370 .await
23371 {
23372 Ok(client) => break client,
23373 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
23374 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
23375 }
23376 }
23377 })
23378 .await
23379 .expect("Kimi context-only Node did not become ready");
23380 assert_eq!(
23381 ready_client
23382 .hello()
23383 .snapshot
23384 .provider_runtime_statuses
23385 .iter()
23386 .find(|status| status.provider().as_str() == "kimi")
23387 .expect("Kimi runtime status is missing")
23388 .mode(),
23389 crate::protocol::ProviderRuntimeMode::RawPassthrough,
23390 );
23391 drop(ready_client);
23392
23393 let receipt = match shared.spawn_from_spec(spec).await {
23394 Ok(receipt) => receipt,
23395 Err(error) => panic!(
23396 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
23397 error.code,
23398 error.message,
23399 ),
23400 };
23401 assert_eq!(receipt.provider, agent("kimi"));
23402 assert_eq!(receipt.context.as_ref(), Some(&context));
23403 assert!(receipt.context_binding_is_valid());
23404 timeout(Duration::from_secs(5), async {
23405 while !proof_path.is_file() {
23406 sleep(Duration::from_millis(10)).await;
23407 }
23408 })
23409 .await
23410 .expect("Kimi context-only fixture did not write its proof");
23411
23412 let proof = std::fs::read_to_string(&proof_path).unwrap();
23413 let lines = proof.lines().collect::<Vec<_>>();
23414 assert_eq!(lines.len(), 7);
23415 assert_eq!(lines[0], "context-only");
23416 assert!(Path::new(lines[1]).is_absolute());
23417 assert_ne!(
23418 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
23419 std::fs::canonicalize(&workspace_root).unwrap(),
23420 );
23421 assert_eq!(
23422 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
23423 std::fs::canonicalize(&workspace_root).unwrap(),
23424 );
23425 assert_eq!(lines[3], expected_context_hash);
23426 assert_eq!(lines[4], "g4a-context-pack-v1");
23427 assert_eq!(lines[5], "codex");
23428 assert_eq!(lines[6], "2");
23429 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
23430 session.instance_id == receipt.session.session.instance_id
23431 && session.agent_id.as_str() == "kimi"
23432 }));
23433
23434 shutdown.request_shutdown().await.unwrap();
23435 timeout(Duration::from_secs(5), node_task)
23436 .await
23437 .unwrap()
23438 .unwrap()
23439 .unwrap();
23440 drop(shutdown);
23441 drop(shared);
23442 std::fs::remove_dir_all(root).unwrap();
23443 }
23444
23445 #[cfg(feature = "fixture")]
23446 #[test]
23447 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
23448 let root = temporary_workspace_root("clean-exit-constructor");
23449 std::fs::create_dir_all(&root).unwrap();
23450 let config = construction_test_config("clean-exit-constructor")
23451 .with_state_path(root.join("node-state.json"))
23452 .unwrap();
23453 let server = NodeServer::new_clean_exit_fixture(
23454 config,
23455 root.clone(),
23456 root.join("started.marker"),
23457 root.join("release.signal"),
23458 )
23459 .unwrap();
23460 assert_eq!(
23461 server
23462 .shared
23463 .enabled_providers
23464 .iter()
23465 .map(AgentId::as_str)
23466 .collect::<Vec<_>>(),
23467 ["claude"],
23468 );
23469 drop(server);
23470 std::fs::remove_dir_all(root).unwrap();
23471 }
23472
23473 #[test]
23474 fn production_registry_provider_contract_manifest_is_exact() {
23475 let registry = active_registry().unwrap();
23476 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
23477 assert_eq!(
23478 providers
23479 .iter()
23480 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
23481 .collect::<Vec<_>>(),
23482 vec![
23483 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23484 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23485 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23486 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23487 ]
23488 );
23489 assert_eq!(
23490 adapters
23491 .iter()
23492 .map(|contract| (
23493 contract.provider.as_str(),
23494 contract.family,
23495 contract.adapter_id.as_str(),
23496 contract.revision.as_str(),
23497 ))
23498 .collect::<Vec<_>>(),
23499 vec![
23500 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
23501 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
23502 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
23503 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
23504 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
23505 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
23506 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
23507 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
23508 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
23509 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
23510 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
23511 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
23512 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
23513 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
23514 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
23515 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
23516 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
23517 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
23518 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
23519 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
23520 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
23521 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
23522 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
23523 ]
23524 );
23525 }
23526
23527 fn construction_test_config(name: &str) -> NodeServerConfig {
23528 let workspace = WorkspaceConfig::new(
23529 WorkspaceId::new("primary").unwrap(),
23530 std::env::current_dir().unwrap(),
23531 )
23532 .unwrap();
23533 NodeServerConfig::new(
23534 format!(r"\\.\pipe\gate4agent-{name}"),
23535 "fixture-token",
23536 NodeId::new(name).unwrap(),
23537 [workspace],
23538 )
23539 .unwrap()
23540 }
23541
23542 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
23543 let adapter = AdapterBinding::new(
23544 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
23545 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
23546 gate4agent_types::AdapterVerification::SyntheticFixture,
23547 )
23548 .unwrap();
23549 let agent_ids = ["claude", "codex", "kimi"]
23550 .into_iter()
23551 .map(|agent_id| AgentId::new(agent_id).unwrap())
23552 .collect::<Vec<_>>();
23553 let adapters = gate4agent_catalog::AdapterRegistry::new(
23554 [
23555 AdapterFamily::PtySemantic,
23556 AdapterFamily::Pipe,
23557 AdapterFamily::OneShot,
23558 AdapterFamily::Acp,
23559 AdapterFamily::Hook,
23560 AdapterFamily::ManagedHook,
23561 AdapterFamily::History,
23562 AdapterFamily::Resume,
23563 AdapterFamily::SessionOptions,
23564 AdapterFamily::CapabilityProbe,
23565 ]
23566 .into_iter()
23567 .map(|family| gate4agent_catalog::AdapterDescriptor {
23568 family,
23569 binding: adapter.clone(),
23570 agents: agent_ids.clone(),
23571 }),
23572 )
23573 .unwrap();
23574 let specs = agent_ids
23575 .iter()
23576 .map(|agent_id| {
23577 let agent_id = agent_id.as_str();
23578 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
23579 spec.revision =
23580 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
23581 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
23582 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
23583 adapter: adapter.clone(),
23584 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
23585 launch_override: None,
23586 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
23587 });
23588 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
23589 adapter: adapter.clone(),
23590 launch_override: None,
23591 });
23592 spec.capabilities.adapters.hook = Some(adapter.clone());
23593 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
23594 spec.capabilities.adapters.one_shot = Some(adapter.clone());
23595 spec.capabilities.adapters.history = Some(adapter.clone());
23596 spec.capabilities.adapters.resume = Some(adapter.clone());
23597 spec.capabilities.adapters.session_options = Some(adapter.clone());
23598 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
23599 spec
23600 })
23601 .collect::<Vec<_>>();
23602 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
23603 }
23604
23605 #[test]
23613 fn node_server_construction_accepts_the_production_manifest() {
23614 let registry = active_registry().unwrap();
23615 let (providers, _) = provider_contract_manifest(®istry).unwrap();
23616 assert_eq!(providers.len(), registry.iter().count());
23617 assert!(!providers.is_empty());
23618 let server = NodeServer::new_with_registry(
23619 construction_test_config("manifest-production-bound"),
23620 registry,
23621 )
23622 .unwrap();
23623 drop(server);
23624 }
23625
23626 #[test]
23627 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
23628 let registry = maximum_size_provider_manifest_registry();
23629 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
23630 assert_eq!(providers.len(), 3);
23631 assert_eq!(adapters.len(), 30);
23632 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
23633 let error = match NodeServer::new_with_registry(
23634 construction_test_config("manifest-handshake-overflow"),
23635 registry,
23636 ) {
23637 Ok(_) => panic!("oversized negotiated manifest was accepted"),
23638 Err(error) => error,
23639 };
23640 assert!(matches!(
23641 error,
23642 NodeServerError::ProviderContractManifest(message)
23643 if message.contains("authentication binding length")
23644 && message.contains("8192-byte limit")
23645 ));
23646 }
23647
23648 #[test]
23649 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
23650 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
23651 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
23652 let registry = AgentRegistry::new([spec]).unwrap();
23653 assert!(matches!(
23654 provider_contract_manifest(®istry),
23655 Err(NodeServerError::ProviderContractManifest(message))
23656 if message.contains("exceeds the 128-byte limit")
23657 ));
23658 }
23659
23660 #[test]
23661 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
23662 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
23663 spec.revision = "invalid revision".to_owned();
23664 let registry = AgentRegistry::new([spec]).unwrap();
23665 assert!(matches!(
23666 provider_contract_manifest(®istry),
23667 Err(NodeServerError::ProviderContractManifest(message))
23668 if message.contains("bounded lowercase ASCII")
23669 ));
23670 }
23671
23672 #[test]
23673 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
23674 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
23675 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
23676 let conflicting_binding = builtin_registry()
23677 .get_by_id("codex")
23678 .unwrap()
23679 .capabilities
23680 .adapters
23681 .one_shot
23682 .clone()
23683 .unwrap();
23684 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
23685 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
23686 pipe.adapter = transport_binding;
23687 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
23688 let registry = AgentRegistry::new([spec]).unwrap();
23689 assert!(matches!(
23690 provider_contract_manifest(®istry),
23691 Err(NodeServerError::ProviderContractManifest(message))
23692 if message.contains("conflicting OneShot adapter bindings")
23693 ));
23694 }
23695
23696 #[test]
23697 fn public_node_failures_expose_only_fixed_categories() {
23698 let provider = failure(
23699 NodeFailureCode::BackendOperationFailed,
23700 r"provider bearer-secret failed at C:\private\session.jsonl",
23701 );
23702 assert_eq!(provider.message, "backend-operation-failed");
23703 assert!(!provider.message.contains("bearer-secret"));
23704 assert!(!provider.message.contains(r"C:\private"));
23705 let persistence = persistence_failure(io::Error::new(
23706 io::ErrorKind::Other,
23707 r"state write failed at C:\private\state-v1.json",
23708 ));
23709 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
23710 assert!(!persistence.message.contains(r"C:\private"));
23711
23712 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
23713 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
23714 assert_eq!(
23715 generic.to_string(),
23716 "node durable state failed: durable-state-load-failed",
23717 );
23718 }
23719 }
23720
23721 #[test]
23722 fn terminal_byte_sequences_are_bounded_before_dispatch() {
23723 let sequence = b"\x1b[1;5D".to_vec();
23724 assert_eq!(
23725 terminal_bytes_action(sequence.clone()).unwrap(),
23726 InputAction::TerminalBytes(sequence),
23727 );
23728 assert_eq!(
23729 terminal_bytes_action(Vec::new()).unwrap_err().code,
23730 NodeFailureCode::InvalidRequest,
23731 );
23732 assert_eq!(
23733 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
23734 .unwrap_err()
23735 .code,
23736 NodeFailureCode::InvalidRequest,
23737 );
23738 }
23739
23740 #[test]
23741 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
23742 let root = temporary_workspace_root("exclusive-state-owner");
23743 std::fs::create_dir_all(&root).unwrap();
23744 let workspace = WorkspaceConfig::new(
23745 WorkspaceId::new("primary").unwrap(),
23746 &root,
23747 )
23748 .unwrap();
23749 let state_path = root.join("state-v1.json");
23750 let endpoint = format!(
23751 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
23752 std::process::id(),
23753 std::time::SystemTime::now()
23754 .duration_since(std::time::UNIX_EPOCH)
23755 .unwrap()
23756 .as_nanos(),
23757 );
23758 let config = NodeServerConfig::new(
23759 endpoint,
23760 "fixture-token",
23761 NodeId::new("node-1").unwrap(),
23762 [workspace],
23763 )
23764 .unwrap()
23765 .with_state_path(&state_path)
23766 .unwrap();
23767
23768 let first = NodeServer::new(config.clone()).unwrap();
23769 let error = match NodeServer::new(config.clone()) {
23770 Ok(_) => panic!("a second node server acquired the same durable state path"),
23771 Err(error) => error,
23772 };
23773 assert!(matches!(
23774 &error,
23775 NodeServerError::DurableState(error)
23776 if error.kind() == io::ErrorKind::WouldBlock
23777 ));
23778 assert_eq!(
23779 error.to_string(),
23780 "node durable state failed: durable-state-lock-failed",
23781 );
23782 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
23783 drop(first);
23784 let second = NodeServer::new(config).unwrap();
23785 drop(second);
23786 std::fs::remove_dir_all(root).unwrap();
23787 }
23788
23789 #[test]
23790 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
23791 let catalog = active_registry().unwrap();
23792 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23793 let workspace_id = WorkspaceId::new("primary").unwrap();
23794 let workspace = WorkspaceConfig::new(
23795 workspace_id.clone(),
23796 std::env::current_dir().unwrap(),
23797 )
23798 .unwrap();
23799 let shared = NodeShared::new(
23800 handle,
23801 "fixture-token".to_owned(),
23802 NodeId::new("node-1").unwrap(),
23803 vec![workspace.clone()],
23804 vec![agent("claude"), agent("codex"), agent("kimi")],
23805 );
23806 let instance_id = AgentInstanceId(17);
23807 let generation = SessionGeneration(3);
23808 assert!(shared.address_for(instance_id, generation).is_none());
23809 shared.bind_session(instance_id, workspace_id.clone(), generation);
23810 let address = SessionAddress {
23811 workspace_id,
23812 session: SessionKey { instance_id, generation },
23813 };
23814 assert_eq!(
23815 shared.workspace_root(&address.workspace_id).unwrap(),
23816 workspace.canonical_root,
23817 );
23818 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
23819 shared.remove_binding(&address);
23820 assert!(shared.address_for(instance_id, generation).is_none());
23821 }
23822
23823 #[test]
23824 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
23825 let address = SessionAddress {
23826 workspace_id: WorkspaceId::new("primary").unwrap(),
23827 session: SessionKey {
23828 instance_id: AgentInstanceId(7),
23829 generation: SessionGeneration(2),
23830 },
23831 };
23832 let record_id = SessionRecordId::new("record-a").unwrap();
23833 let size = gate4agent_types::TerminalSize {
23834 rows: 24,
23835 columns: 80,
23836 };
23837 let address_requests = [
23838 NodeRequest::Resume {
23839 session: address.clone(),
23840 terminal_size: size,
23841 initial_prompt: None,
23842 },
23843 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
23844 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
23845 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
23846 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
23847 NodeRequest::TerminalControl {
23848 session: address.clone(),
23849 control: TerminalControl::Enter,
23850 },
23851 NodeRequest::Resize { session: address.clone(), size },
23852 NodeRequest::Interrupt { session: address.clone() },
23853 NodeRequest::Stop { session: address.clone(), force: false },
23854 NodeRequest::Remove { session: address },
23855 ];
23856 for request in &address_requests {
23857 assert!(request_requires_open_provider_ids_with(
23858 request,
23859 |_| Some(agent("third-party-agent")),
23860 |_| Some(agent("claude")),
23861 ));
23862 assert!(!request_requires_open_provider_ids_with(
23863 request,
23864 |_| Some(agent("claude")),
23865 |_| Some(agent("claude")),
23866 ));
23867 assert!(request_requires_open_provider_ids_with(
23868 request,
23869 |_| None,
23870 |_| Some(agent("claude")),
23871 ));
23872 }
23873
23874 let record_requests = [
23875 NodeRequest::RenameSessionRecord {
23876 record_id: record_id.clone(),
23877 display_name: "renamed".to_owned(),
23878 },
23879 NodeRequest::ResumeSessionRecord {
23880 record_id: record_id.clone(),
23881 terminal_size: size,
23882 initial_prompt: None,
23883 },
23884 NodeRequest::ForgetSessionRecord { record_id },
23885 ];
23886 for request in &record_requests {
23887 assert!(request_requires_open_provider_ids_with(
23888 request,
23889 |_| Some(agent("claude")),
23890 |_| Some(agent("grok")),
23891 ));
23892 assert!(!request_requires_open_provider_ids_with(
23893 request,
23894 |_| Some(agent("claude")),
23895 |_| Some(agent("kimi")),
23896 ));
23897 assert!(request_requires_open_provider_ids_with(
23898 request,
23899 |_| Some(agent("claude")),
23900 |_| None,
23901 ));
23902 }
23903
23904 assert!(request_requires_open_provider_ids_with(
23905 &NodeRequest::Spawn {
23906 workspace_id: WorkspaceId::new("primary").unwrap(),
23907 provider: agent("third-party-agent"),
23908 mode: SessionMode::Pty,
23909 terminal_size: size,
23910 initial_prompt: None,
23911 },
23912 |_| None,
23913 |_| None,
23914 ));
23915 assert!(!request_requires_open_provider_ids_with(
23916 &NodeRequest::Snapshot,
23917 |_| None,
23918 |_| None,
23919 ));
23920 assert!(request_requires_open_provider_ids_with(
23921 &NodeRequest::ForgetContextPack {
23922 context_id: SpawnContextId::new("ctx-forget").unwrap(),
23923 },
23924 |_| None,
23925 |_| None,
23926 ));
23927 }
23928
23929 #[test]
23930 fn legacy_projection_filters_open_record_replies_and_event_history() {
23931 let catalog = active_registry().unwrap();
23932 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23933 let workspace = WorkspaceConfig::new(
23934 WorkspaceId::new("primary").unwrap(),
23935 std::env::current_dir().unwrap(),
23936 )
23937 .unwrap();
23938 let shared = NodeShared::new(
23939 handle,
23940 "fixture-token".to_owned(),
23941 NodeId::new("node-1").unwrap(),
23942 vec![workspace],
23943 vec![agent("claude"), agent("grok")],
23944 );
23945 let legacy = record("claude", "legacy-record");
23946 let open = record("grok", "open-record");
23947 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
23948 record: legacy.clone(),
23949 });
23950 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
23951 record: open.clone(),
23952 });
23953 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
23954 record_id: legacy.record_id.clone(),
23955 });
23956 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
23957 record_id: open.record_id.clone(),
23958 });
23959 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
23960 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
23961 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
23962 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
23963
23964 let mut record_reply = ResponseEnvelope {
23965 request_id: 1,
23966 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
23967 };
23968 project_response_legacy_provider_ids(&shared, &mut record_reply);
23969 assert!(matches!(
23970 record_reply.result,
23971 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
23972 ));
23973
23974 let mut resync = ResponseEnvelope {
23975 request_id: 2,
23976 result: Ok(NodeResponse::Resync {
23977 event_sequence: open_removed.sequence,
23978 oldest_available_sequence: 1,
23979 snapshot: shared.snapshot(),
23980 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
23981 }),
23982 };
23983 project_response_legacy_provider_ids(&shared, &mut resync);
23984 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
23985 panic!("legacy projection changed the resync response kind");
23986 };
23987 assert!(snapshot.session_records.iter().all(|item| {
23988 provider_id_is_legacy(&item.provider)
23989 }));
23990 assert_eq!(events.len(), 2);
23991 assert!(events.iter().all(|event| matches!(
23992 &event.event,
23993 NodeEvent::SessionRecordUpserted { record }
23994 if provider_id_is_legacy(&record.provider)
23995 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
23996 }
23997
23998 #[test]
23999 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
24000 let catalog = active_registry().unwrap();
24001 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24002 let workspace_id = WorkspaceId::new("primary").unwrap();
24003 let workspace = WorkspaceConfig::new(
24004 workspace_id.clone(),
24005 std::env::current_dir().unwrap(),
24006 )
24007 .unwrap();
24008 let shared = NodeShared::new(
24009 handle,
24010 "fixture-token".to_owned(),
24011 NodeId::new("node-1").unwrap(),
24012 vec![workspace],
24013 vec![agent("claude")],
24014 );
24015 for index in 0..CONTROL_SESSIONS_MAX {
24016 shared.bind_session(
24017 AgentInstanceId((index + 1) as u64),
24018 workspace_id.clone(),
24019 SessionGeneration(1),
24020 );
24021 }
24022 let error = shared.ensure_binding_capacity().unwrap_err();
24023 assert_eq!(error.code, NodeFailureCode::BackendBusy);
24024 }
24025
24026 #[tokio::test]
24027 async fn provider_spawn_admission_rejects_before_session_mutation() {
24028 let catalog = active_registry().unwrap();
24029 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24030 let workspace = WorkspaceConfig::new(
24031 WorkspaceId::new("primary").unwrap(),
24032 std::env::current_dir().unwrap(),
24033 )
24034 .unwrap();
24035 let mut shared = NodeShared::new(
24036 handle,
24037 "fixture-token".to_owned(),
24038 NodeId::new("node-provider-admission").unwrap(),
24039 vec![workspace],
24040 vec![agent("claude")],
24041 );
24042 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24043 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
24044 ])
24045 .unwrap();
24046 let terminal_size = gate4agent_types::TerminalSize {
24047 rows: 24,
24048 columns: 80,
24049 };
24050
24051 let unavailable = shared
24052 .spawn_session(
24053 WorkspaceId::new("primary").unwrap(),
24054 agent("claude"),
24055 SessionMode::Pty,
24056 terminal_size,
24057 None,
24058 )
24059 .await
24060 .unwrap_err();
24061 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
24062 assert_eq!(unavailable.message, "backend-operation-failed");
24063
24064 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24065 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
24066 agent("claude"),
24067 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
24068 ),
24069 ])
24070 .unwrap();
24071 let semantic = shared
24072 .spawn_session(
24073 WorkspaceId::new("primary").unwrap(),
24074 agent("claude"),
24075 SessionMode::Inline,
24076 terminal_size,
24077 None,
24078 )
24079 .await
24080 .unwrap_err();
24081 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
24082 assert_eq!(semantic.message, "unsupported-capability");
24083
24084 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
24085 assert!(shared
24086 .session_bindings
24087 .lock()
24088 .unwrap_or_else(|poisoned| poisoned.into_inner())
24089 .is_empty());
24090 assert!(shared
24091 .session_records
24092 .lock()
24093 .unwrap_or_else(|poisoned| poisoned.into_inner())
24094 .records
24095 .is_empty());
24096 assert!(shared.handle.snapshot().sessions.is_empty());
24097 }
24098
24099 #[tokio::test]
24100 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
24101 let catalog = active_registry().unwrap();
24102 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24103 let workspace_id = WorkspaceId::new("primary").unwrap();
24104 let workspace = WorkspaceConfig::new(
24105 workspace_id.clone(),
24106 std::env::current_dir().unwrap(),
24107 )
24108 .unwrap();
24109 let shared = NodeShared::new(
24110 handle,
24111 "fixture-token".to_owned(),
24112 NodeId::new("node-raw-spawn-record").unwrap(),
24113 vec![workspace],
24114 vec![agent("claude")],
24115 );
24116 let address = SessionAddress {
24117 workspace_id,
24118 session: SessionKey {
24119 instance_id: AgentInstanceId(71),
24120 generation: SessionGeneration::default(),
24121 },
24122 };
24123
24124 let record_id = shared
24125 .bind_spawn_session(
24126 &address,
24127 agent("claude"),
24128 SessionMode::Pty,
24129 ProviderRuntimePolicy::raw_pty(),
24130 SpawnRecordPolicy::Always,
24131 None,
24132 )
24133 .unwrap()
24134 .unwrap();
24135 shared
24136 .handle
24137 .dispatch(shared.prepare_command(ControlCommand::Register {
24138 instance_id: address.session.instance_id,
24139 agent_id: agent("claude"),
24140 transport: TransportKind::Pty,
24141 }))
24142 .unwrap();
24143 runtime.tick().await;
24144
24145 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24146 session.instance_id == address.session.instance_id
24147 && session.generation == address.session.generation
24148 }));
24149 let record = shared.record(&record_id).unwrap();
24150 assert_eq!(record.display_name, "claude #1");
24151 assert_eq!(record.state, ManagedSessionState::Live);
24152 assert_eq!(record.active_session.as_ref(), Some(&address));
24153 assert!(record.provider_session.is_none());
24154 let binding_record_id = shared
24155 .session_bindings
24156 .lock()
24157 .unwrap_or_else(|poisoned| poisoned.into_inner())
24158 .get(&address.session.instance_id)
24159 .and_then(|binding| binding.record_id.clone());
24160 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
24161
24162 let renamed = shared
24163 .rename_session_record(&record_id, "raw review".to_owned())
24164 .unwrap();
24165 assert_eq!(renamed.record_id, record_id);
24166 assert_eq!(renamed.display_name, "raw review");
24167 shared.reconcile_managed_record(
24168 &record_id,
24169 &address,
24170 &ControlEvent {
24171 sequence: 1,
24172 command_id: None,
24173 instance_id: address.session.instance_id,
24174 generation: address.session.generation,
24175 event: ControlEventKind::Running { process_id: None },
24176 },
24177 false,
24178 false,
24179 );
24180 let running = shared.record(&record_id).unwrap();
24181 assert_eq!(running.state, ManagedSessionState::Live);
24182 assert_eq!(running.active_session.as_ref(), Some(&address));
24183 assert_eq!(running.display_name, "raw review");
24184
24185 shared.reconcile_managed_record(
24186 &record_id,
24187 &address,
24188 &ControlEvent {
24189 sequence: 2,
24190 command_id: None,
24191 instance_id: address.session.instance_id,
24192 generation: address.session.generation,
24193 event: ControlEventKind::Exited {
24194 exit_code: Some(0),
24195 forced: false,
24196 },
24197 },
24198 false,
24199 false,
24200 );
24201 let detached = shared.record(&record_id).unwrap();
24202 assert_eq!(detached.state, ManagedSessionState::Unavailable);
24203 assert!(detached.active_session.is_none());
24204 assert_eq!(detached.display_name, "raw review");
24205 let renamed_detached = shared
24206 .rename_session_record(&record_id, "raw review detached".to_owned())
24207 .unwrap();
24208 assert_eq!(renamed_detached.record_id, record_id);
24209 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
24210 assert!(renamed_detached.active_session.is_none());
24211 assert_eq!(renamed_detached.display_name, "raw review detached");
24212 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
24213 }
24214
24215 #[test]
24216 fn legacy_raw_spawn_binding_remains_untracked() {
24217 let catalog = active_registry().unwrap();
24218 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24219 let workspace_id = WorkspaceId::new("primary").unwrap();
24220 let workspace = WorkspaceConfig::new(
24221 workspace_id.clone(),
24222 std::env::current_dir().unwrap(),
24223 )
24224 .unwrap();
24225 let shared = NodeShared::new(
24226 handle,
24227 "fixture-token".to_owned(),
24228 NodeId::new("node-legacy-raw-spawn").unwrap(),
24229 vec![workspace],
24230 vec![agent("claude")],
24231 );
24232 let address = SessionAddress {
24233 workspace_id,
24234 session: SessionKey {
24235 instance_id: AgentInstanceId(72),
24236 generation: SessionGeneration::default(),
24237 },
24238 };
24239
24240 let record_id = shared
24241 .bind_spawn_session(
24242 &address,
24243 agent("claude"),
24244 SessionMode::Pty,
24245 ProviderRuntimePolicy::raw_pty(),
24246 SpawnRecordPolicy::ProviderIdentityOnly,
24247 None,
24248 )
24249 .unwrap();
24250
24251 assert!(record_id.is_none());
24252 let binding_record_id = shared
24253 .session_bindings
24254 .lock()
24255 .unwrap_or_else(|poisoned| poisoned.into_inner())
24256 .get(&address.session.instance_id)
24257 .and_then(|binding| binding.record_id.clone());
24258 assert!(binding_record_id.is_none());
24259 assert!(shared.snapshot().session_records.is_empty());
24260 }
24261
24262 #[tokio::test]
24263 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
24264 let catalog = active_registry().unwrap();
24265 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24266 let workspace_id = WorkspaceId::new("primary").unwrap();
24267 let workspace = WorkspaceConfig::new(
24268 workspace_id.clone(),
24269 std::env::current_dir().unwrap(),
24270 )
24271 .unwrap();
24272 let mut shared = NodeShared::new(
24273 handle,
24274 "fixture-token".to_owned(),
24275 NodeId::new("node-raw-semantic-gate").unwrap(),
24276 vec![workspace],
24277 vec![agent("claude")],
24278 );
24279 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24280 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
24281 ])
24282 .unwrap();
24283 shared
24284 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24285 .unwrap();
24286 let address = SessionAddress {
24287 workspace_id,
24288 session: SessionKey {
24289 instance_id: AgentInstanceId(72),
24290 generation: SessionGeneration::default(),
24291 },
24292 };
24293 shared.bind_session_with_policy(
24294 &address,
24295 ProviderRuntimePolicy::raw_pty(),
24296 None,
24297 );
24298 shared
24299 .handle
24300 .dispatch(shared.prepare_command(ControlCommand::Register {
24301 instance_id: address.session.instance_id,
24302 agent_id: agent("claude"),
24303 transport: TransportKind::Pty,
24304 }))
24305 .unwrap();
24306 runtime.tick().await;
24307 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
24308 let revision_before = shared.handle.snapshot().revision;
24309 let history_sequence_before = shared.current_sequence();
24310 let terminal_size = gate4agent_types::TerminalSize {
24311 rows: 24,
24312 columns: 80,
24313 };
24314
24315 for request in [
24316 NodeRequest::Prompt {
24317 session: address.clone(),
24318 text: "prompt".to_owned(),
24319 },
24320 NodeRequest::Paste {
24321 session: address.clone(),
24322 text: "paste".to_owned(),
24323 },
24324 NodeRequest::Resume {
24330 session: address.clone(),
24331 terminal_size,
24332 initial_prompt: Some("resume".to_owned()),
24333 },
24334 ] {
24335 let error = process_request_inner(
24336 &shared,
24337 77,
24338 ClientRole::Operator,
24339 request,
24340 )
24341 .await
24342 .unwrap_err();
24343 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
24344 }
24345
24346 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
24347 assert_eq!(shared.handle.snapshot().revision, revision_before);
24348 assert_eq!(shared.current_sequence(), history_sequence_before);
24349 let bindings = shared
24350 .session_bindings
24351 .lock()
24352 .unwrap_or_else(|poisoned| poisoned.into_inner());
24353 let binding = bindings.get(&address.session.instance_id).unwrap();
24354 assert_eq!(binding.generation, address.session.generation);
24355 assert!(binding.pending_resume.is_none());
24356 assert!(binding.record_id.is_none());
24357 }
24358
24359 #[tokio::test]
24360 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
24361 let catalog = active_registry().unwrap();
24362 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24363 let primary = WorkspaceConfig::new(
24364 WorkspaceId::new("primary").unwrap(),
24365 std::env::current_dir().unwrap(),
24366 )
24367 .unwrap();
24368 let shared = NodeShared::new(
24369 handle,
24370 "fixture-token".to_owned(),
24371 NodeId::new("node-1").unwrap(),
24372 vec![primary],
24373 vec![agent("claude")],
24374 );
24375 shared
24376 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24377 .unwrap();
24378 let before = shared.snapshot();
24379 let error = process_request_inner(
24380 &shared,
24381 77,
24382 ClientRole::Operator,
24383 NodeRequest::RegisterWorkspace {
24384 workspace_id: WorkspaceId::new("foreign").unwrap(),
24385 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
24386 },
24387 )
24388 .await
24389 .unwrap_err();
24390 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
24391 assert_eq!(error.message, "invalid-request");
24392 assert_eq!(shared.snapshot().workspaces, before.workspaces);
24393 }
24394
24395 #[tokio::test]
24396 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
24397 let catalog = active_registry().unwrap();
24398 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24399 let primary_id = WorkspaceId::new("primary").unwrap();
24400 let primary = WorkspaceConfig::new(
24401 primary_id.clone(),
24402 std::env::current_dir().unwrap(),
24403 )
24404 .unwrap();
24405 let shared = NodeShared::new(
24406 handle,
24407 "fixture-token".to_owned(),
24408 NodeId::new("node-1").unwrap(),
24409 vec![primary.clone()],
24410 vec![agent("claude")],
24411 );
24412 let secondary_id = WorkspaceId::new("secondary").unwrap();
24413 let secondary = shared
24414 .register_workspace(
24415 secondary_id.clone(),
24416 std::env::temp_dir().to_string_lossy().into_owned(),
24417 )
24418 .await
24419 .unwrap();
24420 assert_eq!(secondary.workspace_id, secondary_id);
24421 assert!(secondary.sessions.is_empty());
24422 assert_eq!(shared.snapshot().workspaces.len(), 2);
24423
24424 let duplicate_id = shared
24425 .register_workspace(
24426 secondary_id.clone(),
24427 primary.canonical_root().to_owned(),
24428 )
24429 .await
24430 .unwrap_err();
24431 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
24432 let duplicate_root = shared
24433 .register_workspace(
24434 WorkspaceId::new("duplicate-root").unwrap(),
24435 windows_path_text(&secondary.canonical_root).to_owned(),
24436 )
24437 .await
24438 .unwrap_err();
24439 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
24440
24441 let address = SessionAddress {
24442 workspace_id: secondary_id.clone(),
24443 session: SessionKey {
24444 instance_id: AgentInstanceId(41),
24445 generation: SessionGeneration(1),
24446 },
24447 };
24448 shared.bind_session(
24449 address.session.instance_id,
24450 secondary_id.clone(),
24451 address.session.generation,
24452 );
24453 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
24454 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
24455 shared.remove_binding(&address);
24456 shared.unregister_workspace(&secondary_id).unwrap();
24457 assert_eq!(shared.snapshot().workspaces.len(), 1);
24458
24459 let last = shared.unregister_workspace(&primary_id).unwrap_err();
24460 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
24461 let history = shared
24462 .history
24463 .lock()
24464 .unwrap_or_else(|poisoned| poisoned.into_inner());
24465 assert!(matches!(
24466 &history.events[0],
24467 NodeEventEnvelope {
24468 sequence: 1,
24469 event: NodeEvent::WorkspaceAdded { workspace },
24470 } if workspace.workspace_id == secondary_id
24471 ));
24472 assert!(matches!(
24473 &history.events[1],
24474 NodeEventEnvelope {
24475 sequence: 2,
24476 event: NodeEvent::WorkspaceRemoved { workspace_id },
24477 } if workspace_id == &secondary_id
24478 ));
24479 }
24480
24481 #[tokio::test]
24482 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
24483 let secondary_root = temporary_workspace_root("provider-session-reference-index");
24484 std::fs::create_dir_all(&secondary_root).unwrap();
24485 let catalog = active_registry().unwrap();
24486 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24487 let primary_id = WorkspaceId::new("primary").unwrap();
24488 let secondary_id = WorkspaceId::new("secondary").unwrap();
24489 let shared = NodeShared::new(
24490 handle,
24491 "fixture-token".to_owned(),
24492 NodeId::new("node-session-reference-index").unwrap(),
24493 vec![
24494 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
24495 .unwrap(),
24496 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
24497 ],
24498 vec![agent("claude"), agent("codex")],
24499 );
24500 let identity = ProviderSessionIdentity {
24501 key: gate4agent_types::ProviderSessionKey::SessionId,
24502 id: "provider-session-42".to_owned(),
24503 transcript_path: None,
24504 };
24505 let index_request = |workspace_id: WorkspaceId,
24506 provider: AgentId,
24507 identity: ProviderSessionIdentity,
24508 display_name: &str| {
24509 NodeRequest::IndexProviderSession {
24510 workspace_id,
24511 provider,
24512 identity,
24513 display_name: display_name.to_owned(),
24514 }
24515 };
24516 let request = index_request(
24517 primary_id.clone(),
24518 agent("claude"),
24519 identity.clone(),
24520 "release shepherd",
24521 );
24522 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24523 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
24524 }));
24525 let observer = process_request_inner(
24526 &shared,
24527 77,
24528 ClientRole::Observer,
24529 request.clone(),
24530 )
24531 .await
24532 .unwrap_err();
24533 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
24534 let uncontrolled = process_request_inner(
24535 &shared,
24536 77,
24537 ClientRole::Operator,
24538 request.clone(),
24539 )
24540 .await
24541 .unwrap_err();
24542 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
24543 shared
24544 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24545 .unwrap();
24546
24547 let indexed = process_request_inner(
24548 &shared,
24549 77,
24550 ClientRole::Operator,
24551 request.clone(),
24552 )
24553 .await
24554 .unwrap();
24555 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
24556 panic!("provider session reference index returned another response");
24557 };
24558 assert_eq!(record.state, ManagedSessionState::Dormant);
24559 assert_eq!(record.mode, SessionMode::Pty);
24560 assert_eq!(record.workspace_id, primary_id);
24561 assert_eq!(record.provider_session.as_ref(), Some(&identity));
24562 assert!(record.active_session.is_none());
24563 assert!(record.environment_profile.is_none());
24564 assert!(record.bundle.is_none());
24565 assert!(record.context.is_none());
24566
24567 let repeated = process_request_inner(
24568 &shared,
24569 77,
24570 ClientRole::Operator,
24571 index_request(
24572 primary_id.clone(),
24573 agent("claude"),
24574 identity.clone(),
24575 "ignored replacement",
24576 ),
24577 )
24578 .await
24579 .unwrap();
24580 assert!(matches!(
24581 repeated,
24582 NodeResponse::ProviderSessionIndexed { record: ref existing }
24583 if existing.record_id == record.record_id
24584 && existing.display_name == "release shepherd"
24585 ));
24586 let conflicting_workspace = process_request_inner(
24587 &shared,
24588 77,
24589 ClientRole::Operator,
24590 index_request(
24591 secondary_id,
24592 agent("claude"),
24593 identity.clone(),
24594 "release shepherd",
24595 ),
24596 )
24597 .await
24598 .unwrap_err();
24599 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
24600 let conflicting_provider = process_request_inner(
24601 &shared,
24602 77,
24603 ClientRole::Operator,
24604 index_request(
24605 primary_id.clone(),
24606 agent("codex"),
24607 identity.clone(),
24608 "release shepherd",
24609 ),
24610 )
24611 .await
24612 .unwrap_err();
24613 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
24614 let transcript_rejected = process_request_inner(
24615 &shared,
24616 77,
24617 ClientRole::Operator,
24618 index_request(
24619 primary_id,
24620 agent("claude"),
24621 ProviderSessionIdentity {
24622 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
24623 ..identity
24624 },
24625 "release shepherd",
24626 ),
24627 )
24628 .await
24629 .unwrap_err();
24630 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
24631 let upserts = shared
24632 .history
24633 .lock()
24634 .unwrap_or_else(|poisoned| poisoned.into_inner())
24635 .events
24636 .iter()
24637 .filter(|event| matches!(
24638 &event.event,
24639 NodeEvent::SessionRecordUpserted { record: published }
24640 if published.record_id == record.record_id
24641 ))
24642 .count();
24643 assert_eq!(upserts, 1);
24644 std::fs::remove_dir_all(secondary_root).unwrap();
24645 }
24646
24647 #[tokio::test]
24648 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
24649 let fixture_root = temporary_workspace_root("standalone-independent");
24650 let target = fixture_root.join("independent");
24651 std::fs::create_dir_all(&fixture_root).unwrap();
24652 let catalog = active_registry().unwrap();
24653 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24654 let selected = WorkspaceConfig::new(
24655 WorkspaceId::new("primary").unwrap(),
24656 std::env::current_dir().unwrap(),
24657 )
24658 .unwrap();
24659 let selected_root = selected.canonical_root().to_owned();
24660 let shared = NodeShared::new(
24661 handle,
24662 "fixture-token".to_owned(),
24663 NodeId::new("node-standalone").unwrap(),
24664 vec![selected],
24665 vec![agent("claude")],
24666 );
24667 let workspace_id = WorkspaceId::new("independent").unwrap();
24668 let request = NodeRequest::CreateStandaloneWorkspace {
24669 workspace_id: workspace_id.clone(),
24670 root: opaque_windows_path(target.to_string_lossy().into_owned()),
24671 initial_branch: Some("main".to_owned()),
24672 };
24673 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24674 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
24675 }));
24676 assert!(!request_uses_unnegotiated_capability(
24677 &request,
24678 &baseline_capabilities().unwrap(),
24679 ));
24680 assert!(request_uses_unnegotiated_capability(&request, &[]));
24681
24682 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
24683 .await
24684 .unwrap_err();
24685 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
24686 assert!(!target.exists());
24687 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
24688 .await
24689 .unwrap_err();
24690 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
24691 assert!(!target.exists());
24692 shared
24693 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24694 .unwrap();
24695
24696 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
24697 .await
24698 .unwrap();
24699 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
24700 panic!("standalone workspace creation returned another response");
24701 };
24702 assert_eq!(workspace.workspace_id, workspace_id);
24703 assert!(target.join(".git").is_dir());
24704 let branch = run_git_bounded(
24705 target.to_str().unwrap(),
24706 &["branch", "--show-current"],
24707 GIT_OUTPUT_MAX_BYTES,
24708 )
24709 .await
24710 .unwrap();
24711 assert!(branch.success);
24712 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
24713 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
24714 assert_eq!(worktrees.len(), 1);
24715 let listed_root = WorkspaceConfig::new(
24716 WorkspaceId::new("listed-independent").unwrap(),
24717 &worktrees[0].path,
24718 )
24719 .unwrap();
24720 assert!(platform::roots_equal(
24721 listed_root.canonical_root(),
24722 windows_path_text(&workspace.canonical_root),
24723 ));
24724 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
24725 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
24726 snapshot.workspace_id == workspace_id
24727 && snapshot.canonical_root == workspace.canonical_root
24728 }));
24729 assert!(shared
24730 .history
24731 .lock()
24732 .unwrap_or_else(|poisoned| poisoned.into_inner())
24733 .events
24734 .iter()
24735 .any(|event| matches!(
24736 &event.event,
24737 NodeEvent::WorkspaceAdded { workspace }
24738 if workspace.workspace_id == workspace_id
24739 )));
24740 std::fs::remove_dir_all(fixture_root).unwrap();
24741 }
24742
24743 #[tokio::test]
24744 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
24745 let fixture_root = temporary_workspace_root("standalone-compensation");
24746 let created_target = fixture_root.join("created-target");
24747 let existing_target = fixture_root.join("existing-target");
24748 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
24749 let invalid_state_path = invalid_state_parent.join("state.json");
24750 std::fs::create_dir_all(&existing_target).unwrap();
24751 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
24752 let catalog = active_registry().unwrap();
24753 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24754 let primary = WorkspaceConfig::new(
24755 WorkspaceId::new("primary").unwrap(),
24756 std::env::current_dir().unwrap(),
24757 )
24758 .unwrap();
24759 let mut shared = NodeShared::new(
24760 handle,
24761 "fixture-token".to_owned(),
24762 NodeId::new("node-standalone-compensation").unwrap(),
24763 vec![primary],
24764 vec![agent("claude")],
24765 );
24766 shared.state_path = Some(invalid_state_path);
24767
24768 let created_recovery = shared
24769 .create_standalone_workspace(
24770 WorkspaceId::new("compensated").unwrap(),
24771 created_target.to_string_lossy().into_owned(),
24772 Some("main".to_owned()),
24773 )
24774 .await
24775 .unwrap_err();
24776 assert_eq!(
24777 created_recovery.code,
24778 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
24779 );
24780 assert!(created_target.join(".git").is_dir());
24781 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
24782 workspace.workspace_id.as_str() != "compensated"
24783 }));
24784
24785 let existing_recovery = shared
24786 .create_standalone_workspace(
24787 WorkspaceId::new("restored").unwrap(),
24788 existing_target.to_string_lossy().into_owned(),
24789 None,
24790 )
24791 .await
24792 .unwrap_err();
24793 assert_eq!(
24794 existing_recovery.code,
24795 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
24796 );
24797 assert!(existing_target.is_dir());
24798 assert!(existing_target.join(".git").is_dir());
24799 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
24800 workspace.workspace_id.as_str() != "restored"
24801 }));
24802
24803 let registered_empty = fixture_root.join("registered-empty");
24804 std::fs::create_dir(®istered_empty).unwrap();
24805 let catalog = active_registry().unwrap();
24806 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24807 let registered = WorkspaceConfig::new(
24808 WorkspaceId::new("registered-empty").unwrap(),
24809 ®istered_empty,
24810 )
24811 .unwrap();
24812 let duplicate_shared = NodeShared::new(
24813 handle,
24814 "fixture-token".to_owned(),
24815 NodeId::new("node-standalone-duplicate-root").unwrap(),
24816 vec![registered],
24817 vec![agent("claude")],
24818 );
24819 let duplicate = duplicate_shared
24820 .create_standalone_workspace(
24821 WorkspaceId::new("duplicate-root").unwrap(),
24822 registered_empty.to_string_lossy().into_owned(),
24823 None,
24824 )
24825 .await
24826 .unwrap_err();
24827 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
24828 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
24829 std::fs::remove_dir_all(fixture_root).unwrap();
24830 }
24831
24832 #[test]
24833 fn managed_record_id_collision_preserves_the_existing_record() {
24834 let catalog = active_registry().unwrap();
24835 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24836 let workspace = WorkspaceConfig::new(
24837 WorkspaceId::new("primary").unwrap(),
24838 std::env::current_dir().unwrap(),
24839 )
24840 .unwrap();
24841 let shared = NodeShared::new(
24842 handle,
24843 "fixture-token".to_owned(),
24844 NodeId::new("node-1").unwrap(),
24845 vec![workspace.clone()],
24846 vec![agent("claude")],
24847 );
24848 let record_id = SessionRecordId::new("sr-collision").unwrap();
24849 let original = ManagedSessionRecord {
24850 record_id: record_id.clone(),
24851 display_name: "original".to_owned(),
24852 provider: agent("claude"),
24853 mode: SessionMode::Pty,
24854 state: ManagedSessionState::Dormant,
24855 workspace_id: workspace.workspace_id,
24856 canonical_root: opaque_windows_path(workspace.canonical_root),
24857 provider_session: None,
24858 active_session: None,
24859 environment_profile: None,
24860 bundle: None,
24861 context_id: None,
24862 context: None,
24863 exported_context: None,
24864 task_binding: None,
24865 created_at_unix_ms: 1,
24866 updated_at_unix_ms: 1,
24867 last_error: None,
24868 };
24869 shared.insert_record(original.clone()).unwrap();
24870 let mut collision = original.clone();
24871 collision.display_name = "replacement".to_owned();
24872 let error = shared.insert_record(collision).unwrap_err();
24873 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24874 assert_eq!(shared.record(&record_id).unwrap(), original);
24875 }
24876
24877 #[test]
24878 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
24879 let catalog = active_registry().unwrap();
24880 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24881 let workspace = WorkspaceConfig::new(
24882 WorkspaceId::new("primary").unwrap(),
24883 std::env::current_dir().unwrap(),
24884 )
24885 .unwrap();
24886 let shared = NodeShared::new(
24887 handle,
24888 "fixture-token".to_owned(),
24889 NodeId::new("node-1").unwrap(),
24890 vec![workspace.clone()],
24891 vec![agent("claude")],
24892 );
24893 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
24894 shared
24895 .insert_record(ManagedSessionRecord {
24896 record_id: record_id.clone(),
24897 display_name: "runtime error".to_owned(),
24898 provider: agent("claude"),
24899 mode: SessionMode::Pty,
24900 state: ManagedSessionState::IdentityPending,
24901 workspace_id: workspace.workspace_id,
24902 canonical_root: opaque_windows_path(workspace.canonical_root),
24903 provider_session: None,
24904 active_session: None,
24905 environment_profile: None,
24906 bundle: None,
24907 context_id: None,
24908 context: None,
24909 exported_context: None,
24910 task_binding: None,
24911 created_at_unix_ms: 1,
24912 updated_at_unix_ms: 1,
24913 last_error: None,
24914 })
24915 .unwrap();
24916
24917 shared
24918 .mark_record_error(
24919 &record_id,
24920 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
24921 )
24922 .unwrap();
24923 let record = shared.record(&record_id).unwrap();
24924 assert_eq!(record.state, ManagedSessionState::Unavailable);
24925 assert_eq!(
24926 record.last_error.as_deref(),
24927 Some("provider-runtime-failed"),
24928 );
24929 let snapshot = shared.snapshot();
24930 assert_eq!(snapshot.session_records, vec![record]);
24931 }
24932
24933 #[test]
24942 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
24943 let catalog = active_registry().unwrap();
24944 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24945 let workspace = WorkspaceConfig::new(
24946 WorkspaceId::new("primary").unwrap(),
24947 std::env::current_dir().unwrap(),
24948 )
24949 .unwrap();
24950 let shared = NodeShared::new(
24951 handle,
24952 "fixture-token".to_owned(),
24953 NodeId::new("node-1").unwrap(),
24954 vec![workspace],
24955 vec![agent("claude")],
24956 );
24957
24958 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
24959
24960 shared.record_rejected_command(
24961 CommandId(1),
24962 "agent 'claude' does not support transport Acp".to_owned(),
24963 true,
24964 );
24965 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
24967
24968 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
24969 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
24970 assert!(reason.unsupported_transport);
24971
24972 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
24974
24975 shared.record_rejected_command(
24980 CommandId(3),
24981 "backend busy".to_owned(),
24982 false,
24983 );
24984 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
24985 assert!(!unclassified.unsupported_transport);
24986 }
24987
24988 #[test]
24989 fn provider_identity_update_is_ignored_without_exact_session_policy() {
24990 let catalog = active_registry().unwrap();
24991 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24992 let workspace = WorkspaceConfig::new(
24993 WorkspaceId::new("primary").unwrap(),
24994 std::env::current_dir().unwrap(),
24995 )
24996 .unwrap();
24997 let shared = NodeShared::new(
24998 handle,
24999 "fixture-token".to_owned(),
25000 NodeId::new("node-identity-policy").unwrap(),
25001 vec![workspace.clone()],
25002 vec![agent("claude")],
25003 );
25004 let address = SessionAddress {
25005 workspace_id: workspace.workspace_id.clone(),
25006 session: SessionKey {
25007 instance_id: AgentInstanceId(90),
25008 generation: SessionGeneration(1),
25009 },
25010 };
25011 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
25012 shared
25013 .insert_record(ManagedSessionRecord {
25014 record_id: record_id.clone(),
25015 display_name: "policy denied".to_owned(),
25016 provider: agent("claude"),
25017 mode: SessionMode::Pty,
25018 state: ManagedSessionState::IdentityPending,
25019 workspace_id: workspace.workspace_id,
25020 canonical_root: opaque_windows_path(workspace.canonical_root),
25021 provider_session: None,
25022 active_session: Some(address.clone()),
25023 environment_profile: None,
25024 bundle: None,
25025 context_id: None,
25026 context: None,
25027 exported_context: None,
25028 task_binding: None,
25029 created_at_unix_ms: 1,
25030 updated_at_unix_ms: 1,
25031 last_error: None,
25032 })
25033 .unwrap();
25034 let event = ControlEvent {
25035 sequence: 1,
25036 command_id: None,
25037 instance_id: address.session.instance_id,
25038 generation: address.session.generation,
25039 event: ControlEventKind::ProviderEvent {
25040 sequence: 1,
25041 source: gate4agent_types::ProviderSource {
25042 family: AdapterFamily::PtySemantic,
25043 binding: AdapterBinding::new(
25044 gate4agent_types::AdapterId::new("claude").unwrap(),
25045 "fixture/v1",
25046 gate4agent_types::AdapterVerification::SyntheticFixture,
25047 )
25048 .unwrap(),
25049 },
25050 source_sequence: 1,
25051 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25052 identity: gate4agent_types::ProviderSessionIdentity {
25053 key: gate4agent_types::ProviderSessionKey::SessionId,
25054 id: "provider-session".to_owned(),
25055 transcript_path: None,
25056 },
25057 },
25058 },
25059 };
25060
25061 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
25062
25063 let record = shared.record(&record_id).unwrap();
25064 assert!(record.provider_session.is_none());
25065 assert_eq!(record.state, ManagedSessionState::IdentityPending);
25066 assert_eq!(record.active_session.as_ref(), Some(&address));
25067 }
25068
25069 #[test]
25070 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
25071 let secondary_root = temporary_workspace_root("identity-scope");
25072 std::fs::create_dir_all(&secondary_root).unwrap();
25073 let catalog = active_registry().unwrap();
25074 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25075 let primary = WorkspaceConfig::new(
25076 WorkspaceId::new("primary").unwrap(),
25077 std::env::current_dir().unwrap(),
25078 )
25079 .unwrap();
25080 let secondary = WorkspaceConfig::new(
25081 WorkspaceId::new("secondary").unwrap(),
25082 &secondary_root,
25083 )
25084 .unwrap();
25085 let shared = NodeShared::new(
25086 handle,
25087 "fixture-token".to_owned(),
25088 NodeId::new("node-1").unwrap(),
25089 vec![primary.clone(), secondary.clone()],
25090 vec![agent("claude")],
25091 );
25092 let identity = gate4agent_types::ProviderSessionIdentity {
25093 key: gate4agent_types::ProviderSessionKey::SessionId,
25094 id: "provider-session".to_owned(),
25095 transcript_path: None,
25096 };
25097 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
25098 shared
25099 .insert_record(ManagedSessionRecord {
25100 record_id: dormant_id.clone(),
25101 display_name: "dormant".to_owned(),
25102 provider: agent("claude"),
25103 mode: SessionMode::Pty,
25104 state: ManagedSessionState::Dormant,
25105 workspace_id: primary.workspace_id,
25106 canonical_root: opaque_windows_path(primary.canonical_root),
25107 provider_session: Some(identity.clone()),
25108 active_session: None,
25109 environment_profile: None,
25110 bundle: None,
25111 context_id: None,
25112 context: None,
25113 exported_context: None,
25114 task_binding: None,
25115 created_at_unix_ms: 1,
25116 updated_at_unix_ms: 1,
25117 last_error: None,
25118 })
25119 .unwrap();
25120 let address = SessionAddress {
25121 workspace_id: secondary.workspace_id.clone(),
25122 session: SessionKey {
25123 instance_id: AgentInstanceId(91),
25124 generation: SessionGeneration(1),
25125 },
25126 };
25127 let current_id = SessionRecordId::new("sr-current").unwrap();
25128 shared
25129 .insert_record(ManagedSessionRecord {
25130 record_id: current_id.clone(),
25131 display_name: "current".to_owned(),
25132 provider: agent("claude"),
25133 mode: SessionMode::Pty,
25134 state: ManagedSessionState::IdentityPending,
25135 workspace_id: secondary.workspace_id,
25136 canonical_root: opaque_windows_path(secondary.canonical_root),
25137 provider_session: None,
25138 active_session: Some(address.clone()),
25139 environment_profile: None,
25140 bundle: None,
25141 context_id: None,
25142 context: None,
25143 exported_context: None,
25144 task_binding: None,
25145 created_at_unix_ms: 2,
25146 updated_at_unix_ms: 2,
25147 last_error: None,
25148 })
25149 .unwrap();
25150 shared.bind_managed_session(
25151 &address,
25152 current_id.clone(),
25153 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25154 None,
25155 );
25156 let event = ControlEvent {
25157 sequence: 1,
25158 command_id: None,
25159 instance_id: address.session.instance_id,
25160 generation: address.session.generation,
25161 event: ControlEventKind::ProviderEvent {
25162 sequence: 1,
25163 source: gate4agent_types::ProviderSource {
25164 family: gate4agent_types::AdapterFamily::PtySemantic,
25165 binding: gate4agent_types::AdapterBinding::new(
25166 gate4agent_types::AdapterId::new("claude").unwrap(),
25167 "fixture/v1",
25168 gate4agent_types::AdapterVerification::SyntheticFixture,
25169 )
25170 .unwrap(),
25171 },
25172 source_sequence: 1,
25173 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25174 identity,
25175 },
25176 },
25177 };
25178 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
25179
25180 let binding = shared
25181 .session_bindings
25182 .lock()
25183 .unwrap_or_else(|poisoned| poisoned.into_inner())
25184 .get(&address.session.instance_id)
25185 .cloned()
25186 .unwrap();
25187 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
25188 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
25189 let current = shared.record(¤t_id).unwrap();
25190 assert_eq!(current.state, ManagedSessionState::Unavailable);
25191 assert_eq!(current.active_session.as_ref(), Some(&address));
25192 assert_eq!(
25193 current.last_error.as_deref(),
25194 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
25195 );
25196 std::fs::remove_dir_all(secondary_root).unwrap();
25197 }
25198
25199 #[tokio::test]
25200 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
25201 let root = temporary_workspace_root("semantic-provider-session");
25202 std::fs::create_dir_all(&root).unwrap();
25203 let state_path = root.join("state-v1.json");
25204 let catalog = active_registry().unwrap();
25205 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25206 let workspace = WorkspaceConfig::new(
25207 WorkspaceId::new("primary").unwrap(),
25208 &root,
25209 )
25210 .unwrap();
25211 let node_id = NodeId::new("node-semantic-identity").unwrap();
25212 let shared = NodeShared::new_with_incarnation(
25213 handle,
25214 "fixture-token".to_owned(),
25215 node_id.clone(),
25216 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
25217 vec![workspace.clone()],
25218 vec![agent("claude")],
25219 ProviderRuntimeStatuses::default(),
25220 None,
25221 Vec::new(),
25222 Vec::new(),
25223 SpawnProfileRegistry::default(),
25224 None,
25225 None,
25226 None,
25227 Some(state_path.clone()),
25228 Vec::new(),
25229 Vec::new(),
25230 Vec::new(),
25231 Vec::new(),
25232 None,
25233 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
25234 );
25235 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
25236 key: gate4agent_types::ProviderSessionKey::SessionId,
25237 id: "provider-session".to_owned(),
25238 transcript_path: None,
25239 };
25240 let durable_id = SessionRecordId::new("sr-durable").unwrap();
25241 shared
25242 .insert_record(ManagedSessionRecord {
25243 record_id: durable_id.clone(),
25244 display_name: "durable".to_owned(),
25245 provider: agent("claude"),
25246 mode: SessionMode::Pty,
25247 state: ManagedSessionState::Dormant,
25248 workspace_id: workspace.workspace_id.clone(),
25249 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25250 provider_session: Some(persisted_identity.clone()),
25251 active_session: None,
25252 environment_profile: None,
25253 bundle: None,
25254 context_id: None,
25255 context: None,
25256 exported_context: None,
25257 task_binding: None,
25258 created_at_unix_ms: 1,
25259 updated_at_unix_ms: 1,
25260 last_error: None,
25261 })
25262 .unwrap();
25263 let address = SessionAddress {
25264 workspace_id: workspace.workspace_id.clone(),
25265 session: SessionKey {
25266 instance_id: AgentInstanceId(92),
25267 generation: SessionGeneration(2),
25268 },
25269 };
25270 let pending_id = SessionRecordId::new("sr-pending").unwrap();
25271 shared
25272 .insert_record(ManagedSessionRecord {
25273 record_id: pending_id.clone(),
25274 display_name: "pending".to_owned(),
25275 provider: agent("claude"),
25276 mode: SessionMode::Pty,
25277 state: ManagedSessionState::IdentityPending,
25278 workspace_id: workspace.workspace_id.clone(),
25279 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25280 provider_session: None,
25281 active_session: Some(address.clone()),
25282 environment_profile: None,
25283 bundle: None,
25284 context_id: None,
25285 context: None,
25286 exported_context: None,
25287 task_binding: None,
25288 created_at_unix_ms: 2,
25289 updated_at_unix_ms: 2,
25290 last_error: None,
25291 })
25292 .unwrap();
25293 shared.bind_managed_session(
25294 &address,
25295 pending_id.clone(),
25296 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25297 None,
25298 );
25299 let observed_identity = gate4agent_types::ProviderSessionIdentity {
25300 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
25301 ..persisted_identity
25302 };
25303 let event = ControlEvent {
25304 sequence: 1,
25305 command_id: None,
25306 instance_id: address.session.instance_id,
25307 generation: address.session.generation,
25308 event: ControlEventKind::ProviderEvent {
25309 sequence: 1,
25310 source: gate4agent_types::ProviderSource {
25311 family: gate4agent_types::AdapterFamily::PtySemantic,
25312 binding: gate4agent_types::AdapterBinding::new(
25313 gate4agent_types::AdapterId::new("claude").unwrap(),
25314 "fixture/v1",
25315 gate4agent_types::AdapterVerification::SyntheticFixture,
25316 )
25317 .unwrap(),
25318 },
25319 source_sequence: 1,
25320 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25321 identity: observed_identity.clone(),
25322 },
25323 },
25324 };
25325 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
25326
25327 let snapshot = shared.snapshot();
25328 assert_eq!(snapshot.session_records.len(), 1);
25329 let live = &snapshot.session_records[0];
25330 assert_eq!(live.record_id, durable_id);
25331 assert_eq!(live.state, ManagedSessionState::Live);
25332 assert_eq!(live.active_session.as_ref(), Some(&address));
25333 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
25334 let binding = shared
25335 .session_bindings
25336 .lock()
25337 .unwrap_or_else(|poisoned| poisoned.into_inner())
25338 .get(&address.session.instance_id)
25339 .cloned()
25340 .unwrap();
25341 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
25342
25343 let mut duplicate = live.clone();
25344 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
25345 duplicate.display_name = "duplicate".to_owned();
25346 duplicate.state = ManagedSessionState::Dormant;
25347 duplicate.active_session = None;
25348 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
25349 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
25350 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
25351 assert_eq!(shared.snapshot().session_records.len(), 1);
25352
25353 let terminal_size = gate4agent_types::TerminalSize {
25354 rows: 24,
25355 columns: 80,
25356 };
25357 let (first_busy, second_busy) = tokio::join!(
25358 shared.resume_session_record(&durable_id, terminal_size, None),
25359 shared.resume_session_record(&durable_id, terminal_size, None),
25360 );
25361 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
25362 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
25363 assert_eq!(busy.message, "session-record-busy");
25364 }
25365
25366 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
25367 assert_eq!(loaded.records.len(), 1);
25368 assert_eq!(loaded.records[0].record_id, durable_id);
25369 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
25370 assert_eq!(
25371 loaded.records[0]
25372 .provider_session
25373 .as_ref()
25374 .unwrap()
25375 .transcript_path,
25376 None,
25377 );
25378 let catalog = active_registry().unwrap();
25379 let (reloaded_handle, _reloaded_runtime) =
25380 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25381 let reloaded = NodeShared::new_with_incarnation(
25382 reloaded_handle,
25383 "fixture-token".to_owned(),
25384 node_id,
25385 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
25386 vec![workspace],
25387 vec![agent("claude")],
25388 ProviderRuntimeStatuses::default(),
25389 None,
25390 Vec::new(),
25391 Vec::new(),
25392 SpawnProfileRegistry::default(),
25393 None,
25394 None,
25395 None,
25396 None,
25397 loaded.records,
25398 loaded.managed_worktrees,
25399 loaded.managed_worktree_tombstones,
25400 loaded.materializations,
25401 loaded.warning,
25402 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
25403 );
25404 assert_eq!(reloaded.snapshot().session_records.len(), 1);
25405 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
25406 std::fs::remove_dir_all(root).unwrap();
25407 }
25408
25409 #[tokio::test]
25410 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
25411 let secondary_root = temporary_workspace_root("workspace-restore");
25412 let other_root = temporary_workspace_root("workspace-conflict");
25413 std::fs::create_dir_all(&secondary_root).unwrap();
25414 std::fs::create_dir_all(&other_root).unwrap();
25415 let catalog = active_registry().unwrap();
25416 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25417 let primary = WorkspaceConfig::new(
25418 WorkspaceId::new("primary").unwrap(),
25419 std::env::current_dir().unwrap(),
25420 )
25421 .unwrap();
25422 let secondary = WorkspaceConfig::new(
25423 WorkspaceId::new("secondary").unwrap(),
25424 &secondary_root,
25425 )
25426 .unwrap();
25427 let shared = NodeShared::new(
25428 handle,
25429 "fixture-token".to_owned(),
25430 NodeId::new("node-1").unwrap(),
25431 vec![primary, secondary.clone()],
25432 vec![agent("claude")],
25433 );
25434 let record_id = SessionRecordId::new("sr-workspace").unwrap();
25435 shared
25436 .insert_record(ManagedSessionRecord {
25437 record_id: record_id.clone(),
25438 display_name: "workspace record".to_owned(),
25439 provider: agent("claude"),
25440 mode: SessionMode::Pty,
25441 state: ManagedSessionState::Dormant,
25442 workspace_id: secondary.workspace_id.clone(),
25443 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
25444 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
25445 key: gate4agent_types::ProviderSessionKey::SessionId,
25446 id: "provider-session".to_owned(),
25447 transcript_path: None,
25448 }),
25449 active_session: None,
25450 environment_profile: None,
25451 bundle: None,
25452 context_id: None,
25453 context: None,
25454 exported_context: None,
25455 task_binding: None,
25456 created_at_unix_ms: 1,
25457 updated_at_unix_ms: 1,
25458 last_error: None,
25459 })
25460 .unwrap();
25461 shared.unregister_workspace(&secondary.workspace_id).unwrap();
25462 let unavailable = shared.record(&record_id).unwrap();
25463 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
25464 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
25465
25466 let conflict = shared
25467 .register_workspace(
25468 secondary.workspace_id.clone(),
25469 other_root.to_string_lossy().into_owned(),
25470 )
25471 .await
25472 .unwrap_err();
25473 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
25474 shared
25475 .register_workspace(
25476 secondary.workspace_id,
25477 secondary_root.to_string_lossy().into_owned(),
25478 )
25479 .await
25480 .unwrap();
25481 let restored = shared.record(&record_id).unwrap();
25482 assert_eq!(restored.state, ManagedSessionState::Dormant);
25483 assert!(restored.last_error.is_none());
25484 std::fs::remove_dir_all(secondary_root).unwrap();
25485 std::fs::remove_dir_all(other_root).unwrap();
25486 }
25487
25488 #[test]
25489 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
25490 let error = validate_workspace_request_root(
25491 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
25492 )
25493 .unwrap_err();
25494 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
25495 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
25496 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
25497 }
25498
25499 #[test]
25500 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
25501 let catalog = active_registry().unwrap();
25502 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25503 let workspace = WorkspaceConfig::new(
25504 WorkspaceId::new("primary").unwrap(),
25505 std::env::current_dir().unwrap(),
25506 )
25507 .unwrap();
25508 let shared = NodeShared::new(
25509 handle,
25510 "fixture-token".to_owned(),
25511 NodeId::new("node-1").unwrap(),
25512 vec![workspace],
25513 vec![agent("claude")],
25514 );
25515 let publishers = 4_u64;
25516 let events_per_publisher = 256_u64;
25517 let start = std::sync::Barrier::new(publishers as usize + 1);
25518 let finished = AtomicU64::new(0);
25519
25520 std::thread::scope(|scope| {
25521 for _ in 0..publishers {
25522 scope.spawn(|| {
25523 start.wait();
25524 for _ in 0..events_per_publisher {
25525 shared.publish(NodeEvent::ControllerChanged { controller: None });
25526 }
25527 finished.fetch_add(1, Ordering::Release);
25528 });
25529 }
25530 start.wait();
25531 while finished.load(Ordering::Acquire) != publishers {
25532 assert_resync_history_is_linearized(shared.resync(0));
25533 std::thread::yield_now();
25534 }
25535 });
25536 let response = shared.resync(0);
25537 assert_resync_history_is_linearized(response.clone());
25538 let NodeResponse::Resync { event_sequence, events, .. } = response else {
25539 unreachable!("resync helper returned another response");
25540 };
25541 assert_eq!(event_sequence, publishers * events_per_publisher);
25542 assert_eq!(events.len() as u64, event_sequence);
25543 }
25544
25545 fn assert_resync_history_is_linearized(response: NodeResponse) {
25546 let NodeResponse::Resync { event_sequence, events, .. } = response else {
25547 unreachable!("resync helper returned another response");
25548 };
25549 assert!(events.windows(2).all(|pair| {
25550 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
25551 }));
25552 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
25553 }
25554
25555 #[test]
25556 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
25557 assert_eq!(
25558 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
25559 r"C:\repo\worktree",
25560 );
25561 assert_eq!(
25562 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
25563 r"\\server\share\repo",
25564 );
25565 assert!(validate_workspace_root(
25566 &WorkspaceId::new("network").unwrap(),
25567 r"\\server\share\repo",
25568 )
25569 .is_err());
25570 let workspace = WorkspaceConfig::new(
25571 WorkspaceId::new("primary").unwrap(),
25572 std::env::current_dir().unwrap(),
25573 )
25574 .unwrap();
25575 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
25576 }
25577
25578 #[test]
25579 fn resume_rebinds_only_on_the_expected_authorized_generation() {
25580 let catalog = active_registry().unwrap();
25581 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25582 let workspace_id = WorkspaceId::new("primary").unwrap();
25583 let workspace = WorkspaceConfig::new(
25584 workspace_id.clone(),
25585 std::env::current_dir().unwrap(),
25586 )
25587 .unwrap();
25588 let shared = NodeShared::new(
25589 handle,
25590 "fixture-token".to_owned(),
25591 NodeId::new("node-1").unwrap(),
25592 vec![workspace],
25593 vec![agent("claude")],
25594 );
25595 let instance_id = AgentInstanceId(17);
25596 let original = SessionAddress {
25597 workspace_id,
25598 session: SessionKey {
25599 instance_id,
25600 generation: SessionGeneration(3),
25601 },
25602 };
25603 shared.bind_session(
25604 instance_id,
25605 original.workspace_id.clone(),
25606 original.session.generation,
25607 );
25608 shared
25609 .arm_resume(
25610 &original,
25611 CommandId(41),
25612 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25613 )
25614 .unwrap();
25615
25616 shared.publish_control(ControlEvent {
25617 sequence: 1,
25618 command_id: None,
25619 instance_id,
25620 generation: SessionGeneration(4),
25621 event: ControlEventKind::Running { process_id: None },
25622 });
25623 assert_eq!(
25624 shared.address_for(instance_id, SessionGeneration(3)),
25625 Some(original.clone()),
25626 );
25627 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
25628
25629 shared.publish_control(ControlEvent {
25630 sequence: 2,
25631 command_id: Some(CommandId(42)),
25632 instance_id,
25633 generation: SessionGeneration(3),
25634 event: ControlEventKind::CommandRejected {
25635 message: "unrelated command".to_owned(),
25636 },
25637 });
25638
25639 shared.publish_control(ControlEvent {
25640 sequence: 3,
25641 command_id: None,
25642 instance_id,
25643 generation: SessionGeneration(4),
25644 event: ControlEventKind::ResumeAuthorized {
25645 session: gate4agent_types::ResumeSessionSummary {
25646 key: gate4agent_types::ProviderSessionKey::SessionId,
25647 id: "fixture-session".to_owned(),
25648 },
25649 },
25650 });
25651 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
25652 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
25653 assert_eq!(rebound.workspace_id, original.workspace_id);
25654 let history = shared
25655 .history
25656 .lock()
25657 .unwrap_or_else(|poisoned| poisoned.into_inner());
25658 assert!(history.events.iter().any(|envelope| matches!(
25659 &envelope.event,
25660 NodeEvent::Control { address, event }
25661 if address == &rebound
25662 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
25663 )));
25664 }
25665
25666 #[test]
25667 fn all_node_pty_prompts_use_bracketed_paste_framing() {
25668 for agent in ["claude", "codex", "kimi"] {
25669 assert_eq!(
25670 prompt_framing(&AgentId::new(agent).unwrap()),
25671 PromptFraming::BracketedPaste
25672 );
25673 }
25674 }
25675
25676 fn generous_walk_deadline() -> Instant {
25677 Instant::now() + Duration::from_secs(30)
25678 }
25679
25680 #[test]
25681 fn workspace_tree_is_relative_and_skips_heavy_directories() {
25682 let root = temporary_workspace_root("tree");
25683 std::fs::create_dir_all(root.join("src")).unwrap();
25684 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
25685 std::fs::create_dir_all(root.join("target/debug")).unwrap();
25686 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
25687 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
25688 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
25689 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
25695 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
25696 std::fs::create_dir_all(root.join("target-notes")).unwrap();
25700 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
25701
25702 let (entries, truncated, _budget) =
25703 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
25704 assert!(!truncated);
25705 assert!(entries.iter().any(|entry| {
25706 entry.relative_path.as_utf8() == Some("src")
25707 && entry.kind == WorkspaceEntryKind::Directory
25708 }));
25709 assert!(entries.iter().any(|entry| {
25710 entry.relative_path.as_utf8() == Some("src/lib.rs")
25711 && entry.kind == WorkspaceEntryKind::File
25712 }));
25713 assert!(entries.iter().any(|entry| {
25714 entry.relative_path.as_utf8() == Some("target-notes")
25715 && entry.kind == WorkspaceEntryKind::Directory
25716 }));
25717 assert!(entries.iter().any(|entry| {
25718 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
25719 && entry.kind == WorkspaceEntryKind::File
25720 }));
25721 assert!(entries.iter().all(|entry| {
25722 let path = entry.relative_path.as_utf8().unwrap();
25723 !path.starts_with(".git")
25724 && !(path == "target" || path.starts_with("target/"))
25725 && !(path == "target-a2" || path.starts_with("target-a2/"))
25726 && !path.starts_with("node_modules")
25727 }));
25728
25729 std::fs::remove_dir_all(&root).unwrap();
25730 }
25731
25732 #[test]
25733 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
25734 let root = temporary_workspace_root("tree-entry-cap");
25735 std::fs::create_dir_all(&root).unwrap();
25736 for index in 0..20 {
25737 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
25738 }
25739
25740 let (entries, truncated, budget) =
25741 collect_workspace_entries(&root, generous_walk_deadline(), 5);
25742 assert!(truncated);
25743 assert!(budget.entry_cap_exceeded);
25744 assert!(!budget.time_budget_exceeded);
25745 assert!(entries.len() <= 5);
25746
25747 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
25748 .expect("an entry-cap-truncated walk must report a truncation marker");
25749 assert!(inspection_truncation.walk_entry_cap_exceeded);
25750 assert!(!inspection_truncation.walk_time_budget_exceeded);
25751 assert!(!inspection_truncation.git_time_budget_exceeded);
25752 assert!(inspection_truncation.entries_visited >= 5);
25753
25754 std::fs::remove_dir_all(&root).unwrap();
25755 }
25756
25757 #[test]
25758 fn workspace_walk_within_budget_reports_no_truncation() {
25759 let root = temporary_workspace_root("tree-no-truncation");
25760 std::fs::create_dir_all(&root).unwrap();
25761 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
25762
25763 let (_entries, truncated, budget) =
25764 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
25765 assert!(!truncated);
25766 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
25767
25768 std::fs::remove_dir_all(&root).unwrap();
25769 }
25770
25771 #[test]
25772 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
25773 let root = temporary_workspace_root("tree-prefix-siblings");
25774 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
25775 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
25776 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
25777 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
25778
25779 let (entries, truncated, _budget) =
25780 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
25781 assert!(!truncated);
25782 let paths = entries
25783 .iter()
25784 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
25785 .collect::<Vec<_>>();
25786 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
25787 assert_eq!(
25788 paths,
25789 vec![
25790 "gate4agent-c2".to_owned(),
25791 "gate4agent-c2-client".to_owned(),
25792 "gate4agent-c2-client/lib.rs".to_owned(),
25793 "gate4agent-c2/src".to_owned(),
25794 "gate4agent-c2/src/lib.rs".to_owned(),
25795 ],
25796 );
25797
25798 std::fs::remove_dir_all(&root).unwrap();
25799 }
25800
25801 #[test]
25802 fn git_parsers_cap_status_and_commit_results() {
25803 let mut status_output = Vec::new();
25804 for index in 0..=GIT_STATUS_MAX_ENTRIES {
25805 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
25806 }
25807 let mut snapshot = git_snapshot_for_parser();
25808 parse_git_status(&status_output, &mut snapshot);
25809 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
25810 assert!(snapshot.truncated);
25811 assert_eq!(snapshot.status[0].index_status, " ");
25812 assert_eq!(snapshot.status[0].worktree_status, "M");
25813
25814 snapshot.truncated = false;
25815 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
25816 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
25817 .collect::<String>();
25818 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
25819 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
25820 assert!(snapshot.truncated);
25821 }
25822
25823 #[test]
25824 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
25825 let mut snapshot = git_snapshot_for_parser();
25826 parse_git_status(
25827 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
25828 &mut snapshot,
25829 );
25830
25831 assert!(!snapshot.truncated);
25832 assert_eq!(snapshot.status.len(), 3);
25833 assert_eq!(snapshot.status[0].index_status, "R");
25834 assert_eq!(snapshot.status[0].worktree_status, " ");
25835 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
25836 assert_eq!(
25837 snapshot.status[0]
25838 .previous_path
25839 .as_ref()
25840 .and_then(RepositoryPath::as_utf8),
25841 Some("src/previous name.rs"),
25842 );
25843 assert_eq!(snapshot.status[1].index_status, " ");
25844 assert_eq!(snapshot.status[1].worktree_status, "C");
25845 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
25846 assert_eq!(
25847 snapshot.status[1]
25848 .previous_path
25849 .as_ref()
25850 .and_then(RepositoryPath::as_utf8),
25851 Some("src/original.rs"),
25852 );
25853 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
25854 assert!(snapshot.status[2].previous_path.is_none());
25855 }
25856
25857 #[test]
25858 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
25859 let mut snapshot = git_snapshot_for_parser();
25860 parse_git_status(
25861 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
25862 &mut snapshot,
25863 );
25864
25865 assert!(snapshot.truncated);
25866 assert_eq!(snapshot.status.len(), 1);
25867 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
25868 assert!(snapshot.status[0].previous_path.is_none());
25869 }
25870
25871 #[tokio::test]
25872 async fn observer_can_browse_host_directories_without_controller() {
25873 let root = temporary_workspace_root("host-directory-observer");
25874 std::fs::create_dir_all(root.join("directory-b")).unwrap();
25875 std::fs::create_dir_all(root.join("directory-a")).unwrap();
25876 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
25877 let workspace_id = WorkspaceId::new("primary").unwrap();
25878 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
25879 let catalog = active_registry().unwrap();
25880 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25881 let shared = NodeShared::new(
25882 handle,
25883 "fixture-token".to_owned(),
25884 NodeId::new("node-1").unwrap(),
25885 vec![workspace],
25886 vec![agent("claude")],
25887 );
25888 let request = NodeRequest::BrowseHostDirectories {
25889 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
25890 after: None,
25891 };
25892 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25893 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
25894 }));
25895 assert!(!request_uses_unnegotiated_capability(
25896 &request,
25897 &baseline_capabilities().unwrap(),
25898 ));
25899 assert!(request_uses_unnegotiated_capability(&request, &[]));
25900
25901 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
25902 .await
25903 .unwrap();
25904 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
25905 panic!("host directory browse returned another response");
25906 };
25907 assert_eq!(
25908 listing
25909 .entries
25910 .iter()
25911 .map(|entry| entry.display_name.as_str())
25912 .collect::<Vec<_>>(),
25913 vec!["directory-a", "directory-b"],
25914 );
25915 assert!(listing.entries.iter().all(|entry| !entry.is_link));
25916 assert!(!listing.incomplete);
25917 std::fs::remove_dir_all(root).unwrap();
25918 }
25919
25920 #[tokio::test]
25921 async fn observer_can_inspect_only_a_registered_workspace() {
25922 let root = temporary_workspace_root("observer");
25923 std::fs::create_dir_all(root.join("src")).unwrap();
25924 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
25925 let workspace_id = WorkspaceId::new("primary").unwrap();
25926 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
25927 let catalog = active_registry().unwrap();
25928 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25929 let shared = NodeShared::new(
25930 handle,
25931 "fixture-token".to_owned(),
25932 NodeId::new("node-1").unwrap(),
25933 vec![workspace],
25934 vec![agent("claude")],
25935 );
25936
25937 let response = process_request_inner(
25938 &shared,
25939 77,
25940 ClientRole::Observer,
25941 NodeRequest::InspectWorkspace {
25942 workspace_id: workspace_id.clone(),
25943 },
25944 )
25945 .await
25946 .unwrap();
25947 let NodeResponse::WorkspaceInspected { inspection } = response else {
25948 panic!("workspace inspection returned another response");
25949 };
25950 assert_eq!(inspection.workspace_id, workspace_id);
25951 assert!(inspection.entries.iter().any(|entry| {
25952 entry.relative_path.as_utf8() == Some("src/main.rs")
25953 }));
25954
25955 let unknown = process_request_inner(
25956 &shared,
25957 77,
25958 ClientRole::Observer,
25959 NodeRequest::InspectWorkspace {
25960 workspace_id: WorkspaceId::new("unknown").unwrap(),
25961 },
25962 )
25963 .await
25964 .unwrap_err();
25965 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
25966 std::fs::remove_dir_all(&root).unwrap();
25967 }
25968
25969 #[test]
25970 fn context_pack_source_accepts_only_running_or_successful_exit() {
25971 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
25972 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
25973 exit_code: Some(0),
25974 }));
25975 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
25976 exit_code: Some(1),
25977 }));
25978 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
25979 exit_code: None,
25980 }));
25981 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
25982 message: "provider failed".to_owned(),
25983 }));
25984 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
25985 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
25986 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
25987 }
25988
25989 #[test]
25990 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
25991 let git = git_snapshot_for_parser();
25992 let first = ContextPackRepositoryObservation {
25993 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
25994 git: git.clone(),
25995 files: Vec::new(),
25996 };
25997 let second = ContextPackRepositoryObservation {
25998 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
25999 git,
26000 files: Vec::new(),
26001 };
26002
26003 let error = stable_context_pack_repository(first, second).unwrap_err();
26004
26005 assert_eq!(
26006 error.code,
26007 NodeFailureCode::ContextPackMaterializationFailed,
26008 );
26009 }
26010
26011 #[test]
26012 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
26013 let git = git_snapshot_for_parser();
26014 let first = ContextPackRepositoryObservation {
26015 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26016 git: git.clone(),
26017 files: vec![ContextPackRepositoryFileSource::utf8(
26018 "README.md",
26019 "first\n".to_owned(),
26020 6,
26021 )],
26022 };
26023 let second = ContextPackRepositoryObservation {
26024 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26025 git,
26026 files: vec![ContextPackRepositoryFileSource::utf8(
26027 "README.md",
26028 "other\n".to_owned(),
26029 6,
26030 )],
26031 };
26032
26033 let error = stable_context_pack_repository(first, second).unwrap_err();
26034
26035 assert_eq!(
26036 error.code,
26037 NodeFailureCode::ContextPackMaterializationFailed,
26038 );
26039 }
26040
26041 #[tokio::test]
26042 async fn native_session_catalog_zero_active_session_fixture() {
26043 let workspace_root = temporary_workspace_root("native-catalog-workspace");
26044 let history_root = temporary_workspace_root("native-catalog-history");
26045 let outside_root = temporary_workspace_root("native-catalog-outside");
26046 std::fs::create_dir_all(&workspace_root).unwrap();
26047 std::fs::create_dir_all(&history_root).unwrap();
26048 std::fs::create_dir_all(&outside_root).unwrap();
26049 let transcript = |session_id: &str, cwd: &Path, title: &str| {
26050 [
26051 serde_json::json!({
26052 "type": "user",
26053 "sessionId": session_id,
26054 "cwd": cwd.to_str().unwrap(),
26055 "message": { "content": "raw user text must not cross the catalog wire" }
26056 }),
26057 serde_json::json!({
26058 "type": "assistant",
26059 "sessionId": session_id,
26060 "cwd": cwd.to_str().unwrap(),
26061 "message": { "model": "claude-sonnet", "content": "raw answer" }
26062 }),
26063 serde_json::json!({
26064 "type": "ai-title",
26065 "sessionId": session_id,
26066 "aiTitle": title
26067 }),
26068 ]
26069 .into_iter()
26070 .map(|record| record.to_string())
26071 .collect::<Vec<_>>()
26072 .join("\n")
26073 };
26074 std::fs::write(
26075 history_root.join("stable-session.jsonl"),
26076 transcript("stable-session", &workspace_root, "Workspace review"),
26077 )
26078 .unwrap();
26079 std::fs::write(
26080 history_root.join("outside-session.jsonl"),
26081 transcript("outside-session", &outside_root, "Outside review"),
26082 )
26083 .unwrap();
26084 let history = NativeHistoryConfig::new(vec![
26085 NativeHistoryRoot::new(
26086 gate4agent_types::AdapterId::new("claude-code").unwrap(),
26087 HistorySourceLayout::SingleNdjson,
26088 history_root.clone(),
26089 )
26090 .unwrap(),
26091 ])
26092 .unwrap();
26093 let catalog = active_registry().unwrap();
26094 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26095 assert!(handle.snapshot().sessions.is_empty());
26096 let workspace_id = WorkspaceId::new("primary").unwrap();
26097 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
26098 let mut shared = NodeShared::new(
26099 handle,
26100 "fixture-token".to_owned(),
26101 NodeId::new("node-native-catalog").unwrap(),
26102 vec![workspace],
26103 vec![agent("claude")],
26104 );
26105 shared.native_session_catalog = Some(Arc::new(Mutex::new(
26106 NativeSessionCatalogAuthority::new(history),
26107 )));
26108 let route = NativeSessionCatalogRoute::workspace(
26109 workspace_id.clone(),
26110 agent("claude"),
26111 );
26112 let request = NodeRequest::CatalogNativeSessions {
26113 route: route.clone(),
26114 limit: 2,
26115 };
26116 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
26117 .await
26118 .unwrap_err();
26119 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26120 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
26121 .await
26122 .unwrap();
26123 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
26124 panic!("native catalog returned a different response");
26125 };
26126 let summary = summary.expect("node must emit native session catalog summary metadata");
26127 assert_eq!(entries.len(), 1);
26128 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
26129 assert_eq!(entries[0].external_group, None);
26130 assert_eq!(entries[0].record_id, None);
26131 assert_eq!(entries[0].title, None);
26132 assert_eq!(entries[0].model, None);
26133 assert_eq!(entries[0].message_count, 0);
26134 let encoded_entries = serde_json::to_string(&entries).unwrap();
26135 assert!(!encoded_entries.contains("stable-session"));
26136 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
26137 let page_request = NodeRequest::PageNativeSessions {
26138 route: route.clone(),
26139 window: crate::protocol::NativeSessionCatalogWindow::Recent,
26140 catalog_revision: summary.catalog_revision,
26141 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26142 after_selection_id: None,
26143 limit: 2,
26144 };
26145 let observer = process_request_inner(
26146 &shared,
26147 7,
26148 ClientRole::Observer,
26149 page_request.clone(),
26150 )
26151 .await
26152 .unwrap_err();
26153 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26154 let page_response = process_request_inner(
26155 &shared,
26156 7,
26157 ClientRole::Operator,
26158 page_request.clone(),
26159 )
26160 .await
26161 .unwrap();
26162 assert!(matches!(
26163 page_response,
26164 NodeResponse::NativeSessionsPaged { page, .. }
26165 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
26166 ));
26167 let stale = process_request_inner(
26168 &shared,
26169 7,
26170 ClientRole::Operator,
26171 NodeRequest::PageNativeSessions {
26172 route: route.clone(),
26173 window: crate::protocol::NativeSessionCatalogWindow::Recent,
26174 catalog_revision: summary.catalog_revision.wrapping_add(1),
26175 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26176 after_selection_id: None,
26177 limit: 2,
26178 },
26179 )
26180 .await
26181 .unwrap_err();
26182 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
26183 let selection = NativeSessionSelection {
26184 route: route.clone(),
26185 catalog_revision: summary.catalog_revision,
26186 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26187 selection_id: entries[0].selection_id.clone(),
26188 };
26189 let preview_response = process_request_inner(
26190 &shared,
26191 7,
26192 ClientRole::Operator,
26193 NodeRequest::PreviewNativeSession {
26194 selection: selection.clone(),
26195 message_limit: 1,
26196 },
26197 )
26198 .await
26199 .unwrap();
26200 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
26201 panic!("native preview returned a different response");
26202 };
26203 assert_eq!(preview.messages.len(), 1);
26204 assert!(preview.message_count_exact);
26205 assert_eq!(preview.messages[0].text, "raw answer");
26206 assert!(preview.truncated);
26207
26208 let index_request = NodeRequest::IndexNativeSession {
26209 selection: selection.clone(),
26210 display_name: "Saved Claude".to_owned(),
26211 };
26212 let observer = process_request_inner(
26213 &shared,
26214 7,
26215 ClientRole::Observer,
26216 index_request.clone(),
26217 )
26218 .await
26219 .unwrap_err();
26220 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26221 let without_controller = process_request_inner(
26222 &shared,
26223 7,
26224 ClientRole::Operator,
26225 index_request.clone(),
26226 )
26227 .await
26228 .unwrap_err();
26229 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
26230 let acquired = process_request_inner(
26231 &shared,
26232 7,
26233 ClientRole::Operator,
26234 NodeRequest::AcquireController { lease_ms: 5_000 },
26235 )
26236 .await
26237 .unwrap();
26238 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
26239 let indexed = process_request_inner(
26240 &shared,
26241 7,
26242 ClientRole::Operator,
26243 index_request,
26244 )
26245 .await
26246 .unwrap();
26247 assert!(indexed.native_session_index_contract_is_valid());
26248 let NodeResponse::NativeSessionIndexed {
26249 selection: echoed_selection,
26250 record,
26251 } = indexed else {
26252 panic!("native session index returned a different response");
26253 };
26254 assert_eq!(echoed_selection, selection);
26255 let recataloged = process_request_inner(
26256 &shared,
26257 7,
26258 ClientRole::Operator,
26259 NodeRequest::CatalogNativeSessions {
26260 route,
26261 limit: 2,
26262 },
26263 )
26264 .await
26265 .unwrap();
26266 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
26267 panic!("native catalog returned a different response after indexing");
26268 };
26269 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
26270
26271 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
26272 let external_catalog = process_request_inner(
26273 &shared,
26274 7,
26275 ClientRole::Operator,
26276 NodeRequest::CatalogNativeSessions {
26277 route: unregistered_route.clone(),
26278 limit: 2,
26279 },
26280 )
26281 .await
26282 .unwrap();
26283 let external_json = serde_json::to_string(&external_catalog).unwrap();
26284 assert!(!external_json.contains("outside-session"));
26285 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
26286 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
26287 assert!(!external_json.contains("\"session_id\""));
26288 assert!(!external_json.contains("\"cwd\""));
26289 assert!(!external_json.contains("\"path\""));
26290 let NodeResponse::NativeSessionsCataloged {
26291 route: echoed_route,
26292 entries: external_entries,
26293 summary: Some(external_summary),
26294 } = external_catalog
26295 else {
26296 panic!("unregistered native catalog returned a different response");
26297 };
26298 assert_eq!(echoed_route, unregistered_route);
26299 assert_eq!(external_entries.len(), 1);
26300 assert_eq!(external_entries[0].record_id, None);
26301 let external_group = external_entries[0]
26302 .external_group
26303 .as_ref()
26304 .expect("unregistered session must have an external group");
26305 assert_eq!(
26306 external_group.kind,
26307 gate4agent_types::NativeSessionExternalGroupKind::Project,
26308 );
26309 assert!(external_group.group_id.starts_with("external-"));
26310 assert_eq!(
26311 external_group.display_name,
26312 outside_root.file_name().unwrap().to_string_lossy(),
26313 );
26314 let external_selection = NativeSessionSelection {
26315 route: unregistered_route,
26316 catalog_revision: external_summary.catalog_revision,
26317 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
26318 selection_id: external_entries[0].selection_id.clone(),
26319 };
26320 let external_preview = process_request_inner(
26321 &shared,
26322 7,
26323 ClientRole::Operator,
26324 NodeRequest::PreviewNativeSession {
26325 selection: external_selection.clone(),
26326 message_limit: 1,
26327 },
26328 )
26329 .await
26330 .unwrap();
26331 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
26332 assert!(!external_preview_json.contains("outside-session"));
26333 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
26334 assert!(!external_preview_json.contains("\"session_id\""));
26335 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
26336 panic!("unregistered native preview returned a different response");
26337 };
26338 assert_eq!(preview.messages.len(), 1);
26339 let external_index = process_request_inner(
26340 &shared,
26341 7,
26342 ClientRole::Operator,
26343 NodeRequest::IndexNativeSession {
26344 selection: external_selection,
26345 display_name: "External Claude".to_owned(),
26346 },
26347 )
26348 .await
26349 .unwrap_err();
26350 assert_eq!(
26351 external_index.code,
26352 NodeFailureCode::WorkspaceRegistrationRequired,
26353 );
26354 let record_response = process_request_inner(
26355 &shared,
26356 7,
26357 ClientRole::Operator,
26358 NodeRequest::PreviewSessionRecord {
26359 record_id: record.record_id.clone(),
26360 message_limit: 2,
26361 },
26362 )
26363 .await
26364 .unwrap();
26365 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
26366 panic!("record preview returned a different response");
26367 };
26368 assert_eq!(record_id, record.record_id);
26369 assert_eq!(preview.messages.len(), 2);
26370 let encoded = serde_json::to_string(&preview).unwrap();
26371 assert!(!encoded.contains("stable-session"));
26372 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
26373 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
26374 panic!("record preview history summary returned a different response");
26375 };
26376 let summaries = events
26377 .iter()
26378 .filter_map(|envelope| match &envelope.event {
26379 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
26380 if record_id == &record.record_id =>
26381 {
26382 Some(*summary)
26383 }
26384 _ => None,
26385 })
26386 .collect::<Vec<_>>();
26387 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
26388 let summary_json = serde_json::to_string(&summaries).unwrap();
26389 for private in [
26390 "stable-session",
26391 "raw user text must not cross the catalog wire",
26392 "raw answer",
26393 "Workspace review",
26394 "claude-sonnet",
26395 ] {
26396 assert!(!summary_json.contains(private), "{private}");
26397 }
26398 let identity = record.provider_session.clone().unwrap();
26399 assert_eq!(
26400 shared.revalidate_session_record_preview(&record, &identity),
26401 Ok(())
26402 );
26403 shared.remove_record_memory(&record.record_id);
26404 assert_eq!(
26405 shared
26406 .revalidate_session_record_preview(&record, &identity)
26407 .unwrap_err()
26408 .code,
26409 NodeFailureCode::UnknownSessionRecord
26410 );
26411 let released = process_request_inner(
26412 &shared,
26413 7,
26414 ClientRole::Operator,
26415 NodeRequest::ReleaseController,
26416 )
26417 .await
26418 .unwrap();
26419 assert!(matches!(released, NodeResponse::Controller { controller: None }));
26420 assert!(shared.controller_state().is_none());
26421 std::fs::remove_dir_all(workspace_root).unwrap();
26422 std::fs::remove_dir_all(history_root).unwrap();
26423 std::fs::remove_dir_all(outside_root).unwrap();
26424 }
26425
26426 #[tokio::test]
26427 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
26428 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
26429 std::fs::create_dir_all(&history_root).unwrap();
26430 let history = NativeHistoryConfig::new(vec![
26431 NativeHistoryRoot::new(
26432 gate4agent_types::AdapterId::new("claude-code").unwrap(),
26433 HistorySourceLayout::SingleNdjson,
26434 history_root.clone(),
26435 )
26436 .unwrap(),
26437 ])
26438 .unwrap();
26439 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
26440 let held = catalog.lock().unwrap();
26441 let result = timeout(
26442 Duration::from_secs(1),
26443 run_native_session_catalog_operation(
26444 Arc::clone(&catalog),
26445 "native session catalog",
26446 |_| Ok::<(), std::convert::Infallible>(()),
26447 ),
26448 )
26449 .await
26450 .expect("concurrent native catalog request must not wait for the operation deadline")
26451 .unwrap_err();
26452 assert_eq!(result.code, NodeFailureCode::BackendBusy);
26453 drop(held);
26454 std::fs::remove_dir_all(history_root).unwrap();
26455 }
26456
26457 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
26458 let unique = std::time::SystemTime::now()
26459 .duration_since(std::time::UNIX_EPOCH)
26460 .unwrap()
26461 .as_nanos();
26462 std::env::temp_dir().join(format!(
26463 "gate4agent-node-{label}-{}-{unique}",
26464 std::process::id(),
26465 ))
26466 }
26467
26468 fn git_snapshot_for_parser() -> GitSnapshot {
26469 GitSnapshot {
26470 is_repository: true,
26471 branch: Some("main".to_owned()),
26472 status: Vec::new(),
26473 recent_commits: Vec::new(),
26474 worktrees: Vec::new(),
26475 managed_worktree: None,
26476 truncated: false,
26477 diagnostic: None,
26478 }
26479 }
26480
26481 #[tokio::test]
26482 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
26483 let root = temporary_workspace_root("managed-git-scope");
26484 std::fs::create_dir_all(&root).unwrap();
26485 run_test_git(&root, &["init"]);
26486 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
26487 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
26488 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
26489 run_test_git(&root, &["add", "seed.txt"]);
26490 run_test_git(&root, &["commit", "-m", "seed"]);
26491 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
26492
26493 let workspace_id = WorkspaceId::new("managed-a").unwrap();
26494 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26495 let catalog = active_registry().unwrap();
26496 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26497 let shared = NodeShared::new(
26498 handle,
26499 "fixture-token".to_owned(),
26500 NodeId::new("node-1").unwrap(),
26501 vec![workspace],
26502 vec![agent("claude")],
26503 );
26504 let mut lease = ManagedWorktreeLeaseRecord {
26505 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
26506 source_workspace_id: WorkspaceId::new("primary").unwrap(),
26507 workspace_id: workspace_id.clone(),
26508 profile_id: WorktreeProfileId::new("default").unwrap(),
26509 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
26510 target_root: root.to_string_lossy().into_owned(),
26511 branch: "gate4agent/a".to_owned(),
26512 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
26513 expected_head: None,
26514 retention: ManagedWorktreeRetention::Retain,
26515 state: ManagedWorktreeLeaseState::InUse,
26516 session_holders: vec![ManagedWorktreeSessionHolder {
26517 incarnation_id: shared.incarnation_id,
26518 instance_id: AgentInstanceId(7),
26519 generation: SessionGeneration(1),
26520 }],
26521 record_holders: Vec::new(),
26522 cleanup_failure: None,
26523 created_at_unix_ms: 1,
26524 updated_at_unix_ms: 1,
26525 };
26526 *shared
26527 .managed_worktrees
26528 .lock()
26529 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
26530 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
26531
26532 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
26533 let scope = inspection.git.managed_worktree.unwrap();
26534 assert_eq!(scope.lease_id, lease.lease_id);
26535 assert_eq!(scope.branch, "gate4agent/a");
26536 assert_eq!(scope.active_session_count, 1);
26537
26538 lease.branch = "gate4agent/b".to_owned();
26539 *shared
26540 .managed_worktrees
26541 .lock()
26542 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
26543 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
26544 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
26545 assert!(mismatch.git.managed_worktree.is_none());
26546 std::fs::remove_dir_all(root).unwrap();
26547 }
26548
26549 #[tokio::test]
26550 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
26551 let root = temporary_workspace_root("workspace-file-write-controller");
26552 std::fs::create_dir_all(root.join("src")).unwrap();
26553 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
26554 let workspace_id = WorkspaceId::new("primary").unwrap();
26555 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26556 let catalog = active_registry().unwrap();
26557 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26558 let shared = NodeShared::new(
26559 handle,
26560 "fixture-token".to_owned(),
26561 NodeId::new("node-1").unwrap(),
26562 vec![workspace],
26563 vec![agent("claude")],
26564 );
26565 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
26566 let expected_revision = workspace_file_revision(b"old\n");
26567 let request = NodeRequest::WriteWorkspaceFile {
26568 workspace_id: workspace_id.clone(),
26569 path: path.clone(),
26570 expected_revision: expected_revision.clone(),
26571 text: "new\n".to_owned(),
26572 };
26573 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26574 .await
26575 .unwrap_err();
26576 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26577 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26578 .await
26579 .unwrap_err();
26580 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26581 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
26582 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
26583 let NodeResponse::WorkspaceFileWritten { file } = response else {
26584 panic!("workspace file write returned another response");
26585 };
26586 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
26587 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
26588 let stale = process_request_inner(
26589 &shared,
26590 77,
26591 ClientRole::Operator,
26592 NodeRequest::WriteWorkspaceFile {
26593 workspace_id,
26594 path,
26595 expected_revision,
26596 text: "lost update\n".to_owned(),
26597 },
26598 )
26599 .await
26600 .unwrap_err();
26601 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
26602 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
26603 std::fs::remove_dir_all(root).unwrap();
26604 }
26605
26606 #[tokio::test]
26607 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
26608 let root = temporary_workspace_root("workspace-entry-create-controller");
26609 std::fs::create_dir_all(root.join("src")).unwrap();
26610 let workspace_id = WorkspaceId::new("primary").unwrap();
26611 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26612 let catalog = active_registry().unwrap();
26613 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26614 let shared = NodeShared::new(
26615 handle,
26616 "fixture-token".to_owned(),
26617 NodeId::new("node-1").unwrap(),
26618 vec![workspace],
26619 vec![agent("claude")],
26620 );
26621 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
26622 let request = NodeRequest::CreateWorkspaceFile {
26623 workspace_id: workspace_id.clone(),
26624 path: path.clone(),
26625 };
26626 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26627 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
26628 }));
26629 assert!(!request_uses_unnegotiated_capability(
26630 &request,
26631 &baseline_capabilities().unwrap(),
26632 ));
26633 assert!(request_uses_unnegotiated_capability(&request, &[]));
26634 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26635 .await
26636 .unwrap_err();
26637 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26638 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26639 .await
26640 .unwrap_err();
26641 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26642 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
26643
26644 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26645 .await
26646 .unwrap();
26647 let NodeResponse::WorkspaceFileCreated { file } = response else {
26648 panic!("workspace file create returned another response");
26649 };
26650 assert_eq!(file.workspace_id, workspace_id);
26651 assert_eq!(file.path, path);
26652 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
26653 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
26654
26655 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
26656 .await
26657 .unwrap_err();
26658 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
26659
26660 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
26661 let response = process_request_inner(
26662 &shared,
26663 77,
26664 ClientRole::Operator,
26665 NodeRequest::CreateWorkspaceDirectory {
26666 workspace_id: workspace_id.clone(),
26667 path: directory_path.clone(),
26668 },
26669 )
26670 .await
26671 .unwrap();
26672 assert_eq!(
26673 response,
26674 NodeResponse::WorkspaceDirectoryCreated {
26675 workspace_id: workspace_id.clone(),
26676 entry: WorkspaceEntry {
26677 relative_path: directory_path,
26678 kind: WorkspaceEntryKind::Directory,
26679 },
26680 },
26681 );
26682 assert!(root.join("src/nested").is_dir());
26683
26684 let missing_parent = process_request_inner(
26685 &shared,
26686 77,
26687 ClientRole::Operator,
26688 NodeRequest::CreateWorkspaceFile {
26689 workspace_id: workspace_id.clone(),
26690 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
26691 },
26692 )
26693 .await
26694 .unwrap_err();
26695 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
26696 let non_directory_parent = process_request_inner(
26697 &shared,
26698 77,
26699 ClientRole::Operator,
26700 NodeRequest::CreateWorkspaceFile {
26701 workspace_id,
26702 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
26703 },
26704 )
26705 .await
26706 .unwrap_err();
26707 assert_eq!(
26708 non_directory_parent.code,
26709 NodeFailureCode::RepositoryParentNotDirectory,
26710 );
26711 std::fs::remove_dir_all(root).unwrap();
26712 }
26713
26714 #[tokio::test]
26715 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
26716 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
26717 std::fs::create_dir_all(root.join("src")).unwrap();
26718 #[cfg(windows)]
26719 let root = std::fs::canonicalize(root).unwrap();
26720 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
26721 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
26722 let barrier = Arc::new(std::sync::Barrier::new(2));
26723 let done = Arc::new(AtomicBool::new(false));
26724 let task_commit_state = Arc::clone(&commit_state);
26725 let task_barrier = Arc::clone(&barrier);
26726 let task_done = Arc::clone(&done);
26727 let task_root = root.clone();
26728 let task = tokio::task::spawn_blocking(move || {
26729 task_barrier.wait();
26730 let result = create_workspace_file_on_disk(
26731 &task_root,
26732 &path,
26733 &task_commit_state,
26734 );
26735 task_done.store(true, Ordering::Release);
26736 result
26737 });
26738
26739 let error = settle_workspace_entry_create(
26740 task,
26741 Arc::clone(&commit_state),
26742 Duration::from_millis(10),
26743 "fixture deadline",
26744 "fixture task failed",
26745 )
26746 .await
26747 .unwrap_err();
26748 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
26749 assert_eq!(
26750 commit_state.load(Ordering::Acquire),
26751 WORKSPACE_ENTRY_CREATE_CANCELED,
26752 );
26753
26754 barrier.wait();
26755 timeout(Duration::from_secs(1), async {
26756 while !done.load(Ordering::Acquire) {
26757 tokio::task::yield_now().await;
26758 }
26759 })
26760 .await
26761 .expect("canceled blocking create did not settle");
26762 assert!(!root.join("src/late.rs").exists());
26763 std::fs::remove_dir_all(root).unwrap();
26764 }
26765
26766}