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 call_home: Option<std::net::SocketAddr>,
1052 pub node_id: NodeId,
1053 pub workspaces: Vec<WorkspaceConfig>,
1054 access_token: String,
1055 pub runtime: NativeRuntimeConfig,
1056 session_record_retention: SessionRecordRetentionConfig,
1057 state_path: Option<PathBuf>,
1058 spawn_profiles: SpawnProfileRegistry,
1059 session_environment: Option<NodeSessionEnvironmentConfig>,
1060 history: Option<NativeHistoryConfig>,
1061 harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1062 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1066 #[cfg(feature = "fixture")]
1067 fixture_raw_pty_runtime: bool,
1068}
1069
1070#[derive(Clone)]
1071struct NodeSessionEnvironmentConfig {
1072 root: PathBuf,
1073 resolver: Arc<dyn NodeSecretResolver>,
1074}
1075
1076impl NodeServerConfig {
1077 pub fn new(
1078 endpoint: impl Into<String>,
1079 access_token: impl Into<String>,
1080 node_id: NodeId,
1081 workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1082 ) -> Result<Self, NodeServerError> {
1083 let endpoint = endpoint.into();
1084 let access_token = access_token.into();
1085 if !platform::validate_endpoint(&endpoint) {
1086 return Err(NodeServerError::InvalidEndpoint);
1087 }
1088 if access_token.is_empty() || access_token.len() > 4_096 {
1089 return Err(NodeServerError::InvalidAccessToken);
1090 }
1091 let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1092 if workspaces.is_empty() {
1093 return Err(NodeServerError::NoWorkspaces);
1094 }
1095 let mut workspace_ids = BTreeMap::new();
1096 let mut workspace_roots = BTreeMap::new();
1097 for workspace in &workspaces {
1098 if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1099 && workspace.managed_worktree_profiles.is_empty()
1100 {
1101 return Err(NodeServerError::ManagedWorktreeProfileRequired(
1102 workspace.workspace_id.clone(),
1103 ));
1104 }
1105 if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1106 && !workspace.managed_worktree_profiles.is_empty()
1107 {
1108 return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1109 workspace.workspace_id.clone(),
1110 ));
1111 }
1112 if workspace_ids
1113 .insert(workspace.workspace_id.clone(), ())
1114 .is_some()
1115 {
1116 return Err(NodeServerError::DuplicateWorkspaceId(
1117 workspace.workspace_id.clone(),
1118 ));
1119 }
1120 let root_key = platform::root_identity(&workspace.canonical_root);
1121 if let Some(existing) = workspace_roots.insert(
1122 root_key,
1123 workspace.workspace_id.clone(),
1124 ) {
1125 return Err(NodeServerError::DuplicateWorkspaceRoot {
1126 first: existing,
1127 second: workspace.workspace_id.clone(),
1128 root: workspace.canonical_root.clone(),
1129 });
1130 }
1131 }
1132 Ok(Self {
1133 endpoint,
1134 api_listen: None,
1135 bridge_listen: None,
1136 bridge_token: None,
1137 call_home: None,
1138 node_id,
1139 workspaces,
1140 access_token,
1141 runtime: NativeRuntimeConfig::default(),
1142 session_record_retention: SessionRecordRetentionConfig::default(),
1143 state_path: None,
1144 spawn_profiles: SpawnProfileRegistry::default(),
1145 session_environment: None,
1146 history: None,
1147 harness_mcp_helper: None,
1148 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1149 #[cfg(feature = "fixture")]
1150 fixture_raw_pty_runtime: false,
1151 })
1152 }
1153
1154 pub fn with_api_listen(
1155 mut self,
1156 api_listen: std::net::SocketAddr,
1157 ) -> Result<Self, NodeServerError> {
1158 if !api_listen.ip().is_loopback() {
1159 return Err(NodeServerError::InvalidApiListen(api_listen));
1160 }
1161 self.api_listen = Some(api_listen);
1162 Ok(self)
1163 }
1164
1165 pub fn with_bridge_listen(
1169 mut self,
1170 bridge_listen: std::net::SocketAddr,
1171 ) -> Result<Self, NodeServerError> {
1172 if !bridge_listen.ip().is_loopback() {
1173 return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1174 }
1175 self.bridge_listen = Some(bridge_listen);
1176 Ok(self)
1177 }
1178
1179 pub fn with_bridge_token(
1182 mut self,
1183 bridge_token: impl Into<String>,
1184 ) -> Result<Self, NodeServerError> {
1185 let bridge_token = bridge_token.into();
1186 if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1187 return Err(NodeServerError::InvalidBridgeToken);
1188 }
1189 self.bridge_token = Some(bridge_token);
1190 Ok(self)
1191 }
1192
1193 pub fn with_call_home(
1214 mut self,
1215 relay: std::net::SocketAddr,
1216 ) -> Result<Self, NodeServerError> {
1217 if !relay.ip().is_loopback() || relay.port() == 0 {
1218 return Err(NodeServerError::InvalidCallHome(relay));
1219 }
1220 self.call_home = Some(relay);
1221 Ok(self)
1222 }
1223
1224 pub fn with_state_path(
1225 mut self,
1226 state_path: impl Into<PathBuf>,
1227 ) -> Result<Self, NodeServerError> {
1228 let state_path = state_path.into();
1229 if !state_path.is_absolute() || state_path.file_name().is_none() {
1230 return Err(NodeServerError::InvalidStatePath(
1231 state_path.to_string_lossy().into_owned(),
1232 ));
1233 }
1234 self.state_path = Some(state_path);
1235 Ok(self)
1236 }
1237
1238 pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1239 self.spawn_profiles = spawn_profiles;
1240 self
1241 }
1242
1243 pub fn with_session_record_retention(
1249 mut self,
1250 retention: SessionRecordRetentionConfig,
1251 ) -> Self {
1252 self.session_record_retention = retention;
1253 self
1254 }
1255
1256 pub fn with_session_environment_materialization(
1257 mut self,
1258 root: impl Into<PathBuf>,
1259 resolver: Arc<dyn NodeSecretResolver>,
1260 ) -> Result<Self, NodeServerError> {
1261 let root = root.into();
1262 if !root.is_absolute() {
1263 return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1264 }
1265 self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1266 Ok(self)
1267 }
1268
1269 pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1272 self.history = Some(history);
1273 self
1274 }
1275
1276 pub fn with_harness_mcp_helper(
1278 mut self,
1279 helper_program: impl Into<PathBuf>,
1280 ) -> Result<Self, NodeServerError> {
1281 #[cfg(not(windows))]
1282 {
1283 let _ = helper_program;
1284 return Err(NodeServerError::InvalidHarnessMcpHelper);
1285 }
1286 #[cfg(windows)]
1287 {
1288 self.harness_mcp_helper = Some(
1289 ReviewedHarnessMcpProgram::review(helper_program.into())
1290 .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1291 );
1292 Ok(self)
1293 }
1294 }
1295
1296 pub fn with_network_allowlist_catalog(
1301 mut self,
1302 catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1303 ) -> Result<Self, NodeServerError> {
1304 if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1305 return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1306 max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1307 });
1308 }
1309 self.network_allowlist_catalog = catalog;
1310 Ok(self)
1311 }
1312
1313 pub fn with_network_allowlist_catalog_file(
1317 self,
1318 path: impl AsRef<std::path::Path>,
1319 ) -> Result<Self, NodeServerError> {
1320 let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1321 .map_err(NodeServerError::from)?;
1322 self.with_network_allowlist_catalog(catalog)
1323 }
1324}
1325
1326pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1327 platform::default_state_path(node_id.as_str())
1328 .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1329}
1330
1331pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1332 platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1333}
1334
1335fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1336 let parent = state_path.parent()?;
1337 let mut name = state_path.file_name()?.to_os_string();
1338 name.push(".delivery-store");
1339 Some(parent.join(name))
1340}
1341
1342fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1343 let parent = state_path.parent()?;
1344 let mut name = state_path.file_name()?.to_os_string();
1345 name.push(".context-pack-store");
1346 Some(parent.join(name))
1347}
1348
1349pub struct NodeServer {
1350 config: NodeServerConfig,
1351 runtime: NativeRuntime,
1352 shared: Arc<NodeShared>,
1353 events: EventSubscription,
1354 state_path_lock: Option<session_registry::StatePathLock>,
1355}
1356
1357#[cfg(feature = "fixture")]
1358#[derive(Clone, Default)]
1359pub struct SpawnManagedWorktreeV2FailureProbe {
1360 latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1361}
1362
1363#[cfg(feature = "fixture")]
1364impl SpawnManagedWorktreeV2FailureProbe {
1365 pub fn latest_code(&self) -> Option<NodeFailureCode> {
1366 *self
1367 .latest_code
1368 .lock()
1369 .unwrap_or_else(|poisoned| poisoned.into_inner())
1370 }
1371
1372 fn record(&self, code: Option<NodeFailureCode>) {
1373 *self
1374 .latest_code
1375 .lock()
1376 .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1377 }
1378}
1379
1380#[cfg(all(feature = "fixture", windows))]
1381struct IsolatedCodexHomeFixtureEnvironment;
1382
1383#[cfg(all(feature = "fixture", windows))]
1384impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1385 for IsolatedCodexHomeFixtureEnvironment
1386{
1387 fn resolve_child_environment(
1388 &self,
1389 ) -> Result<
1390 Vec<gate4agent_catalog::EnvMutation>,
1391 gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1392 > {
1393 Ok(vec![gate4agent_catalog::EnvMutation {
1394 key: OsString::from("USERPROFILE"),
1395 value: None,
1396 }])
1397 }
1398}
1399
1400impl NodeServer {
1401 pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1402 let catalog = active_registry()?;
1403 Self::new_with_registry(config, catalog)
1404 }
1405
1406 #[cfg(feature = "fixture")]
1407 pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1408 let mut spec = gate4agent_testkit::interactive_agent_spec();
1409 spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1410 spec.display_name = "Controlled Claude PTY fixture".to_owned();
1411 Self::new_fixture_with_spec(config, spec)
1412 }
1413
1414 #[cfg(feature = "fixture")]
1415 pub fn new_clean_exit_fixture(
1416 config: NodeServerConfig,
1417 fixture_root: PathBuf,
1418 started_marker: PathBuf,
1419 release_signal: PathBuf,
1420 ) -> Result<Self, NodeServerError> {
1421 let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1422 &fixture_root,
1423 &started_marker,
1424 &release_signal,
1425 )
1426 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1427 spec.id = AgentId::new("claude")
1428 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1429 spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1430 Self::new_fixture_with_spec(config, spec)
1431 }
1432
1433 #[cfg(all(feature = "fixture", windows))]
1443 pub fn new_durable_context_pack_clean_exit_fixture(
1444 config: NodeServerConfig,
1445 fixture_root: PathBuf,
1446 started_marker: PathBuf,
1447 release_signal: PathBuf,
1448 session_id: &str,
1449 ) -> Result<Self, NodeServerError> {
1450 let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1451 &fixture_root,
1452 &started_marker,
1453 &release_signal,
1454 session_id,
1455 )
1456 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1457 let source_id = AgentId::new("claude")
1458 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1459 let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1460 NodeServerError::Registry(
1461 "durable context pack clean-exit fixture source adapter is unavailable"
1462 .to_owned(),
1463 )
1464 })?;
1465 source_spec.capabilities.adapters.history =
1466 source_provider.capabilities.adapters.history.clone();
1467 source_spec.id = source_id;
1468 source_spec.display_name =
1469 "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1470 source_spec.detection.command =
1476 "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1477 source_spec.detection.aliases.clear();
1478
1479 let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1480 target_spec.id = AgentId::new("codex")
1481 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1482 target_spec.display_name =
1483 "Controlled Codex durable context-pack target fixture".to_owned();
1484 target_spec.detection.command =
1485 "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1486 target_spec.detection.aliases.clear();
1487
1488 let catalog = AgentRegistry::new([source_spec, target_spec])
1489 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1490 let mut server = Self::new_with_registry(config, catalog)?;
1491 Arc::get_mut(&mut server.shared)
1492 .expect("fixture Node shared state must remain uniquely owned before run")
1493 .fixture_semantic_hook_policy = true;
1494 Ok(server)
1495 }
1496
1497 #[cfg(feature = "fixture")]
1498 pub fn new_monitoring_hook_fixture(
1499 config: NodeServerConfig,
1500 ) -> Result<Self, NodeServerError> {
1501 let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1502 spec.id = AgentId::new("claude")
1503 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1504 let mut server = Self::new_fixture_with_spec(config, spec)?;
1505 Arc::get_mut(&mut server.shared)
1506 .expect("fixture Node shared state must remain uniquely owned before run")
1507 .fixture_semantic_hook_policy = true;
1508 Ok(server)
1509 }
1510
1511 #[cfg(all(feature = "fixture", windows))]
1512 pub fn new_provider_bundle_argv_fixture(
1513 config: NodeServerConfig,
1514 agent_id: AgentId,
1515 proof_path: PathBuf,
1516 ) -> Result<Self, NodeServerError> {
1517 let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1518 Self::new_fixture_with_spec(config, spec)
1519 }
1520
1521 #[cfg(all(feature = "fixture", windows))]
1522 pub fn new_provider_bundle_argv_hold_fixture(
1523 config: NodeServerConfig,
1524 agent_id: AgentId,
1525 proof_path: PathBuf,
1526 release_signal: PathBuf,
1527 ) -> Result<Self, NodeServerError> {
1528 let spec = Self::provider_bundle_argv_fixture_spec(
1529 agent_id,
1530 proof_path,
1531 Some(release_signal),
1532 )?;
1533 Self::new_fixture_with_spec(config, spec)
1534 }
1535
1536 #[cfg(all(feature = "fixture", windows))]
1537 fn provider_bundle_argv_fixture_spec(
1538 agent_id: AgentId,
1539 proof_path: PathBuf,
1540 release_signal: Option<PathBuf>,
1541 ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1542 if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1543 return Err(NodeServerError::Registry(
1544 "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1545 ));
1546 }
1547 let mut spec = gate4agent_testkit::interactive_agent_spec();
1548 spec.id = agent_id.clone();
1549 spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1550 let provider = builtin_registry()
1551 .get(&agent_id)
1552 .ok_or_else(|| {
1553 NodeServerError::Registry(
1554 "provider bundle argv fixture adapter is unavailable".to_owned(),
1555 )
1556 })?;
1557 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1558 if !proof_path.is_absolute()
1559 || !proof_path
1560 .parent()
1561 .is_some_and(|parent| parent.is_dir())
1562 {
1563 return Err(NodeServerError::Registry(
1564 "provider bundle argv proof path requires an existing absolute parent"
1565 .to_owned(),
1566 ));
1567 }
1568 if let Some(release_signal) = release_signal.as_ref() {
1569 if !release_signal.is_absolute()
1570 || !release_signal
1571 .parent()
1572 .is_some_and(|parent| parent.is_dir())
1573 {
1574 return Err(NodeServerError::Registry(
1575 "provider bundle argv release path requires an existing absolute parent"
1576 .to_owned(),
1577 ));
1578 }
1579 if release_signal.exists() {
1580 return Err(NodeServerError::Registry(
1581 "provider bundle argv release path must not exist".to_owned(),
1582 ));
1583 }
1584 let proof_parent = std::fs::canonicalize(
1585 proof_path
1586 .parent()
1587 .expect("validated provider bundle proof parent"),
1588 )
1589 .map_err(|_| {
1590 NodeServerError::Registry(
1591 "provider bundle argv proof parent is unavailable".to_owned(),
1592 )
1593 })?;
1594 let release_parent = std::fs::canonicalize(
1595 release_signal
1596 .parent()
1597 .expect("validated provider bundle release parent"),
1598 )
1599 .map_err(|_| {
1600 NodeServerError::Registry(
1601 "provider bundle argv release parent is unavailable".to_owned(),
1602 )
1603 })?;
1604 if proof_parent != release_parent {
1605 return Err(NodeServerError::Registry(
1606 "provider bundle argv release path must share the proof parent".to_owned(),
1607 ));
1608 }
1609 }
1610 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1611 NodeServerError::Registry(
1612 "provider bundle argv proof path is not valid Unicode".to_owned(),
1613 )
1614 })?;
1615 let release_signal = release_signal.map(|release_signal| {
1616 release_signal.into_os_string().into_string().map_err(|_| {
1617 NodeServerError::Registry(
1618 "provider bundle argv release path is not valid Unicode".to_owned(),
1619 )
1620 })
1621 }).transpose()?;
1622 let script = spec
1623 .launch
1624 .fixed_args
1625 .pop()
1626 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1627 let bundle_validation = if agent_id.as_str() == "codex" {
1628 "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') }"
1629 } else if agent_id.as_str() == "claude" {
1630 "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 }"
1631 } else {
1632 "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 }"
1633 };
1634 if let Some(release_signal) = release_signal {
1635 spec.launch.fixed_args.push(format!(
1636 "& {{ 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} }}",
1637 ));
1638 spec.launch.fixed_args.push(proof_path);
1639 spec.launch.fixed_args.push(release_signal);
1640 } else {
1641 spec.launch.fixed_args.push(format!(
1642 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1643 ));
1644 spec.launch.fixed_args.push(proof_path);
1645 }
1646 Ok(spec)
1647 }
1648
1649 #[cfg(all(feature = "fixture", windows))]
1650 pub fn new_context_pack_fixture(
1651 config: NodeServerConfig,
1652 proof_path: PathBuf,
1653 ) -> Result<Self, NodeServerError> {
1654 let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1655 Self::new_with_registry(config, catalog)
1656 }
1657
1658 #[cfg(all(feature = "fixture", windows))]
1659 pub fn new_monitoring_context_pack_fixture(
1660 config: NodeServerConfig,
1661 proof_path: PathBuf,
1662 ) -> Result<Self, NodeServerError> {
1663 let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1664 let mut server = Self::new_with_registry(config, catalog)?;
1665 Arc::get_mut(&mut server.shared)
1666 .expect("fixture Node shared state must remain uniquely owned before run")
1667 .fixture_semantic_hook_policy = true;
1668 Ok(server)
1669 }
1670
1671 #[cfg(all(feature = "fixture", windows))]
1672 pub fn install_isolated_codex_home_environment_fixture(
1673 &mut self,
1674 profile_id: SpawnEnvironmentProfileId,
1675 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1676 ) -> Result<(), NodeServerError> {
1677 let provider = AgentId::new("codex")
1678 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1679 let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1680 gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1681 "{}-pty",
1682 profile_id.as_str(),
1683 ))
1684 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1685 provider.clone(),
1686 TransportKind::Pty,
1687 vec![OsString::from("USERPROFILE")],
1688 Arc::new(IsolatedCodexHomeFixtureEnvironment),
1689 )
1690 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1691 let materialization = NodeSessionMaterializationProfile::new(
1692 Vec::new(),
1693 vec![crate::session_environment::NodeSessionPathBinding::new(
1694 "CODEX_HOME",
1695 crate::session_environment::NodeSessionPathClass::ProviderHome,
1696 )
1697 .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1698 Vec::new(),
1699 )
1700 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1701 self.install_environment_profile(
1702 NodeEnvironmentProfile::new_with_materialization(
1703 profile_id,
1704 profile_revision,
1705 provider,
1706 [native_profile],
1707 Some(materialization),
1708 )
1709 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1710 )
1711 }
1712
1713 #[cfg(all(feature = "fixture", windows))]
1714 pub fn new_context_only_proof_fixture(
1715 config: NodeServerConfig,
1716 target_provider: AgentId,
1717 proof_path: PathBuf,
1718 ) -> Result<Self, NodeServerError> {
1719 if target_provider.as_str() != "kimi" {
1720 return Err(NodeServerError::Registry(
1721 "context-only proof fixture requires Kimi".to_owned(),
1722 ));
1723 }
1724 let catalog = Self::context_pack_fixture_catalog(
1725 proof_path,
1726 Some(&target_provider),
1727 false,
1728 )?;
1729 Self::new_with_registry(config, catalog)
1730 }
1731
1732 #[cfg(all(feature = "fixture", windows))]
1733 fn context_pack_fixture_catalog(
1734 proof_path: PathBuf,
1735 context_only_target: Option<&AgentId>,
1736 monitoring_claude_source: bool,
1737 ) -> Result<AgentRegistry, NodeServerError> {
1738 if !proof_path.is_absolute()
1739 || !proof_path
1740 .parent()
1741 .is_some_and(|parent| parent.is_dir())
1742 {
1743 return Err(NodeServerError::Registry(
1744 "context pack proof path requires an existing absolute parent".to_owned(),
1745 ));
1746 }
1747 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1748 NodeServerError::Registry(
1749 "context pack proof path is not valid Unicode".to_owned(),
1750 )
1751 })?;
1752 let providers = ["claude", "codex", "grok", "kimi"];
1753 let builtins = builtin_registry();
1754 let mut specs = Vec::with_capacity(providers.len());
1755 for provider_id in providers {
1756 let agent_id = AgentId::new(provider_id)
1757 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1758 let provider = builtins.get(&agent_id).ok_or_else(|| {
1759 NodeServerError::Registry(
1760 "context pack fixture provider adapter is unavailable".to_owned(),
1761 )
1762 })?;
1763 let monitored_provider =
1764 monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1765 let mut spec = if monitored_provider {
1766 gate4agent_testkit::monitoring_hook_agent_spec()
1767 } else {
1768 gate4agent_testkit::interactive_agent_spec()
1769 };
1770 spec.id = agent_id.clone();
1771 spec.display_name = format!("Controlled {agent_id} context pack fixture");
1772 spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1773 spec.detection.aliases.clear();
1774 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1775 if provider_id == "codex" {
1776 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1777 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1778 })?;
1779 let validation = r#"
1780$bundleArgs = @($bundleArgs)
1781if ($bundleArgs.Count -ne 0) { exit 91 }
1782$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1783$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1784$cwd = (Get-Location).ProviderPath
1785if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1786$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1787if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1788$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1789if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1790$sha = [Security.Cryptography.SHA256]::Create()
1791try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1792if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1793if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1794$contextPath = Join-Path $contextRoot 'context-pack.json'
1795if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1796$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1797if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1798$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1799$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1800$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1801$separator = [IO.Path]::DirectorySeparatorChar
1802if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1803if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1804$rawContext = [IO.File]::ReadAllText($contextPath)
1805if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1806try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1807$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1808$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1809foreach ($property in @($document.PSObject.Properties.Name)) {
1810 if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1811}
1812foreach ($property in $requiredTopLevel) {
1813 if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1814}
1815if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1816if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1817$messages = @($document.retained_messages)
1818if ($messages.Count -eq 0) { exit 107 }
1819foreach ($message in $messages) {
1820 $messageProperties = @($message.PSObject.Properties.Name)
1821 foreach ($property in $messageProperties) {
1822 if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1823 }
1824 if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1825}
1826$sha = [Security.Cryptography.SHA256]::Create()
1827try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1828[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))
1829$rawProof = [IO.File]::ReadAllText($proofPath)
1830if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1831[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1832"#;
1833 spec.launch.fixed_args.push(format!(
1834 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1835 ));
1836 spec.launch.fixed_args.push(proof_path.clone());
1837 } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1838 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1839 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1840 })?;
1841 let validation = r#"
1842$bundleArgs = @($bundleArgs)
1843if ($bundleArgs.Count -ne 0) { exit 91 }
1844$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1845$cwd = (Get-Location).ProviderPath
1846if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1847$contextPath = Join-Path $contextRoot 'context-pack.json'
1848if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1849$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1850if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1851$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1852$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1853$separator = [IO.Path]::DirectorySeparatorChar
1854if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1855try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1856if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1857if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1858$messages = @($document.retained_messages)
1859if ($messages.Count -eq 0) { exit 99 }
1860$hasUser = $false
1861$hasAssistant = $false
1862foreach ($message in $messages) {
1863 if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1864 if ([string]$message.role -ceq 'user') { $hasUser = $true }
1865 elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1866 else { exit 101 }
1867}
1868if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1869$sha = [Security.Cryptography.SHA256]::Create()
1870try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1871[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1872[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1873"#;
1874 spec.launch.fixed_args.push(format!(
1875 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1876 ));
1877 spec.launch.fixed_args.push(proof_path.clone());
1878 }
1879 specs.push(spec);
1880 }
1881 let catalog = AgentRegistry::new(specs)
1882 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1883 Ok(catalog)
1884 }
1885
1886 #[cfg(feature = "fixture")]
1887 pub fn new_exact_launcher_fixture(
1888 config: NodeServerConfig,
1889 agent_id: AgentId,
1890 launcher: String,
1891 ) -> Result<Self, NodeServerError> {
1892 if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1893 return Err(NodeServerError::Registry(
1894 "exact launcher fixture requires a supported provider".to_owned(),
1895 ));
1896 }
1897 if launcher.contains('\0') {
1898 return Err(NodeServerError::Registry(
1899 "exact launcher fixture path contains NUL".to_owned(),
1900 ));
1901 }
1902 let launcher_path = Path::new(&launcher);
1903 if !launcher_path.is_absolute() {
1904 return Err(NodeServerError::Registry(
1905 "exact launcher fixture path must be absolute".to_owned(),
1906 ));
1907 }
1908 let metadata = std::fs::metadata(launcher_path).map_err(|_| {
1909 NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
1910 })?;
1911 if !metadata.is_file() {
1912 return Err(NodeServerError::Registry(
1913 "exact launcher fixture path must be a regular file".to_owned(),
1914 ));
1915 }
1916 let mut spec = builtin_registry()
1917 .get(&agent_id)
1918 .ok_or_else(|| {
1919 NodeServerError::Registry(
1920 "exact launcher fixture provider is unavailable".to_owned(),
1921 )
1922 })?
1923 .clone();
1924 spec.launch.program = launcher;
1925 Self::new_fixture_with_spec(config, spec)
1926 }
1927
1928 #[cfg(feature = "fixture")]
1937 fn h3b_provider_spec_with_program_and_args(
1938 agent_id: AgentId,
1939 provider_program: PathBuf,
1940 fixed_args: Vec<OsString>,
1941 ) -> Result<AgentSpec, NodeServerError> {
1942 if !provider_program.is_absolute() || fixed_args.len() > 32 {
1943 return Err(NodeServerError::Registry(
1944 "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
1945 ));
1946 }
1947 let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
1948 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
1949 })?;
1950 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
1951 return Err(NodeServerError::Registry(
1952 "H3B fixture provider must be an exact regular file".to_owned(),
1953 ));
1954 }
1955 let program = provider_program.into_os_string().into_string().map_err(|_| {
1956 NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
1957 })?;
1958 let fixed_args = fixed_args.into_iter().map(|argument| {
1959 argument.into_string().map_err(|_| NodeServerError::Registry(
1960 "H3B fixture argument is not Unicode".to_owned(),
1961 ))
1962 }).collect::<Result<Vec<_>, _>>()?;
1963 let argument_bytes = fixed_args.iter()
1964 .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
1965 if program.contains('\0')
1966 || fixed_args.iter().any(|argument| argument.contains('\0'))
1967 || !matches!(argument_bytes, Some(total) if total <= 65_536)
1968 {
1969 return Err(NodeServerError::Registry(
1970 "H3B fixture provider or arguments are invalid".to_owned(),
1971 ));
1972 }
1973 let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
1974 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
1975 })?.clone();
1976 let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
1977 .ok_or_else(|| NodeServerError::Registry(
1978 "H3B fixture provider basename is unavailable".to_owned(),
1979 ))?.to_owned();
1980 spec.detection.command = process_name.clone();
1981 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
1982 name: process_name,
1983 }];
1984 spec.launch.program = program;
1985 spec.launch.fixed_args = fixed_args;
1986 Ok(spec)
1987 }
1988
1989 #[cfg(feature = "fixture")]
1990 pub fn new_harness_mcp_proxy_fixture(
1991 config: NodeServerConfig,
1992 provider_program: PathBuf,
1993 fixed_args: Vec<OsString>,
1994 harness_mcp_program: PathBuf,
1995 ) -> Result<Self, NodeServerError> {
1996 let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
1997 let agent_id = AgentId::new("codex")
1998 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1999 let mut spec =
2000 Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2001 spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2002 config.fixture_raw_pty_runtime = true;
2003 Self::new_fixture_with_spec(config, spec)
2004 }
2005
2006 #[cfg(feature = "fixture")]
2030 pub fn new_harness_mcp_acp_launcher_fixture(
2031 config: NodeServerConfig,
2032 helper_program: PathBuf,
2033 provider: AgentId,
2034 program: PathBuf,
2035 args: Vec<OsString>,
2036 ) -> Result<Self, NodeServerError> {
2037 if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2038 return Err(NodeServerError::Registry(
2039 "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2040 ));
2041 }
2042 let config = config.with_harness_mcp_helper(helper_program)?;
2043 let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2044 let launch = spec.launch.clone();
2045 let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2046 NodeServerError::Registry(
2047 "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2048 .to_owned(),
2049 )
2050 })?;
2051 acp.launch_override = Some(launch);
2059 spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2060 Self::new_fixture_with_spec(config, spec)
2061 }
2062
2063 #[cfg(feature = "fixture")]
2064 pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2065 let mut spec = gate4agent_testkit::interactive_agent_spec();
2066 let claude_id = AgentId::new("claude")
2067 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2068 let claude = builtin_registry()
2069 .get(&claude_id)
2070 .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2071 spec.id = claude_id;
2072 spec.display_name = "Resumable Claude PTY fixture".to_owned();
2073 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2074 name: "claude".to_owned(),
2075 }];
2076 spec.capabilities.transports.pty_adapter = claude
2077 .capabilities
2078 .transports
2079 .pty_adapter
2080 .clone();
2081 spec.capabilities.adapters.resume = claude
2082 .capabilities
2083 .adapters
2084 .resume
2085 .clone();
2086 let script = spec
2087 .launch
2088 .fixed_args
2089 .pop()
2090 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2091 spec.launch.fixed_args.push(format!(
2092 "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2093 ));
2094 Self::new_fixture_with_spec(config, spec)
2095 }
2096
2097 #[cfg(feature = "fixture")]
2098 pub fn new_cmd_cwd_fixture(
2099 config: NodeServerConfig,
2100 command: String,
2101 ) -> Result<Self, NodeServerError> {
2102 let mut spec = gate4agent_testkit::interactive_agent_spec();
2103 spec.id = AgentId::new("claude")
2104 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2105 spec.display_name = "Controlled cmd cwd fixture".to_owned();
2106 spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2107 spec.launch.program = command;
2108 spec.launch.fixed_args.clear();
2109 Self::new_fixture_with_spec(config, spec)
2110 }
2111
2112 #[cfg(feature = "fixture")]
2113 fn new_fixture_with_spec(
2114 config: NodeServerConfig,
2115 spec: gate4agent_types::AgentSpec,
2116 ) -> Result<Self, NodeServerError> {
2117 let catalog = AgentRegistry::new([spec])
2118 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2119 Self::new_with_registry(config, catalog)
2120 }
2121
2122 fn new_with_registry(
2123 config: NodeServerConfig,
2124 catalog: AgentRegistry,
2125 ) -> Result<Self, NodeServerError> {
2126 for profile in config.spawn_profiles.iter() {
2127 if catalog.get(&profile.provider).is_none() {
2128 return Err(NodeServerError::Registry(
2129 "spawn profile references an unavailable provider".to_owned(),
2130 ));
2131 }
2132 }
2133 let input_settle_timeout_ms = catalog
2134 .iter()
2135 .map(|spec| spec.readiness.timeout_ms)
2136 .max()
2137 .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2138 .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2139 .max(MUTATION_SETTLE_TIMEOUT_MS);
2140 let (provider_contracts, provider_adapter_contracts) =
2141 provider_contract_manifest(&catalog)?;
2142 let compatibility_support = node_compatibility_support_for_manifest(
2143 &provider_contracts,
2144 &provider_adapter_contracts,
2145 )?;
2146 validate_node_negotiated_handshake_capacity(&compatibility_support)
2147 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2148 let enabled_providers = provider_contracts
2149 .iter()
2150 .map(|contract| contract.provider.clone())
2151 .collect();
2152 #[cfg(feature = "fixture")]
2153 let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2154 #[cfg(not(feature = "fixture"))]
2155 let fixture_raw_pty_runtime = false;
2156 let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2157 let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2158 crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2159 })).expect("fixture provider catalog remains bounded");
2160 (None, statuses)
2161 } else {
2162 let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2163 let statuses = monitor.collect();
2164 (Some(monitor), statuses)
2165 };
2166 let state_path_lock =
2167 session_registry::StatePathLock::acquire(config.state_path.as_deref())
2168 .map_err(|error| {
2169 durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2170 })?;
2171 let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2172 Some(state_path) => {
2173 let delivery_root = delivery_store_root_for_state_path(state_path)
2174 .ok_or(NodeServerError::DeliveryStore)?;
2175 let (store, bundles) = DeliveryStore::open(delivery_root)
2176 .map_err(|_| NodeServerError::DeliveryStore)?;
2177 (Some(store), bundles)
2178 }
2179 None => (None, Vec::new()),
2180 };
2181 let delivered_catalog = BundleCatalog::new(delivered_bundles)
2182 .map_err(|_| NodeServerError::DeliveryStore)?;
2183 let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2184 Some(state_path) => {
2185 let context_pack_root = context_pack_store_root_for_state_path(state_path)
2186 .ok_or(NodeServerError::ContextPackStore)?;
2187 let (store, packs) = ContextPackStore::open(context_pack_root)
2188 .map_err(|_| NodeServerError::ContextPackStore)?;
2189 (Some(store), packs)
2190 }
2191 None => (None, Vec::new()),
2192 };
2193 let mut durable_context_catalog = ContextPackCatalog::default();
2194 for pack in durable_context_packs {
2195 durable_context_catalog
2196 .insert(pack)
2197 .map_err(|_| NodeServerError::ContextPackStore)?;
2198 }
2199 let session_environment_materializer = config
2200 .session_environment
2201 .as_ref()
2202 .map(|environment| {
2203 SessionEnvironmentMaterializer::new(
2204 environment.root.clone(),
2205 Arc::clone(&environment.resolver),
2206 )
2207 .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2208 })
2209 .transpose()?;
2210 let native_session_catalog = config.history.clone().map(|history| {
2211 Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2212 });
2213 let (handle, runtime) = match config.history.clone() {
2214 Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2215 None => NativeRuntime::new(catalog, config.runtime),
2216 };
2217 let native_launch_profile_control = runtime.native_launch_profile_control();
2218 let harness_mcp_registry = config
2219 .harness_mcp_helper
2220 .clone()
2221 .map(HarnessMcpProxyRegistry::new);
2222 let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2223 let incarnation_id = random_incarnation_id()
2224 .map_err(NodeServerError::IncarnationIdentity)?;
2225 let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2226 .map_err(durable_state_load_error)?;
2227 let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2228 let managed_worktree_tombstones =
2229 std::mem::take(&mut loaded.managed_worktree_tombstones);
2230 let materializations = std::mem::take(&mut loaded.materializations);
2231 let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2232 let (workspaces, records, persistence_warning) =
2233 merge_durable_state(&config.workspaces, loaded)?;
2234 let mut shared = NodeShared::new_with_incarnation(
2235 handle,
2236 config.access_token.clone(),
2237 config.node_id.clone(),
2238 incarnation_id,
2239 workspaces,
2240 enabled_providers,
2241 provider_runtime_statuses,
2242 provider_runtime_monitor,
2243 provider_contracts,
2244 provider_adapter_contracts,
2245 config.spawn_profiles.clone(),
2246 Some(native_launch_profile_control),
2247 native_session_catalog,
2248 session_environment_materializer,
2249 config.state_path.clone(),
2250 records,
2251 managed_worktrees,
2252 managed_worktree_tombstones,
2253 materializations,
2254 persistence_warning,
2255 input_settle_timeout_ms,
2256 );
2257 shared.harness_mcp_registry = harness_mcp_registry.clone();
2258 shared.session_record_retention = config.session_record_retention;
2259 shared.network_allowlist_catalog =
2260 RwLock::new(config.network_allowlist_catalog.clone());
2261 shared.bundle_catalog = RwLock::new(delivered_catalog);
2262 shared.delivery_store = Mutex::new(delivery_store);
2263 shared.context_catalog = RwLock::new(durable_context_catalog);
2264 shared.context_pack_store = Mutex::new(context_pack_store);
2265 shared.managed_spawn_replays = Mutex::new(
2266 managed_spawn_replays
2267 .into_iter()
2268 .map(|record| (record.idempotency_key.clone(), record))
2269 .collect(),
2270 );
2271 let shared = Arc::new(shared);
2272 if let Some(registry) = harness_mcp_registry {
2273 let weak = Arc::downgrade(&shared);
2274 registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2275 Some(shared) => shared.publish_transient(event),
2276 None => false,
2277 }));
2278 }
2279 if shared.persistence_error().is_none() {
2280 shared.persist_state().map_err(|error| {
2281 durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2282 })?;
2283 }
2284 Ok(Self {
2285 config,
2286 runtime,
2287 shared,
2288 events,
2289 state_path_lock,
2290 })
2291 }
2292
2293 pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2294 NodeShutdownHandle {
2295 shared: Arc::clone(&self.shared),
2296 }
2297 }
2298
2299 #[cfg(feature = "fixture")]
2300 pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2301 &self,
2302 ) -> SpawnManagedWorktreeV2FailureProbe {
2303 self.shared
2304 .fixture_spawn_managed_worktree_v2_failure_probe
2305 .clone()
2306 }
2307
2308 pub fn install_environment_profile(
2313 &mut self,
2314 profile: NodeEnvironmentProfile,
2315 ) -> Result<(), NodeServerError> {
2316 if !self.shared.enabled_providers.contains(profile.provider()) {
2317 return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2318 profile.provider().clone(),
2319 ));
2320 }
2321 let (binding, native_profiles, materialization) = profile.into_parts();
2322 if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2323 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2324 }
2325 let mut profiles = self
2326 .shared
2327 .environment_profiles
2328 .write()
2329 .unwrap_or_else(|poisoned| poisoned.into_inner());
2330 if profiles.contains_key(&binding.id) {
2331 return Err(NodeServerError::DuplicateEnvironmentProfile(
2332 binding.id.clone(),
2333 ));
2334 }
2335 if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2336 return Err(NodeServerError::EnvironmentProfileCapacity {
2337 max: MAX_NODE_ENVIRONMENT_PROFILES,
2338 });
2339 }
2340 for native_id in binding.native_profile_ids() {
2341 if profiles.values().any(|current| {
2342 current.native_profile_ids().any(|current_id| current_id == native_id)
2343 }) {
2344 return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2345 native_id.to_string(),
2346 ));
2347 }
2348 }
2349
2350 let mut installed = Vec::with_capacity(native_profiles.len());
2351 for native_profile in native_profiles {
2352 let native_id = native_profile.id().clone();
2353 if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2354 for installed_id in installed.into_iter().rev() {
2355 let _ = self.runtime.remove_native_launch_profile(&installed_id);
2356 }
2357 return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2358 }
2359 installed.push(native_id);
2360 }
2361 if let Some(materialization) = materialization {
2362 self.shared
2363 .environment_materialization_profiles
2364 .write()
2365 .unwrap_or_else(|poisoned| poisoned.into_inner())
2366 .insert(binding.id.clone(), materialization);
2367 }
2368 profiles.insert(binding.id.clone(), binding);
2369 Ok(())
2370 }
2371
2372 pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2374 if self.shared.session_environment_materializer.is_none() {
2375 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2376 }
2377 let mut catalog = self
2378 .shared
2379 .bundle_catalog
2380 .write()
2381 .unwrap_or_else(|poisoned| poisoned.into_inner());
2382 catalog
2383 .insert_idempotent(bundle)
2384 .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2385 Ok(())
2386 }
2387
2388 pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2389 let shutdown = self.shutdown_handle();
2390 let run = self.run();
2391 tokio::pin!(run);
2392 tokio::select! {
2393 result = &mut run => result,
2394 signal = wait_for_ctrl_signal() => {
2395 signal?;
2396 let shutdown_result = shutdown.request_shutdown().await;
2397 let run_result = run.await;
2398 shutdown_result.and(run_result)
2399 }
2400 }
2401 }
2402
2403 pub async fn run(self) -> Result<(), NodeServerError> {
2404 let NodeServer {
2405 config,
2406 mut runtime,
2407 shared,
2408 events,
2409 state_path_lock: _state_path_lock,
2410 } = self;
2411 shared.reconcile_materializations();
2412 shared.reconcile_managed_worktrees().await;
2413 shared.retire_unavailable_session_records().await;
2414 shared.reconcile_context_pack_exports().await;
2415 let endpoint = config.endpoint.clone();
2419 let call_home = config.call_home;
2420 let api_listen = config.api_listen;
2421 let bridge_listen = config.bridge_listen;
2422 let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2423 .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2424 let accept_shared = Arc::clone(&shared);
2425 let api_shared = Arc::clone(&shared);
2426 let bridge_shared = Arc::clone(&shared);
2427 let shutdown_shared = Arc::clone(&shared);
2428 let shutdown_timeout = Duration::from_millis(
2429 config.runtime.provider_shutdown_timeout_ms.max(1),
2430 );
2431 let result = {
2432 let runtime_loop = drive_runtime_until_shutdown(
2433 &mut runtime,
2434 events,
2435 Arc::clone(&shared),
2436 shutdown_timeout,
2437 );
2438 let accept_loop = accept_connections(&endpoint, call_home, accept_shared);
2439 let api_loop = http_api::run(api_listen, api_shared);
2440 let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared);
2441 tokio::pin!(runtime_loop);
2442 tokio::pin!(accept_loop);
2443 tokio::pin!(api_loop);
2444 tokio::pin!(bridge_loop);
2445 tokio::select! {
2446 runtime_result = &mut runtime_loop => {
2447 let shutdown_result = shutdown_shared
2448 .begin_shutdown()
2449 .await
2450 .map_err(NodeServerError::ShutdownDispatch);
2451 let (accept_result, api_result, bridge_result) =
2452 tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2453 match runtime_result {
2454 Err(error) => Err(error),
2455 Ok(()) => shutdown_result
2456 .and(accept_result)
2457 .and(api_result.map_err(NodeServerError::HttpApi))
2458 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2459 }
2460 }
2461 accept_result = &mut accept_loop => {
2462 let shutdown_result = shutdown_shared
2463 .begin_shutdown()
2464 .await
2465 .map_err(NodeServerError::ShutdownDispatch);
2466 let (runtime_result, api_result, bridge_result) =
2467 tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2468 match accept_result {
2469 Err(error) => Err(error),
2470 Ok(()) => shutdown_result
2471 .and(runtime_result)
2472 .and(api_result.map_err(NodeServerError::HttpApi))
2473 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2474 }
2475 }
2476 api_result = &mut api_loop => {
2477 let shutdown_result = shutdown_shared
2478 .begin_shutdown()
2479 .await
2480 .map_err(NodeServerError::ShutdownDispatch);
2481 let (runtime_result, accept_result, bridge_result) =
2482 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2483 match api_result {
2484 Err(error) => Err(NodeServerError::HttpApi(error)),
2485 Ok(()) => shutdown_result
2486 .and(runtime_result)
2487 .and(accept_result)
2488 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2489 }
2490 }
2491 bridge_result = &mut bridge_loop => {
2492 let shutdown_result = shutdown_shared
2493 .begin_shutdown()
2494 .await
2495 .map_err(NodeServerError::ShutdownDispatch);
2496 let (runtime_result, accept_result, api_result) =
2497 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2498 match bridge_result {
2499 Err(error) => Err(NodeServerError::BridgeApi(error)),
2500 Ok(()) => shutdown_result
2501 .and(runtime_result)
2502 .and(accept_result)
2503 .and(api_result.map_err(NodeServerError::HttpApi)),
2504 }
2505 }
2506 }
2507 };
2508 result
2509 }
2510}
2511
2512async fn drive_runtime_until_shutdown(
2513 runtime: &mut NativeRuntime,
2514 events: EventSubscription,
2515 shared: Arc<NodeShared>,
2516 shutdown_timeout: Duration,
2517) -> Result<(), NodeServerError> {
2518 let _ = shared
2522 .shell_efficiency_profile
2523 .set(runtime.shell_efficiency_profile());
2524 let mut shutdown_started = None;
2525 let mut last_retention_sweep = Instant::now();
2526 loop {
2527 let runtime_tick_start = Instant::now();
2534 let tick_result = runtime.tick().await;
2535 let runtime_tick_elapsed = runtime_tick_start.elapsed();
2536
2537 let event_drain_start = Instant::now();
2538 for outcome in &tick_result.command_outcomes {
2546 if let Err(error) = &outcome.result {
2547 shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2548 tracing::warn!(
2549 command_id = outcome.command_id.0,
2550 cause = %error,
2551 "control command rejected"
2552 );
2553 shared.record_rejected_command(
2554 outcome.command_id,
2555 error.to_string(),
2556 error.is_unsupported_transport(),
2557 );
2558 }
2559 }
2560 let mut events_drained: u64 = 0;
2561 while let Ok(event) = events.try_recv() {
2562 events_drained += 1;
2563 let clean_exit = matches!(
2564 event.event,
2565 ControlEventKind::Exited { exit_code: Some(0), forced: false },
2566 );
2567 let instance_id = event.instance_id;
2568 let generation = event.generation;
2569 if clean_exit {
2581 if let Some((record_id, session)) =
2582 shared.managed_record_export_target_for_instance(instance_id, generation)
2583 {
2584 let export_shared = Arc::clone(&shared);
2585 tokio::spawn(async move {
2586 export_shared
2587 .reconcile_context_pack_export_for_record(&record_id, session)
2588 .await;
2589 });
2590 }
2591 }
2592 shared.publish_control(event);
2593 }
2594 let event_drain_elapsed = event_drain_start.elapsed();
2595
2596 let publish_terminal_frames_start = Instant::now();
2597 shared.publish_terminal_frames();
2598 let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2599
2600 shared
2606 .native_session_gauge
2607 .store(runtime.active_native_sessions(), Ordering::Relaxed);
2608 *shared
2609 .runtime_tick_profile
2610 .lock()
2611 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2612
2613 let remainder_start = Instant::now();
2614 if shared.shutdown.load(Ordering::Acquire) {
2615 let started = *shutdown_started.get_or_insert_with(Instant::now);
2616 let snapshot = shared.handle.snapshot();
2617 let sessions_settled = snapshot.sessions.iter().all(|session| {
2618 !matches!(
2619 session.status,
2620 gate4agent_types::SessionStatus::Starting
2621 | gate4agent_types::SessionStatus::Running
2622 | gate4agent_types::SessionStatus::Stopping
2623 )
2624 });
2625 if runtime.active_native_sessions() == 0 && sessions_settled {
2626 return Ok(());
2627 }
2628 if started.elapsed() >= shutdown_timeout {
2629 return Err(NodeServerError::ShutdownTimedOut {
2630 active_native_sessions: runtime.active_native_sessions(),
2631 });
2632 }
2633 }
2634 let remainder_elapsed = remainder_start.elapsed();
2635
2636 {
2637 let mut profile = shared
2638 .drive_loop_profile
2639 .lock()
2640 .unwrap_or_else(|poisoned| poisoned.into_inner());
2641 profile.record_runtime_tick(runtime_tick_elapsed);
2642 profile.record_event_drain(event_drain_elapsed);
2643 profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2644 profile.record_remainder(remainder_elapsed);
2645 profile.note_iteration(Instant::now());
2646 }
2647
2648 shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2660 if tick_result.observations_applied == 0 && events_drained == 0 {
2661 shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2662 }
2663
2664 if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2669 shared.retire_unavailable_session_records().await;
2670 last_retention_sweep = Instant::now();
2671 }
2672
2673 sleep(Duration::from_millis(10)).await;
2674 }
2675}
2676
2677#[derive(Clone)]
2678pub struct NodeShutdownHandle {
2679 shared: Arc<NodeShared>,
2680}
2681
2682impl NodeShutdownHandle {
2683 pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2684 self.shared
2685 .begin_shutdown()
2686 .await
2687 .map_err(NodeServerError::ShutdownDispatch)
2688 }
2689}
2690
2691async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2692 #[cfg(windows)]
2693 {
2694 let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2695 let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2696 return tokio::select! {
2697 signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2698 signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2699 };
2700 }
2701 #[cfg(unix)]
2702 {
2703 let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2704 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2705 tokio::select! {
2706 signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2707 signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2708 }
2709 }
2710}
2711
2712fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2725 let specs = builtin_registry()
2726 .iter()
2727 .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2728 .cloned()
2729 .collect::<Vec<_>>();
2730 AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2731}
2732
2733fn provider_contract_manifest(
2734 catalog: &AgentRegistry,
2735) -> Result<
2736 (
2737 Vec<ProviderContractSupport>,
2738 Vec<ProviderAdapterContractSupport>,
2739 ),
2740 NodeServerError,
2741> {
2742 let mut provider_contracts = Vec::new();
2743 let mut provider_adapter_contracts = Vec::new();
2744 for spec in catalog.iter() {
2745 let provider = spec.id.clone();
2746 let revision = ProviderContractRevision::new(spec.revision.clone())
2747 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2748 provider_contracts.push(ProviderContractSupport {
2749 provider: provider.clone(),
2750 revision,
2751 });
2752 for (family, binding) in declared_adapter_bindings(spec)? {
2753 let revision = AdapterContractRevision::new(binding.revision.clone())
2754 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2755 provider_adapter_contracts.push(ProviderAdapterContractSupport {
2756 provider: provider.clone(),
2757 family,
2758 adapter_id: binding.id.clone(),
2759 revision,
2760 });
2761 }
2762 }
2763 validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2764 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2765 Ok((provider_contracts, provider_adapter_contracts))
2766}
2767
2768fn declared_adapter_bindings(
2769 spec: &AgentSpec,
2770) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2771 let mut bindings = Vec::new();
2772 push_declared_binding(
2773 &mut bindings,
2774 AdapterFamily::PtySemantic,
2775 spec.capabilities.transports.pty_adapter.as_ref(),
2776 )?;
2777 push_declared_binding(
2778 &mut bindings,
2779 AdapterFamily::Pipe,
2780 spec.capabilities.adapters.pty_sidecar.as_ref(),
2781 )?;
2782 let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2783 let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2784 gate4agent_types::PipeProtocol::SemanticNdjson
2785 | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2786 gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2787 });
2788 push_declared_binding(
2789 &mut bindings,
2790 AdapterFamily::Pipe,
2791 pipe_transport
2792 .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2793 .map(|transport| &transport.adapter),
2794 )?;
2795 push_declared_binding(
2796 &mut bindings,
2797 AdapterFamily::Acp,
2798 spec.capabilities
2799 .transports
2800 .acp
2801 .as_ref()
2802 .map(|transport| &transport.adapter),
2803 )?;
2804 push_declared_binding(
2805 &mut bindings,
2806 AdapterFamily::Hook,
2807 spec.capabilities.adapters.hook.as_ref(),
2808 )?;
2809 push_declared_binding(
2810 &mut bindings,
2811 AdapterFamily::ManagedHook,
2812 spec.capabilities.adapters.managed_hook.as_ref(),
2813 )?;
2814 push_declared_binding(
2815 &mut bindings,
2816 AdapterFamily::OneShot,
2817 pipe_transport
2818 .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
2819 .map(|transport| &transport.adapter),
2820 )?;
2821 push_declared_binding(
2822 &mut bindings,
2823 AdapterFamily::OneShot,
2824 spec.capabilities.adapters.one_shot.as_ref(),
2825 )?;
2826 push_declared_binding(
2827 &mut bindings,
2828 AdapterFamily::History,
2829 spec.capabilities.adapters.history.as_ref(),
2830 )?;
2831 push_declared_binding(
2832 &mut bindings,
2833 AdapterFamily::Resume,
2834 spec.capabilities.adapters.resume.as_ref(),
2835 )?;
2836 push_declared_binding(
2837 &mut bindings,
2838 AdapterFamily::SessionOptions,
2839 spec.capabilities.adapters.session_options.as_ref(),
2840 )?;
2841 push_declared_binding(
2842 &mut bindings,
2843 AdapterFamily::CapabilityProbe,
2844 spec.capabilities.adapters.capability_probe.as_ref(),
2845 )?;
2846 Ok(bindings)
2847}
2848
2849fn push_declared_binding<'a>(
2850 bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
2851 family: AdapterFamily,
2852 binding: Option<&'a AdapterBinding>,
2853) -> Result<(), NodeServerError> {
2854 let Some(binding) = binding else {
2855 return Ok(());
2856 };
2857 if let Some((_, existing)) = bindings
2858 .iter()
2859 .find(|(existing_family, _)| *existing_family == family)
2860 {
2861 if *existing == binding {
2862 return Ok(());
2863 }
2864 return Err(NodeServerError::ProviderContractManifest(format!(
2865 "provider declares conflicting {family:?} adapter bindings"
2866 )));
2867 }
2868 bindings.push((family, binding));
2869 Ok(())
2870}
2871
2872fn merge_durable_state(
2873 configured: &[WorkspaceConfig],
2874 mut loaded: LoadedNodeState,
2875) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
2876 let mut workspaces = configured
2877 .iter()
2878 .cloned()
2879 .map(|workspace| (workspace.workspace_id.clone(), workspace))
2880 .collect::<BTreeMap<_, _>>();
2881 let mut roots = workspaces
2882 .values()
2883 .map(|workspace| {
2884 (
2885 platform::root_identity(&workspace.canonical_root),
2886 workspace.workspace_id.clone(),
2887 )
2888 })
2889 .collect::<BTreeMap<_, _>>();
2890 let mut warning = loaded
2891 .warning
2892 .take()
2893 .map(|message| session_registry::sanitized_persistence_summary(&message));
2894
2895 for (workspace_id, canonical_root) in loaded.workspaces {
2896 if let Some(existing) = workspaces.get(&workspace_id) {
2897 if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
2898 return Err(NodeServerError::DurableState(io::Error::new(
2899 io::ErrorKind::InvalidData,
2900 DURABLE_STATE_CONFLICT_ERROR,
2901 )));
2902 }
2903 continue;
2904 }
2905 if roots.get(&platform::root_identity(&canonical_root)).is_some() {
2906 return Err(NodeServerError::DurableState(io::Error::new(
2907 io::ErrorKind::InvalidData,
2908 DURABLE_STATE_CONFLICT_ERROR,
2909 )));
2910 }
2911 if !Path::new(&canonical_root).is_dir() {
2912 warning.get_or_insert_with(|| {
2913 session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
2914 });
2915 continue;
2916 }
2917 let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
2918 NodeServerError::DurableState(io::Error::new(
2919 io::ErrorKind::InvalidData,
2920 DURABLE_STATE_CONFLICT_ERROR,
2921 ))
2922 })?;
2923 roots.insert(
2924 platform::root_identity(&workspace.canonical_root),
2925 workspace.workspace_id.clone(),
2926 );
2927 workspaces.insert(workspace_id, workspace);
2928 }
2929
2930 for record in &mut loaded.records {
2931 match workspaces.get(&record.workspace_id) {
2932 Some(workspace)
2933 if platform::roots_equal(
2934 &workspace.canonical_root,
2935 windows_path_text(&record.canonical_root),
2936 ) => {}
2937 Some(_) => {
2938 return Err(NodeServerError::DurableState(io::Error::new(
2939 io::ErrorKind::InvalidData,
2940 DURABLE_STATE_CONFLICT_ERROR,
2941 )));
2942 }
2943 None => {
2944 record.state = ManagedSessionState::Unavailable;
2945 record.active_session = None;
2946 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
2947 }
2948 }
2949 }
2950 Ok((workspaces.into_values().collect(), loaded.records, warning))
2951}
2952
2953struct RejectedCommandReason {
2956 message: String,
2957 unsupported_transport: bool,
2964 expires_at: Instant,
2965}
2966
2967struct NodeShared {
2968 handle: Gate4AgentHandle,
2969 access_token: String,
2970 node_id: NodeId,
2971 incarnation_id: NodeIncarnationId,
2972 started_at_unix_ms: u64,
2973 workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
2974 worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
2975 managed_worktree_profiles:
2976 BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
2977 managed_worktrees: Mutex<ManagedWorktreeRegistry>,
2978 enabled_providers: Vec<AgentId>,
2979 provider_runtime_statuses: ProviderRuntimeStatuses,
2980 provider_runtime_status_updates:
2981 RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
2982 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
2983 provider_contracts: Vec<ProviderContractSupport>,
2984 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
2985 spawn_profiles: SpawnProfileRegistry,
2986 environment_profiles:
2987 RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
2988 environment_materialization_profiles:
2989 RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
2990 network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
2998 #[cfg(feature = "dig2-station-probe")]
3002 browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3003 bundle_catalog: RwLock<BundleCatalog>,
3004 delivery_store: Mutex<Option<DeliveryStore>>,
3005 context_pack_store: Mutex<Option<ContextPackStore>>,
3006 context_catalog: RwLock<ContextPackCatalog>,
3007 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3008 harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3009 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3010 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3011 materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3012 spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3013 managed_spawn_replays:
3014 Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3015 controller: Mutex<Option<ControllerLease>>,
3016 history: Mutex<NodeEventHistory>,
3017 event_tx: broadcast::Sender<NodeEventEnvelope>,
3018 terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3019 agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3027 harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3036 terminal_frame_watermarks:
3037 Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3038 next_connection_id: AtomicU64,
3039 next_instance_id: AtomicU64,
3040 next_command_id: AtomicU64,
3041 shutdown: AtomicBool,
3042 shutdown_notify: Notify,
3043 preauth_slots: Arc<Semaphore>,
3044 authenticated_slots: Arc<Semaphore>,
3045 inspection_slots: Arc<Semaphore>,
3046 mutation_gate: AsyncMutex<()>,
3047 session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3048 session_records: Mutex<SessionRecords>,
3049 state_path: Option<PathBuf>,
3050 persistence_error: RwLock<Option<String>>,
3051 state_transaction: Mutex<()>,
3052 input_settle_timeout_ms: u64,
3053 drive_loop_profile: Mutex<DriveLoopProfiler>,
3057 runtime_tick_profile: Mutex<TickProfileSnapshot>,
3063 shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3075 native_session_gauge: AtomicUsize,
3082 connection_loop_iterations: AtomicU64,
3093 connection_events_sent: AtomicU64,
3094 rejected_commands_total: AtomicU64,
3103 rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3123 drive_loop_iterations_total: AtomicU64,
3124 drive_loop_iterations_idle: AtomicU64,
3125 session_record_retention: SessionRecordRetentionConfig,
3129 retired_records_total: AtomicU64,
3135 #[cfg(feature = "fixture")]
3136 fixture_semantic_hook_policy: bool,
3137 #[cfg(feature = "fixture")]
3138 fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3139}
3140
3141impl Drop for NodeShared {
3142 fn drop(&mut self) {
3143 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3144 let instances = registry.shutdown();
3145 if let Some(control) = self.native_launch_profile_control.as_ref() {
3146 for instance_id in instances {
3147 control.clear_native_mcp_server_launch_overlay(instance_id);
3148 }
3149 }
3150 }
3151 }
3152}
3153
3154#[derive(Clone, Debug, Eq, PartialEq)]
3155struct SessionBinding {
3156 workspace_id: WorkspaceId,
3157 generation: SessionGeneration,
3158 runtime_policy: ProviderRuntimePolicy,
3159 pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3160 record_id: Option<SessionRecordId>,
3161 managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3162 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3163 bundle: Option<ResolvedBundleReceipt>,
3164 context: Option<ResolvedContextPackReceipt>,
3165 materialization_id: Option<MaterializationId>,
3166}
3167
3168#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3169enum SpawnRecordPolicy {
3170 ProviderIdentityOnly,
3171 Always,
3172}
3173
3174struct NativeEnvironmentSelectionGuard {
3175 control: Option<NativeLaunchProfileControl>,
3176 instance_id: AgentInstanceId,
3177}
3178
3179struct NativeInstanceOverlayGuard {
3180 control: Option<NativeLaunchProfileControl>,
3181 instance_id: AgentInstanceId,
3182}
3183
3184struct NativeHarnessMcpOverlayGuard {
3185 control: NativeLaunchProfileControl,
3186 instance_id: AgentInstanceId,
3187}
3188
3189impl NativeHarnessMcpOverlayGuard {
3190 fn retain(self) { std::mem::forget(self); }
3191}
3192
3193impl Drop for NativeHarnessMcpOverlayGuard {
3194 fn drop(&mut self) {
3195 self.control
3196 .clear_native_mcp_server_launch_overlay(self.instance_id);
3197 }
3198}
3199
3200impl NativeInstanceOverlayGuard {
3201 fn retain(mut self) {
3202 self.control = None;
3203 }
3204}
3205
3206impl Drop for NativeInstanceOverlayGuard {
3207 fn drop(&mut self) {
3208 if let Some(control) = self.control.as_ref() {
3209 control.clear_native_instance_launch_overlay(self.instance_id);
3210 }
3211 }
3212}
3213
3214enum PreparedNativeLaunchOverlay {
3215 Environment(NativeLaunchEnvironmentOverlay),
3216 Instance(NativeInstanceLaunchOverlay),
3217}
3218
3219impl NativeEnvironmentSelectionGuard {
3220 fn retain(mut self) {
3221 self.control = None;
3222 }
3223}
3224
3225impl Drop for NativeEnvironmentSelectionGuard {
3226 fn drop(&mut self) {
3227 if let Some(control) = self.control.as_ref() {
3228 control.clear_native_launch_profile_selection(self.instance_id);
3229 }
3230 }
3231}
3232
3233struct SessionMaterializationGuard<'a> {
3234 shared: &'a NodeShared,
3235 id: Option<MaterializationId>,
3236 provider_home: Option<PathBuf>,
3239}
3240
3241impl SessionMaterializationGuard<'_> {
3242 fn id(&self) -> Option<&MaterializationId> {
3243 self.id.as_ref()
3244 }
3245
3246 fn provider_home(&self) -> Option<&Path> {
3247 self.provider_home.as_deref()
3248 }
3249
3250 fn retain(mut self) {
3251 self.id = None;
3252 }
3253}
3254
3255impl Drop for SessionMaterializationGuard<'_> {
3256 fn drop(&mut self) {
3257 if let Some(id) = self.id.take() {
3258 let _ = self.shared.cleanup_materialization(&id);
3259 }
3260 }
3261}
3262
3263struct SessionRecords {
3264 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3265}
3266
3267struct SpawnIdempotencyCache {
3268 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3269}
3270
3271struct SpawnIdempotencyEntry {
3272 value: SpawnIdempotencyValue,
3273 expires_at: Instant,
3274}
3275
3276enum SpawnIdempotencyValue {
3277 Standard {
3278 spec: SpawnSpec,
3279 result: Result<ResolvedSpawnReceipt, NodeFailure>,
3280 },
3281 Managed {
3282 request: ManagedWorktreeSpawnRequest,
3283 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3284 },
3285 HarnessMcp,
3286}
3287
3288enum DurableManagedSpawnReplayDecision {
3289 Reserved,
3290 PendingLinked,
3291 Committed(ManagedWorktreeSpawnReceipt),
3292}
3293
3294impl SpawnIdempotencyValue {
3295 fn references_context(&self, context_id: &SpawnContextId) -> bool {
3296 match self {
3297 Self::Standard { result, .. } => result
3298 .as_ref()
3299 .ok()
3300 .and_then(|receipt| receipt.context.as_ref()),
3301 Self::Managed { result, .. } => result
3302 .as_ref()
3303 .ok()
3304 .and_then(|receipt| receipt.spawn.context.as_ref()),
3305 Self::HarnessMcp => None,
3306 }
3307 .is_some_and(|receipt| &receipt.id == context_id)
3308 }
3309}
3310
3311#[derive(Clone, Copy)]
3312struct ControllerLease {
3313 connection_id: u64,
3314 expires_at: Instant,
3315}
3316
3317struct NodeEventHistory {
3318 last_sequence: u64,
3319 replay_floor_sequence: u64,
3320 events: VecDeque<NodeEventEnvelope>,
3321 record_providers: BTreeMap<SessionRecordId, AgentId>,
3322 removed_record_providers: BTreeMap<u64, AgentId>,
3323}
3324
3325impl NodeEventHistory {
3326 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
3327 Self {
3328 last_sequence: 0,
3329 replay_floor_sequence: 1,
3330 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
3331 record_providers,
3332 removed_record_providers: BTreeMap::new(),
3333 }
3334 }
3335}
3336
3337impl NodeShared {
3338 fn new_with_incarnation(
3339 handle: Gate4AgentHandle,
3340 access_token: String,
3341 node_id: NodeId,
3342 incarnation_id: NodeIncarnationId,
3343 workspaces: Vec<WorkspaceConfig>,
3344 enabled_providers: Vec<AgentId>,
3345 provider_runtime_statuses: ProviderRuntimeStatuses,
3346 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3347 provider_contracts: Vec<ProviderContractSupport>,
3348 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3349 spawn_profiles: SpawnProfileRegistry,
3350 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3351 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3352 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3353 state_path: Option<PathBuf>,
3354 records: Vec<ManagedSessionRecord>,
3355 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
3356 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
3357 materializations: Vec<MaterializationOwnershipRecord>,
3358 persistence_warning: Option<String>,
3359 input_settle_timeout_ms: u64,
3360 ) -> Self {
3361 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
3362 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
3363 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
3364 let (harness_mcp_proxy_event_tx, _) =
3365 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
3366 let record_providers = records
3367 .iter()
3368 .map(|record| (record.record_id.clone(), record.provider.clone()))
3369 .collect();
3370 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
3371 .iter()
3372 .map(|workspace| {
3373 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
3374 })
3375 .collect();
3376 for lease in &managed_worktrees {
3377 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
3378 }
3379 let managed_worktree_profiles = workspaces.iter()
3380 .map(|workspace| (
3381 workspace.workspace_id.clone(),
3382 workspace.managed_worktree_profiles.clone(),
3383 ))
3384 .collect();
3385 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
3386 managed_worktrees,
3387 managed_worktree_tombstones,
3388 ).expect("durable managed worktree registry was validated while loading");
3389 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
3390 managed_worktrees.reattach_record_holders(
3391 &managed_worktree_record_holders(&records),
3392 unix_time_ms(),
3393 );
3394 Self {
3395 handle,
3396 access_token,
3397 node_id,
3398 incarnation_id,
3399 started_at_unix_ms: std::time::SystemTime::now()
3400 .duration_since(std::time::UNIX_EPOCH)
3401 .unwrap_or_default()
3402 .as_millis()
3403 .min(u64::MAX as u128) as u64,
3404 workspaces: RwLock::new(
3405 workspaces
3406 .into_iter()
3407 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
3408 .collect(),
3409 ),
3410 worktree_service_modes,
3411 managed_worktree_profiles,
3412 managed_worktrees: Mutex::new(managed_worktrees),
3413 enabled_providers,
3414 provider_runtime_statuses,
3415 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
3416 provider_runtime_monitor,
3417 provider_contracts,
3418 provider_adapter_contracts,
3419 spawn_profiles,
3420 environment_profiles: RwLock::new(BTreeMap::new()),
3421 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
3422 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
3423 #[cfg(feature = "dig2-station-probe")]
3424 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
3425 bundle_catalog: RwLock::new(BundleCatalog::default()),
3426 delivery_store: Mutex::new(None),
3427 context_pack_store: Mutex::new(None),
3428 context_catalog: RwLock::new(ContextPackCatalog::default()),
3429 native_launch_profile_control,
3430 harness_mcp_registry: None,
3431 native_session_catalog,
3432 session_environment_materializer,
3433 materializations: Mutex::new(
3434 materializations
3435 .into_iter()
3436 .map(|record| (record.id().clone(), record))
3437 .collect(),
3438 ),
3439 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
3440 entries: BTreeMap::new(),
3441 }),
3442 managed_spawn_replays: Mutex::new(BTreeMap::new()),
3443 controller: Mutex::new(None),
3444 history: Mutex::new(NodeEventHistory::new(record_providers)),
3445 event_tx,
3446 terminal_event_tx,
3447 agent_stream_event_tx,
3448 harness_mcp_proxy_event_tx,
3449 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
3450 next_connection_id: AtomicU64::new(1),
3451 next_instance_id: AtomicU64::new(1),
3452 next_command_id: AtomicU64::new(1),
3453 shutdown: AtomicBool::new(false),
3454 shutdown_notify: Notify::new(),
3455 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
3456 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
3457 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
3458 mutation_gate: AsyncMutex::new(()),
3459 session_bindings: Mutex::new(BTreeMap::new()),
3460 session_records: Mutex::new(SessionRecords {
3461 records: records
3462 .into_iter()
3463 .map(|record| (record.record_id.clone(), record))
3464 .collect(),
3465 }),
3466 state_path,
3467 persistence_error: RwLock::new(
3468 persistence_warning
3469 .map(|message| session_registry::sanitized_persistence_summary(&message)),
3470 ),
3471 state_transaction: Mutex::new(()),
3472 input_settle_timeout_ms,
3473 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
3474 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
3475 shell_efficiency_profile: OnceLock::new(),
3476 native_session_gauge: AtomicUsize::new(0),
3477 connection_loop_iterations: AtomicU64::new(0),
3478 connection_events_sent: AtomicU64::new(0),
3479 rejected_commands_total: AtomicU64::new(0),
3480 rejected_command_reasons: Mutex::new(BTreeMap::new()),
3481 drive_loop_iterations_total: AtomicU64::new(0),
3482 drive_loop_iterations_idle: AtomicU64::new(0),
3483 session_record_retention: SessionRecordRetentionConfig::default(),
3484 retired_records_total: AtomicU64::new(0),
3485 #[cfg(feature = "fixture")]
3486 fixture_semantic_hook_policy: false,
3487 #[cfg(feature = "fixture")]
3488 fixture_spawn_managed_worktree_v2_failure_probe:
3489 SpawnManagedWorktreeV2FailureProbe::default(),
3490 }
3491 }
3492
3493 #[cfg(test)]
3494 fn new(
3495 handle: Gate4AgentHandle,
3496 access_token: String,
3497 node_id: NodeId,
3498 workspaces: Vec<WorkspaceConfig>,
3499 enabled_providers: Vec<AgentId>,
3500 ) -> Self {
3501 Self::new_with_incarnation(
3502 handle,
3503 access_token,
3504 node_id,
3505 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
3506 workspaces,
3507 enabled_providers,
3508 ProviderRuntimeStatuses::default(),
3509 None,
3510 Vec::new(),
3511 Vec::new(),
3512 SpawnProfileRegistry::default(),
3513 None,
3514 None,
3515 None,
3516 None,
3517 Vec::new(),
3518 Vec::new(),
3519 Vec::new(),
3520 Vec::new(),
3521 None,
3522 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
3523 )
3524 }
3525
3526 fn persistence_error(&self) -> Option<String> {
3527 self.persistence_error
3528 .read()
3529 .unwrap_or_else(|poisoned| poisoned.into_inner())
3530 .clone()
3531 }
3532
3533 async fn admit_provider_runtime(
3534 &self,
3535 provider: &AgentId,
3536 requirement: ProviderRuntimeRequirement,
3537 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
3538 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
3539 let pipe_transport = monitor.supports_pipe_transport(provider);
3540 let acp_transport = monitor.supports_acp_transport(provider);
3541 let provider = provider.clone();
3542 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
3543 let (status, admission) = timeout(
3544 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
3545 refresh,
3546 )
3547 .await
3548 .map_err(|_| failure(
3549 NodeFailureCode::BackendBusy,
3550 "provider runtime probe exceeded its bounded deadline",
3551 ))?
3552 .map_err(|_| failure(
3553 NodeFailureCode::BackendOperationFailed,
3554 "provider runtime probe task failed",
3555 ))?;
3556 if let Some(status) = status {
3557 self
3558 .provider_runtime_status_updates
3559 .write()
3560 .unwrap_or_else(|poisoned| poisoned.into_inner())
3561 .insert(status.provider().clone(), status);
3562 }
3563 admission.and_then(|policy| {
3564 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
3565 })
3566 } else {
3567 crate::provider_runtime::admit_status(
3573 &self.provider_runtime_statuses,
3574 provider,
3575 false,
3576 false,
3577 requirement,
3578 )
3579 };
3580 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
3581 admission.map_err(|error| match error {
3582 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
3583 NodeFailureCode::BackendOperationFailed,
3584 "provider launcher is unavailable",
3585 ),
3586 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
3587 NodeFailureCode::UnsupportedCapability,
3588 "provider semantic capability is not verified",
3589 ),
3590 ProviderRuntimeAdmissionError::ProbeBusy => failure(
3591 NodeFailureCode::BackendBusy,
3592 "provider runtime probe is already in progress",
3593 ),
3594 })
3595 }
3596
3597 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
3603 match self.provider_runtime_monitor.as_deref() {
3604 Some(monitor) => monitor.supports_pipe_transport(provider),
3605 None => false,
3606 }
3607 }
3608
3609 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
3615 match self.provider_runtime_monitor.as_deref() {
3616 Some(monitor) => monitor.supports_acp_transport(provider),
3617 None => false,
3618 }
3619 }
3620
3621 fn admit_declared_hook_semantics(
3637 &self,
3638 provider: &AgentId,
3639 policy: ProviderRuntimePolicy,
3640 ) -> ProviderRuntimePolicy {
3641 if policy.hook_semantics || !policy.raw_pty_lifecycle {
3642 return policy;
3643 }
3644 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
3645 contract.provider == *provider && contract.family == AdapterFamily::Hook
3646 });
3647 if !declares_hook {
3648 return policy;
3649 }
3650 ProviderRuntimePolicy::new(
3651 policy.raw_pty_lifecycle,
3652 policy.semantic_readiness,
3653 policy.structured_prompt,
3654 policy.provider_session_identity,
3655 policy.semantic_resume,
3656 true,
3657 )
3658 .expect("granting hook semantics over a raw-PTY policy is internally valid")
3659 }
3660
3661 fn resolve_network_allowlist_overlay_args(
3676 &self,
3677 provider: &AgentId,
3678 mode: SessionMode,
3679 approval_level: ApprovalLevel,
3680 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
3681 provider_home: Option<&Path>,
3682 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
3683 let Some(allowlist_id) = network_allowlist else {
3684 return Ok(Vec::new());
3685 };
3686 let catalog = self
3687 .network_allowlist_catalog
3688 .read()
3689 .unwrap_or_else(|poisoned| poisoned.into_inner());
3690 let Some(entry) = catalog.get(allowlist_id) else {
3691 return Err(failure(
3692 NodeFailureCode::UnknownNetworkAllowlist,
3693 "spawn network allowlist is unavailable on this node",
3694 ));
3695 };
3696
3697 if provider.as_str() == "claude" && !entry.permits.is_empty() {
3699 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
3700 |message| {
3701 failure(
3702 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3703 &message,
3704 )
3705 },
3706 )?;
3707 if !matches!(mode, SessionMode::Pty) {
3708 return Err(failure(
3709 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3710 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
3711 ));
3712 }
3713 let Some(home) = provider_home else {
3714 return Err(failure(
3715 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3716 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
3717 ));
3718 };
3719 let domains =
3720 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
3721 if domains.is_empty() {
3722 return Err(failure(
3723 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3724 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
3725 ));
3726 }
3727 let settings_path = home.join(
3728 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
3729 );
3730 let json =
3731 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
3732 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
3733 |_| {
3734 failure(
3735 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3736 "Claude station network settings sidecar could not be written under ProviderHome",
3737 )
3738 },
3739 )?;
3740 let overlay =
3741 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
3742 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
3743 }
3744
3745 let Some(native) = entry.provider_native.as_ref() else {
3746 return Ok(Vec::new());
3747 };
3748 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
3749 provider.as_str(),
3750 approval_level,
3751 native,
3752 )
3753 .map_err(|message| {
3754 failure(
3755 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3756 &message,
3757 )
3758 })?;
3759 if overlay.is_empty() {
3760 return Ok(Vec::new());
3761 }
3762 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
3763 return Err(failure(
3764 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3765 "Codex network_access -c overlay requires PTY or ACP transport",
3766 ));
3767 }
3768 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
3769 }
3770
3771 fn resolve_codex_network_access_overlay_args(
3773 &self,
3774 provider: &AgentId,
3775 mode: SessionMode,
3776 approval_level: ApprovalLevel,
3777 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
3778 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
3779 self.resolve_network_allowlist_overlay_args(
3780 provider,
3781 mode,
3782 approval_level,
3783 network_allowlist,
3784 None,
3785 )
3786 }
3787
3788 fn resolve_environment_profile(
3789 &self,
3790 resolved: &ResolvedSpawnSpec,
3791 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
3792 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
3798 let catalog = self
3799 .network_allowlist_catalog
3800 .read()
3801 .unwrap_or_else(|poisoned| poisoned.into_inner());
3802 let Some(entry) = catalog.get(allowlist_id) else {
3803 return Err(failure(
3804 NodeFailureCode::UnknownNetworkAllowlist,
3805 "spawn network allowlist is unavailable on this node",
3806 ));
3807 };
3808 if let Some(native) = entry.provider_native.as_ref() {
3811 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
3812 resolved.provider.as_str(),
3813 resolved.approval_level,
3814 native,
3815 ) {
3816 return Err(failure(
3817 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3818 &message,
3819 ));
3820 }
3821 }
3822 }
3823 #[cfg(feature = "dig2-station-probe")]
3832 if resolved.browser_profile_id.is_some() {
3833 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
3834 match probe_station_reachable() {
3835 Ok(()) => {}
3836 Err(StationProbeError::UnsupportedPlatform)
3837 | Err(StationProbeError::InvalidPipeSuffix) => {
3838 return Err(failure(
3839 NodeFailureCode::BrowserStationProbeUnavailable,
3840 "dig2browser station probe unavailable on this platform",
3841 ));
3842 }
3843 Err(StationProbeError::Unreachable) => {
3844 return Err(failure(
3845 NodeFailureCode::BrowserStationUnreachable,
3846 "dig2browser station named-pipe is unreachable",
3847 ));
3848 }
3849 }
3850 }
3851 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
3852 return Ok(None);
3853 };
3854 let profiles = self
3855 .environment_profiles
3856 .read()
3857 .unwrap_or_else(|poisoned| poisoned.into_inner());
3858 let binding = profiles.get(profile_id).ok_or_else(|| {
3859 failure(
3860 NodeFailureCode::UnknownEnvironmentProfile,
3861 "spawn environment profile is unavailable on this node",
3862 )
3863 })?;
3864 if binding.provider != resolved.provider
3865 || binding.native_profile_id(resolved.mode).is_none()
3866 {
3867 return Err(failure(
3868 NodeFailureCode::EnvironmentProfileBindingMismatch,
3869 "spawn environment profile does not match the provider and mode",
3870 ));
3871 }
3872 Ok(Some(ResolvedEnvironmentProfileReceipt {
3876 profile_id: binding.id.clone(),
3877 profile_revision: binding.revision.clone(),
3878 network_allowlist: resolved.network_allowlist.clone(),
3879 browser_profile_id: resolved.browser_profile_id.clone(),
3880 }))
3881 }
3882
3883 fn resolve_bundle(
3884 &self,
3885 resolved: &ResolvedSpawnSpec,
3886 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3887 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
3888 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
3889 return Ok(None);
3890 };
3891 let catalog = self.bundle_catalog
3892 .read()
3893 .unwrap_or_else(|poisoned| poisoned.into_inner());
3894 let bundle = catalog.get(bundle_id).ok_or_else(|| {
3895 failure(
3896 NodeFailureCode::UnknownBundle,
3897 "spawn bundle is unavailable on this node",
3898 )
3899 })?;
3900 self.bundle_layout(
3901 &resolved.provider,
3902 resolved.mode,
3903 environment_profile,
3904 bundle,
3905 )?;
3906 Ok(Some(bundle.receipt()))
3907 }
3908
3909 fn begin_delivery_stage(
3910 &self,
3911 manifest: DeliveryBundleManifestV2,
3912 ) -> Result<NodeResponse, NodeFailure> {
3913 let mut store = self
3914 .delivery_store
3915 .lock()
3916 .unwrap_or_else(|poisoned| poisoned.into_inner());
3917 let store = store.as_mut().ok_or_else(|| {
3918 failure(
3919 NodeFailureCode::DeliveryStageStorageFailed,
3920 "node delivery store is unavailable",
3921 )
3922 })?;
3923 let begun = store.begin(manifest).map_err(delivery_failure)?;
3924 Ok(NodeResponse::DeliveryStageBegun {
3925 stage_id: begun.stage_id,
3926 manifest_digest: begun.manifest_digest,
3927 missing_blobs: begun.missing_blobs,
3928 })
3929 }
3930
3931 fn put_delivery_blob_chunk(
3932 &self,
3933 stage_id: DeliveryStageId,
3934 blob_digest: DeliveryBlobDigestV1,
3935 offset: u64,
3936 chunk_hex: DeliveryBlobChunkHexV1,
3937 ) -> Result<NodeResponse, NodeFailure> {
3938 let mut store = self
3939 .delivery_store
3940 .lock()
3941 .unwrap_or_else(|poisoned| poisoned.into_inner());
3942 let store = store.as_mut().ok_or_else(|| {
3943 failure(
3944 NodeFailureCode::DeliveryStageStorageFailed,
3945 "node delivery store is unavailable",
3946 )
3947 })?;
3948 let accepted = store
3949 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
3950 .map_err(delivery_failure)?;
3951 Ok(NodeResponse::DeliveryBlobChunkAccepted {
3952 stage_id: accepted.stage_id,
3953 blob_digest: accepted.blob_digest,
3954 next_offset: accepted.next_offset,
3955 })
3956 }
3957
3958 fn commit_delivery_stage(
3959 &self,
3960 stage_id: DeliveryStageId,
3961 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
3962 let mut store = self
3963 .delivery_store
3964 .lock()
3965 .unwrap_or_else(|poisoned| poisoned.into_inner());
3966 let store = store.as_mut().ok_or_else(|| {
3967 failure(
3968 NodeFailureCode::DeliveryStageStorageFailed,
3969 "node delivery store is unavailable",
3970 )
3971 })?;
3972 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
3973 let mut catalog = self
3974 .bundle_catalog
3975 .write()
3976 .unwrap_or_else(|poisoned| poisoned.into_inner());
3977 let mut next_catalog = catalog.clone();
3978 next_catalog
3979 .insert_idempotent(prepared.bundle().clone())
3980 .map_err(|_| {
3981 failure(
3982 NodeFailureCode::DeliveryStageConflict,
3983 "delivery bundle identity conflicts with installed content",
3984 )
3985 })?;
3986 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
3987 *catalog = next_catalog;
3988 Ok(receipt)
3989 }
3990
3991 fn abort_delivery_stage(
3992 &self,
3993 stage_id: &DeliveryStageId,
3994 ) -> Result<(), NodeFailure> {
3995 let mut store = self
3996 .delivery_store
3997 .lock()
3998 .unwrap_or_else(|poisoned| poisoned.into_inner());
3999 let store = store.as_mut().ok_or_else(|| {
4000 failure(
4001 NodeFailureCode::DeliveryStageStorageFailed,
4002 "node delivery store is unavailable",
4003 )
4004 })?;
4005 store.abort(stage_id).map_err(delivery_failure)
4006 }
4007
4008 fn resolve_context(
4009 &self,
4010 resolved: &ResolvedSpawnSpec,
4011 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4012 let Some(context_id) = resolved.context_id.as_ref() else {
4013 return Ok(None);
4014 };
4015 self.context_catalog
4016 .read()
4017 .unwrap_or_else(|poisoned| poisoned.into_inner())
4018 .get(context_id)
4019 .map(|context| context.receipt().clone())
4020 .ok_or_else(|| {
4021 failure(
4022 NodeFailureCode::UnknownContextPack,
4023 "spawn context pack is unavailable on this node",
4024 )
4025 })
4026 .map(Some)
4027 }
4028
4029 fn bundle_layout(
4030 &self,
4031 provider: &AgentId,
4032 mode: SessionMode,
4033 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4034 bundle: &NodeBundle,
4035 ) -> Result<BundleProviderLayout, NodeFailure> {
4036 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4037 failure(
4038 NodeFailureCode::BundleBindingMismatch,
4039 "spawn bundle does not match the provider and mode",
4040 )
4041 })?;
4042 if layout != BundleProviderLayout::Codex {
4043 return Ok(layout);
4044 }
4045 let profile = environment_profile.and_then(|receipt| {
4046 let profiles = self
4047 .environment_profiles
4048 .read()
4049 .unwrap_or_else(|poisoned| poisoned.into_inner());
4050 let binding = profiles.get(&receipt.profile_id)?;
4051 if binding.revision != receipt.profile_revision
4052 || &binding.provider != provider
4053 || binding.native_profile_id(mode).is_none()
4054 {
4055 return None;
4056 }
4057 self.environment_materialization_profiles
4058 .read()
4059 .unwrap_or_else(|poisoned| poisoned.into_inner())
4060 .get(&receipt.profile_id)
4061 .cloned()
4062 });
4063 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4064 return Err(failure(
4065 NodeFailureCode::BundleBindingMismatch,
4066 "Codex bundle requires an exact isolated CODEX_HOME profile",
4067 ));
4068 }
4069 Ok(layout)
4070 }
4071
4072 fn select_environment_profile(
4073 &self,
4074 instance_id: AgentInstanceId,
4075 provider: &AgentId,
4076 mode: SessionMode,
4077 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4078 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4079 let Some(environment_profile) = environment_profile else {
4080 return Ok(None);
4081 };
4082 let native_profile_id = {
4083 let profiles = self
4084 .environment_profiles
4085 .read()
4086 .unwrap_or_else(|poisoned| poisoned.into_inner());
4087 let binding = profiles
4088 .get(&environment_profile.profile_id)
4089 .ok_or_else(|| {
4090 failure(
4091 NodeFailureCode::UnknownEnvironmentProfile,
4092 "session environment profile is unavailable on this node",
4093 )
4094 })?;
4095 if &binding.revision != &environment_profile.profile_revision
4096 || &binding.provider != provider
4097 {
4098 return Err(failure(
4099 NodeFailureCode::EnvironmentProfileBindingMismatch,
4100 "session environment profile revision or provider changed",
4101 ));
4102 }
4103 binding.native_profile_id(mode).cloned().ok_or_else(|| {
4104 failure(
4105 NodeFailureCode::EnvironmentProfileBindingMismatch,
4106 "session environment profile does not support this mode",
4107 )
4108 })?
4109 };
4110 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4111 failure(
4112 NodeFailureCode::BackendOperationFailed,
4113 "native environment profile controller is unavailable",
4114 )
4115 })?;
4116 control
4117 .select_native_launch_profile(instance_id, native_profile_id)
4118 .map_err(|_| {
4119 failure(
4120 NodeFailureCode::BackendOperationFailed,
4121 "native environment profile selection failed",
4122 )
4123 })?;
4124 Ok(Some(NativeEnvironmentSelectionGuard {
4125 control: Some(control),
4126 instance_id,
4127 }))
4128 }
4129
4130 fn install_prepared_launch_overlay(
4131 &self,
4132 instance_id: AgentInstanceId,
4133 overlay: PreparedNativeLaunchOverlay,
4134 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4135 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4136 failure(
4137 NodeFailureCode::BackendOperationFailed,
4138 "native launch profile controller is unavailable",
4139 )
4140 })?;
4141 match overlay {
4142 PreparedNativeLaunchOverlay::Environment(overlay) => {
4143 control
4144 .install_native_launch_environment_overlay(instance_id, overlay)
4145 .map_err(|_| failure(
4146 NodeFailureCode::BackendOperationFailed,
4147 "session environment overlay installation failed",
4148 ))?;
4149 Ok(None)
4150 }
4151 PreparedNativeLaunchOverlay::Instance(overlay) => {
4152 control
4153 .install_native_instance_launch_overlay(instance_id, overlay)
4154 .map_err(|_| failure(
4155 NodeFailureCode::BundleBindingMismatch,
4156 "session bundle launch overlay installation failed",
4157 ))?;
4158 Ok(Some(NativeInstanceOverlayGuard {
4159 control: Some(control),
4160 instance_id,
4161 }))
4162 }
4163 }
4164 }
4165
4166 fn environment_materialization_profile(
4167 &self,
4168 environment_profile: &ResolvedEnvironmentProfileReceipt,
4169 ) -> Option<NodeSessionMaterializationProfile> {
4170 self.environment_materialization_profiles
4171 .read()
4172 .unwrap_or_else(|poisoned| poisoned.into_inner())
4173 .get(&environment_profile.profile_id)
4174 .cloned()
4175 }
4176
4177 fn materialization_profile(
4178 &self,
4179 provider: &AgentId,
4180 mode: SessionMode,
4181 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4182 bundle: Option<&ResolvedBundleReceipt>,
4183 context: Option<&ResolvedContextPackReceipt>,
4184 ) -> Result<Option<(
4185 NodeSessionMaterializationProfile,
4186 bool,
4187 Option<BundleProviderLayout>,
4188 )>, NodeFailure> {
4189 let environment = environment_profile
4190 .and_then(|receipt| self.environment_materialization_profile(receipt));
4191 let (bundle, bundle_layout) = match bundle {
4192 Some(receipt) => {
4193 let catalog = self.bundle_catalog.read()
4194 .unwrap_or_else(|poisoned| poisoned.into_inner());
4195 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4196 NodeFailureCode::UnknownBundle,
4197 "session bundle is unavailable on this node",
4198 ))?;
4199 if installed.receipt() != *receipt {
4200 return Err(failure(
4201 NodeFailureCode::BundleBindingMismatch,
4202 "session bundle revision or digest changed",
4203 ));
4204 }
4205 let layout = self.bundle_layout(
4206 provider,
4207 mode,
4208 environment_profile,
4209 installed,
4210 )?;
4211 (Some(installed.clone()), Some(layout))
4212 }
4213 None => (None, None),
4214 };
4215 let has_environment_overlay = environment.is_some() || context.is_some();
4216 let profile = match (environment, bundle.as_ref(), bundle_layout) {
4217 (None, None, None) => None,
4218 (Some(profile), None, None) => Some(Ok(profile)),
4219 (None, Some(bundle), Some(layout)) => {
4220 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4221 }
4222 (Some(profile), Some(bundle), Some(layout)) => {
4223 Some(profile.with_bundle(bundle, layout))
4224 }
4225 _ => {
4226 return Err(failure(
4227 NodeFailureCode::BundleBindingMismatch,
4228 "session bundle provider layout is unavailable",
4229 ));
4230 }
4231 };
4232 let profile = profile
4233 .transpose()
4234 .map_err(|_| failure(
4235 NodeFailureCode::BundleMaterializationFailed,
4236 "session bundle materialization profile is invalid",
4237 ))?;
4238 let profile = match (profile, context) {
4239 (Some(profile), Some(receipt)) => {
4240 let catalog = self.context_catalog
4241 .read()
4242 .unwrap_or_else(|poisoned| poisoned.into_inner());
4243 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4244 NodeFailureCode::UnknownContextPack,
4245 "session context pack is unavailable on this node",
4246 ))?;
4247 if pack.receipt() != receipt {
4248 return Err(failure(
4249 NodeFailureCode::ContextPackMaterializationFailed,
4250 "session context pack identity changed",
4251 ));
4252 }
4253 profile.with_context(pack)
4254 }
4255 (None, Some(receipt)) => {
4256 let catalog = self.context_catalog
4257 .read()
4258 .unwrap_or_else(|poisoned| poisoned.into_inner());
4259 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4260 NodeFailureCode::UnknownContextPack,
4261 "session context pack is unavailable on this node",
4262 ))?;
4263 if pack.receipt() != receipt {
4264 return Err(failure(
4265 NodeFailureCode::ContextPackMaterializationFailed,
4266 "session context pack identity changed",
4267 ));
4268 }
4269 NodeSessionMaterializationProfile::from_context(pack)
4270 }
4271 (Some(profile), None) => Ok(profile),
4272 (None, None) => return Ok(None),
4273 }
4274 .map_err(|_| failure(
4275 NodeFailureCode::ContextPackMaterializationFailed,
4276 "session context pack materialization profile is invalid",
4277 ))?;
4278 Ok(Some((profile, has_environment_overlay, bundle_layout)))
4279 }
4280
4281 fn prepare_session_materialization<'a>(
4282 &'a self,
4283 address: &SessionAddress,
4284 provider: &AgentId,
4285 mode: SessionMode,
4286 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4287 bundle: Option<&ResolvedBundleReceipt>,
4288 context: Option<&ResolvedContextPackReceipt>,
4289 managed_lease_id: Option<ManagedWorktreeLeaseId>,
4290 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
4291 let Some((profile, has_environment_overlay, bundle_layout)) =
4292 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
4293 else {
4294 return Ok(None);
4295 };
4296 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4297 failure(
4298 NodeFailureCode::BackendOperationFailed,
4299 "session environment materializer is unavailable",
4300 )
4301 })?;
4302 let materialization_failure_code = if context.is_some() {
4303 NodeFailureCode::ContextPackMaterializationFailed
4304 } else if bundle.is_some() {
4305 NodeFailureCode::BundleMaterializationFailed
4306 } else {
4307 NodeFailureCode::BackendOperationFailed
4308 };
4309 let id = MaterializationId::new(format!(
4310 "mat-{}-{}-{}",
4311 self.incarnation_id,
4312 address.session.instance_id.0,
4313 address.session.generation.0,
4314 ))
4315 .map_err(|_| {
4316 failure(
4317 NodeFailureCode::BackendOperationFailed,
4318 "session environment materialization identity failed",
4319 )
4320 })?;
4321 let owner = MaterializationOwner::Session {
4322 incarnation_id: self.incarnation_id,
4323 instance_id: address.session.instance_id,
4324 generation: address.session.generation,
4325 };
4326 let mut ownership = materializer
4327 .begin(
4328 id.clone(),
4329 environment_profile.cloned(),
4330 bundle.cloned(),
4331 context.cloned(),
4332 owner,
4333 managed_lease_id,
4334 &profile,
4335 unix_time_ms(),
4336 )
4337 .map_err(|_| {
4338 failure(
4339 materialization_failure_code,
4340 "session materialization preparation failed",
4341 )
4342 })?;
4343 {
4344 let _transaction = self
4345 .state_transaction
4346 .lock()
4347 .unwrap_or_else(|poisoned| poisoned.into_inner());
4348 let duplicate = self
4349 .materializations
4350 .lock()
4351 .unwrap_or_else(|poisoned| poisoned.into_inner())
4352 .insert(id.clone(), ownership.clone());
4353 if duplicate.is_some() {
4354 return Err(failure(
4355 NodeFailureCode::BackendOperationFailed,
4356 "session environment materialization identity conflict",
4357 ));
4358 }
4359 if let Err(error) = self.persist_state_locked() {
4360 self.materializations
4361 .lock()
4362 .unwrap_or_else(|poisoned| poisoned.into_inner())
4363 .remove(&id);
4364 return Err(persistence_failure(error));
4365 }
4366 }
4367 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
4368 Ok(environment) => environment,
4369 Err(_) => {
4370 let transaction = self
4371 .state_transaction
4372 .lock()
4373 .unwrap_or_else(|poisoned| poisoned.into_inner());
4374 self.materializations
4375 .lock()
4376 .unwrap_or_else(|poisoned| poisoned.into_inner())
4377 .insert(id.clone(), ownership.clone());
4378 if self.persist_state_locked().is_err() {
4379 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4380 }
4381 drop(transaction);
4382 if ownership.state() == MaterializationState::CleanupRequired {
4383 let _ = self.cleanup_materialization(&id);
4384 }
4385 return Err(failure(
4386 materialization_failure_code,
4387 "session materialization failed",
4388 ));
4389 }
4390 };
4391 {
4392 let _transaction = self
4393 .state_transaction
4394 .lock()
4395 .unwrap_or_else(|poisoned| poisoned.into_inner());
4396 self.materializations
4397 .lock()
4398 .unwrap_or_else(|poisoned| poisoned.into_inner())
4399 .insert(id.clone(), ownership.clone());
4400 if let Err(error) = self.persist_state_locked() {
4401 drop(_transaction);
4402 let _ = self.cleanup_materialization(&id);
4403 return Err(persistence_failure(error));
4404 }
4405 }
4406 let guard = SessionMaterializationGuard {
4407 shared: self,
4408 id: Some(id),
4409 provider_home: Some(ownership.provider_home().to_path_buf()),
4410 };
4411 let bundle_arguments = match bundle {
4412 Some(receipt) => {
4413 let catalog = self.bundle_catalog.read()
4414 .unwrap_or_else(|poisoned| poisoned.into_inner());
4415 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4416 NodeFailureCode::UnknownBundle,
4417 "session bundle is unavailable on this node",
4418 ))?;
4419 if installed.receipt() != *receipt {
4420 return Err(failure(
4421 NodeFailureCode::BundleBindingMismatch,
4422 "session bundle revision or digest changed",
4423 ));
4424 }
4425 let layout = bundle_layout.ok_or_else(|| failure(
4426 NodeFailureCode::BundleBindingMismatch,
4427 "materialized bundle provider layout is unavailable",
4428 ))?;
4429 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
4430 failure(
4431 NodeFailureCode::BundleMaterializationFailed,
4432 "session bundle byte revalidation failed",
4433 )
4434 })?;
4435 Some(bundle_launch_arguments(
4436 layout,
4437 ownership.bundle_root(),
4438 ownership.provider_home(),
4439 ).map_err(|_| failure(
4440 NodeFailureCode::BundleBindingMismatch,
4441 "session bundle launch binding is invalid",
4442 ))?)
4443 }
4444 None => None,
4445 };
4446 if let Some(receipt) = context {
4447 materializer
4448 .revalidate_context(&ownership, receipt)
4449 .map_err(|_| failure(
4450 NodeFailureCode::ContextPackMaterializationFailed,
4451 "session context pack byte revalidation failed",
4452 ))?;
4453 }
4454 let transport = match mode {
4455 SessionMode::Pty => TransportKind::Pty,
4456 SessionMode::Inline => TransportKind::Pipe,
4457 SessionMode::Acp => TransportKind::Acp,
4458 };
4459 let overlay = if let Some(extra_args) = bundle_arguments {
4460 Some(PreparedNativeLaunchOverlay::Instance(
4461 NativeInstanceLaunchOverlay::new(
4462 provider.clone(),
4463 transport,
4464 if has_environment_overlay {
4465 environment
4466 } else {
4467 Vec::new()
4468 },
4469 extra_args,
4470 )
4471 .map_err(|_| {
4472 failure(
4473 NodeFailureCode::BundleBindingMismatch,
4474 "session bundle launch overlay is invalid",
4475 )
4476 })?,
4477 ))
4478 } else if has_environment_overlay {
4479 let context_only = environment_profile.is_none()
4480 && bundle.is_none()
4481 && context.is_some();
4482 let overlay = if context_only {
4483 NativeLaunchEnvironmentOverlay::new_context_root(
4484 provider.clone(),
4485 transport,
4486 environment,
4487 )
4488 .map_err(|_| {
4489 failure(
4490 NodeFailureCode::ContextPackMaterializationFailed,
4491 "context-only launch overlay is invalid",
4492 )
4493 })?
4494 } else {
4495 NativeLaunchEnvironmentOverlay::new(
4496 provider.clone(),
4497 transport,
4498 environment,
4499 )
4500 .map_err(|_| {
4501 failure(
4502 NodeFailureCode::BackendOperationFailed,
4503 "session environment overlay is invalid",
4504 )
4505 })?
4506 };
4507 Some(PreparedNativeLaunchOverlay::Environment(overlay))
4508 } else {
4509 None
4510 };
4511 Ok(Some((overlay, guard)))
4512 }
4513
4514 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
4515 let Some(mut ownership) = self
4516 .materializations
4517 .lock()
4518 .unwrap_or_else(|poisoned| poisoned.into_inner())
4519 .get(id)
4520 .cloned()
4521 else {
4522 return Ok(());
4523 };
4524 if ownership.state() != MaterializationState::CleanupRequired {
4525 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
4526 failure(
4527 NodeFailureCode::BackendOperationFailed,
4528 "session environment cleanup state is invalid",
4529 )
4530 })?;
4531 }
4532 {
4533 let _transaction = self
4534 .state_transaction
4535 .lock()
4536 .unwrap_or_else(|poisoned| poisoned.into_inner());
4537 let previous = self
4538 .materializations
4539 .lock()
4540 .unwrap_or_else(|poisoned| poisoned.into_inner())
4541 .insert(id.clone(), ownership.clone());
4542 if let Err(error) = self.persist_state_locked() {
4543 let mut materializations = self
4544 .materializations
4545 .lock()
4546 .unwrap_or_else(|poisoned| poisoned.into_inner());
4547 if let Some(previous) = previous {
4548 materializations.insert(id.clone(), previous);
4549 }
4550 return Err(persistence_failure(error));
4551 }
4552 }
4553 let cleanup = self
4554 .session_environment_materializer
4555 .as_ref()
4556 .ok_or(())
4557 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
4558 if cleanup.is_err() {
4559 let _ = ownership.mark_recovery_required(unix_time_ms());
4560 self.materializations
4561 .lock()
4562 .unwrap_or_else(|poisoned| poisoned.into_inner())
4563 .insert(id.clone(), ownership);
4564 if self.persist_state().is_err() {
4565 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4566 }
4567 return Err(failure(
4568 NodeFailureCode::BackendOperationFailed,
4569 "session environment cleanup requires recovery",
4570 ));
4571 }
4572 let _transaction = self
4573 .state_transaction
4574 .lock()
4575 .unwrap_or_else(|poisoned| poisoned.into_inner());
4576 self.materializations
4577 .lock()
4578 .unwrap_or_else(|poisoned| poisoned.into_inner())
4579 .remove(id);
4580 if let Err(error) = self.persist_state_locked() {
4581 self.materializations
4582 .lock()
4583 .unwrap_or_else(|poisoned| poisoned.into_inner())
4584 .insert(id.clone(), ownership);
4585 return Err(persistence_failure(error));
4586 }
4587 Ok(())
4588 }
4589
4590 fn resolve_record_materialization(
4591 &self,
4592 record_id: &SessionRecordId,
4593 provider: &AgentId,
4594 mode: SessionMode,
4595 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4596 bundle: Option<&ResolvedBundleReceipt>,
4597 context: Option<&ResolvedContextPackReceipt>,
4598 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
4599 let ownership = {
4600 let materializations = self
4601 .materializations
4602 .lock()
4603 .unwrap_or_else(|poisoned| poisoned.into_inner());
4604 let mut matches = materializations.values().filter(|record| {
4605 record.owner()
4606 == &MaterializationOwner::Record {
4607 record_id: record_id.clone(),
4608 }
4609 });
4610 let ownership = matches.next().cloned();
4611 if matches.next().is_some() {
4612 return Err(failure(
4613 NodeFailureCode::BackendOperationFailed,
4614 "managed session has conflicting environment ownership",
4615 ));
4616 }
4617 ownership
4618 };
4619 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
4620 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4621 failure(
4622 NodeFailureCode::ContextPackMaterializationFailed,
4623 "session context materializer is unavailable",
4624 )
4625 })?;
4626 let pack = materializer
4627 .revalidate_context(ownership, receipt)
4628 .map_err(|_| {
4629 self.mark_materialization_recovery_required(ownership.id());
4630 failure(
4631 NodeFailureCode::ContextPackMaterializationFailed,
4632 "managed session context pack revalidation failed",
4633 )
4634 })?;
4635 self.context_catalog
4636 .write()
4637 .unwrap_or_else(|poisoned| poisoned.into_inner())
4638 .insert(pack)
4639 .map_err(|_| failure(
4640 NodeFailureCode::ContextPackMaterializationFailed,
4641 "managed session context pack catalog restore failed",
4642 ))?;
4643 }
4644 let materialization = self.materialization_profile(
4645 provider,
4646 mode,
4647 environment_profile,
4648 bundle,
4649 context,
4650 )?;
4651 match (ownership, materialization) {
4652 (None, None) => Ok(None),
4653 (None, Some(_)) | (Some(_), None) => Err(failure(
4654 NodeFailureCode::BackendOperationFailed,
4655 "managed session materialization is unavailable",
4656 )),
4657 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
4658 if ownership.environment_profile() != environment_profile
4659 || ownership.bundle() != bundle
4660 || ownership.context() != context
4661 || ownership.state() != MaterializationState::Ready
4662 {
4663 self.mark_materialization_recovery_required(ownership.id());
4664 return Err(failure(
4665 NodeFailureCode::BackendOperationFailed,
4666 "managed session environment ownership requires recovery",
4667 ));
4668 }
4669 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4670 failure(
4671 NodeFailureCode::BackendOperationFailed,
4672 "session environment materializer is unavailable",
4673 )
4674 })?;
4675 if materializer.revalidate(&ownership).is_err() {
4676 self.mark_materialization_recovery_required(ownership.id());
4677 return Err(failure(
4678 NodeFailureCode::BackendOperationFailed,
4679 "managed session environment revalidation failed",
4680 ));
4681 }
4682 let bundle_arguments = if let Some(bundle_receipt) = bundle {
4683 let catalog = self.bundle_catalog.read()
4684 .unwrap_or_else(|poisoned| poisoned.into_inner());
4685 let Some(installed) = catalog.get(&bundle_receipt.id) else {
4686 return Err(failure(
4687 NodeFailureCode::UnknownBundle,
4688 "managed session bundle is unavailable",
4689 ));
4690 };
4691 let layout = bundle_layout.ok_or_else(|| failure(
4692 NodeFailureCode::BundleBindingMismatch,
4693 "record bundle provider layout is unavailable",
4694 ))?;
4695 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
4696 drop(catalog);
4697 self.mark_materialization_recovery_required(ownership.id());
4698 return Err(failure(
4699 NodeFailureCode::BundleMaterializationFailed,
4700 "managed session bundle revalidation failed",
4701 ));
4702 }
4703 Some(bundle_launch_arguments(
4704 layout,
4705 ownership.bundle_root(),
4706 ownership.provider_home(),
4707 ).map_err(|_| failure(
4708 NodeFailureCode::BundleBindingMismatch,
4709 "managed session bundle launch binding is invalid",
4710 ))?)
4711 } else {
4712 None
4713 };
4714 if !has_environment_materialization {
4715 let overlay = bundle_arguments.map(|extra_args| {
4716 NativeInstanceLaunchOverlay::new(
4717 provider.clone(),
4718 TransportKind::Pty,
4719 Vec::new(),
4720 extra_args,
4721 )
4722 .map(PreparedNativeLaunchOverlay::Instance)
4723 .map_err(|_| failure(
4724 NodeFailureCode::BundleBindingMismatch,
4725 "managed session bundle launch overlay is invalid",
4726 ))
4727 }).transpose()?;
4728 return Ok(Some((ownership.id().clone(), overlay)));
4729 }
4730 let environment = match materializer.resolve_environment(&ownership, &profile) {
4731 Ok(environment) => environment,
4732 Err(
4733 SessionEnvironmentMaterializeError::SecretUnavailable
4734 | SessionEnvironmentMaterializeError::SecretDenied
4735 | SessionEnvironmentMaterializeError::InvalidSecretValue,
4736 ) => {
4737 return Err(failure(
4738 NodeFailureCode::BackendOperationFailed,
4739 "managed session environment resolution failed",
4740 ));
4741 }
4742 Err(
4743 SessionEnvironmentMaterializeError::InvalidRoot
4744 | SessionEnvironmentMaterializeError::OwnershipMismatch
4745 | SessionEnvironmentMaterializeError::Filesystem(_)
4746 | SessionEnvironmentMaterializeError::Record(_),
4747 ) => {
4748 self.mark_materialization_recovery_required(ownership.id());
4749 return Err(failure(
4750 NodeFailureCode::BackendOperationFailed,
4751 "managed session environment resolution failed",
4752 ));
4753 }
4754 };
4755 let transport = match mode {
4756 SessionMode::Pty => TransportKind::Pty,
4757 SessionMode::Inline => TransportKind::Pipe,
4758 SessionMode::Acp => TransportKind::Acp,
4759 };
4760 let overlay = match bundle_arguments {
4761 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
4762 NativeInstanceLaunchOverlay::new(
4763 provider.clone(),
4764 transport,
4765 environment,
4766 extra_args,
4767 )
4768 .map_err(|_| failure(
4769 NodeFailureCode::BundleBindingMismatch,
4770 "managed session bundle launch overlay is invalid",
4771 ))?,
4772 ),
4773 None => PreparedNativeLaunchOverlay::Environment(
4774 NativeLaunchEnvironmentOverlay::new(
4775 provider.clone(),
4776 transport,
4777 environment,
4778 )
4779 .map_err(|_| {
4780 failure(
4781 NodeFailureCode::BackendOperationFailed,
4782 "managed session environment overlay is invalid",
4783 )
4784 })?,
4785 ),
4786 };
4787 Ok(Some((ownership.id().clone(), Some(overlay))))
4788 }
4789 }
4790 }
4791
4792 fn destroy_record_materialization(
4793 &self,
4794 record_id: &SessionRecordId,
4795 ) -> Result<bool, NodeFailure> {
4796 let ids = self
4797 .materializations
4798 .lock()
4799 .unwrap_or_else(|poisoned| poisoned.into_inner())
4800 .values()
4801 .filter_map(|record| {
4802 (record.owner()
4803 == &MaterializationOwner::Record {
4804 record_id: record_id.clone(),
4805 })
4806 .then(|| record.id().clone())
4807 })
4808 .collect::<Vec<_>>();
4809 if ids.len() > 1 {
4810 return Err(failure(
4811 NodeFailureCode::BackendOperationFailed,
4812 "managed session has conflicting environment ownership",
4813 ));
4814 }
4815 let Some(id) = ids.first().cloned() else {
4816 return Ok(false);
4817 };
4818 let (terminal_record, mut cleanup_ownership) = {
4819 let _transaction = self
4820 .state_transaction
4821 .lock()
4822 .unwrap_or_else(|poisoned| poisoned.into_inner());
4823 let original_record = self.record(record_id)?;
4824 let mut terminal_record = original_record.clone();
4825 terminal_record.provider_session = None;
4826 terminal_record.active_session = None;
4827 terminal_record.state = ManagedSessionState::Unavailable;
4828 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
4829 terminal_record.updated_at_unix_ms = unix_time_ms();
4830 let original_ownership = self
4831 .materializations
4832 .lock()
4833 .unwrap_or_else(|poisoned| poisoned.into_inner())
4834 .get(&id)
4835 .cloned()
4836 .ok_or_else(|| {
4837 failure(
4838 NodeFailureCode::BackendOperationFailed,
4839 "managed session environment ownership disappeared during cleanup",
4840 )
4841 })?;
4842 let mut cleanup_ownership = original_ownership.clone();
4843 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
4844 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
4845 failure(
4846 NodeFailureCode::BackendOperationFailed,
4847 "managed session environment cleanup state is invalid",
4848 )
4849 })?;
4850 }
4851 self.session_records
4852 .lock()
4853 .unwrap_or_else(|poisoned| poisoned.into_inner())
4854 .records
4855 .insert(record_id.clone(), terminal_record.clone());
4856 self.materializations
4857 .lock()
4858 .unwrap_or_else(|poisoned| poisoned.into_inner())
4859 .insert(id.clone(), cleanup_ownership.clone());
4860 if let Err(error) = self.persist_state_locked() {
4861 self.session_records
4862 .lock()
4863 .unwrap_or_else(|poisoned| poisoned.into_inner())
4864 .records
4865 .insert(record_id.clone(), original_record);
4866 self.materializations
4867 .lock()
4868 .unwrap_or_else(|poisoned| poisoned.into_inner())
4869 .insert(id.clone(), original_ownership);
4870 return Err(persistence_failure(error));
4871 }
4872 (terminal_record, cleanup_ownership)
4873 };
4874 let cleanup = self
4875 .session_environment_materializer
4876 .as_ref()
4877 .ok_or(())
4878 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
4879 if cleanup.is_err() {
4880 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
4881 self.materializations
4882 .lock()
4883 .unwrap_or_else(|poisoned| poisoned.into_inner())
4884 .insert(id.clone(), cleanup_ownership);
4885 if self.persist_state().is_err() {
4886 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4887 }
4888 return Err(failure(
4889 NodeFailureCode::BackendOperationFailed,
4890 "managed session environment cleanup requires recovery",
4891 ));
4892 }
4893 let _transaction = self
4894 .state_transaction
4895 .lock()
4896 .unwrap_or_else(|poisoned| poisoned.into_inner());
4897 self.remove_record_memory(record_id);
4898 self.materializations
4899 .lock()
4900 .unwrap_or_else(|poisoned| poisoned.into_inner())
4901 .remove(&id);
4902 if let Err(error) = self.persist_state_locked() {
4903 self.session_records
4904 .lock()
4905 .unwrap_or_else(|poisoned| poisoned.into_inner())
4906 .records
4907 .insert(record_id.clone(), terminal_record);
4908 self.materializations
4909 .lock()
4910 .unwrap_or_else(|poisoned| poisoned.into_inner())
4911 .insert(id, cleanup_ownership);
4912 return Err(persistence_failure(error));
4913 }
4914 Ok(true)
4915 }
4916
4917 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
4918 let changed = {
4919 let mut materializations = self
4920 .materializations
4921 .lock()
4922 .unwrap_or_else(|poisoned| poisoned.into_inner());
4923 let Some(ownership) = materializations.get_mut(id) else {
4924 return;
4925 };
4926 ownership.mark_recovery_required(unix_time_ms()).is_ok()
4927 };
4928 if changed && self.persist_state().is_err() {
4929 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4930 }
4931 }
4932
4933 fn reconcile_materializations(&self) {
4934 let records = self
4935 .materializations
4936 .lock()
4937 .unwrap_or_else(|poisoned| poisoned.into_inner())
4938 .values()
4939 .cloned()
4940 .collect::<Vec<_>>();
4941 for mut ownership in records {
4942 let result = match (ownership.state(), ownership.owner()) {
4943 (MaterializationState::Preparing, _)
4944 | (MaterializationState::CleanupRequired, _) => {
4945 self.cleanup_materialization(ownership.id())
4946 }
4947 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
4948 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
4949 self.materializations
4950 .lock()
4951 .unwrap_or_else(|poisoned| poisoned.into_inner())
4952 .insert(ownership.id().clone(), ownership.clone());
4953 self.persist_state().map_err(persistence_failure)
4954 } else {
4955 Err(failure(
4956 NodeFailureCode::BackendOperationFailed,
4957 "session environment reconciliation failed",
4958 ))
4959 }
4960 }
4961 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
4962 .session_environment_materializer
4963 .as_ref()
4964 .ok_or_else(|| {
4965 failure(
4966 NodeFailureCode::BackendOperationFailed,
4967 "session environment materializer is unavailable",
4968 )
4969 })
4970 .and_then(|materializer| {
4971 materializer.revalidate(&ownership).map_err(|_| failure(
4972 NodeFailureCode::BackendOperationFailed,
4973 "session materialization revalidation failed",
4974 ))?;
4975 let record = self.record(record_id)?;
4976 if record.environment_profile.as_ref() != ownership.environment_profile()
4977 || record.bundle.as_ref() != ownership.bundle()
4978 || record.context.as_ref() != ownership.context()
4979 {
4980 return Err(failure(
4981 NodeFailureCode::BackendOperationFailed,
4982 "session materialization ownership changed after restart",
4983 ));
4984 }
4985 if let Some(receipt) = ownership.bundle() {
4986 let catalog = self.bundle_catalog.read()
4987 .unwrap_or_else(|poisoned| poisoned.into_inner());
4988 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
4989 NodeFailureCode::UnknownBundle,
4990 "session bundle is unavailable after restart",
4991 ))?;
4992 if bundle.receipt() != *receipt {
4993 return Err(failure(
4994 NodeFailureCode::BundleBindingMismatch,
4995 "session bundle changed after restart",
4996 ));
4997 }
4998 let layout = self.bundle_layout(
4999 &record.provider,
5000 record.mode,
5001 record.environment_profile.as_ref(),
5002 bundle,
5003 )?;
5004 materializer
5005 .revalidate_bundle(&ownership, bundle, layout)
5006 .map_err(|_| failure(
5007 NodeFailureCode::BundleMaterializationFailed,
5008 "session bundle revalidation failed",
5009 ))?;
5010 }
5011 if let Some(receipt) = ownership.context() {
5012 let pack = materializer
5013 .revalidate_context(&ownership, receipt)
5014 .map_err(|_| {
5015 failure(
5016 NodeFailureCode::ContextPackMaterializationFailed,
5017 "session context revalidation failed",
5018 )
5019 })?;
5020 self.context_catalog
5021 .write()
5022 .unwrap_or_else(|poisoned| poisoned.into_inner())
5023 .insert(pack)
5024 .map_err(|_| {
5025 failure(
5026 NodeFailureCode::ContextPackMaterializationFailed,
5027 "session context catalog restore failed",
5028 )
5029 })?;
5030 }
5031 Ok(())
5032 }),
5033 (MaterializationState::RecoveryRequired, _) => Ok(()),
5034 };
5035 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5036 let _ = ownership.mark_recovery_required(unix_time_ms());
5037 self.materializations
5038 .lock()
5039 .unwrap_or_else(|poisoned| poisoned.into_inner())
5040 .insert(ownership.id().clone(), ownership);
5041 if self.persist_state().is_err() {
5042 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5043 }
5044 }
5045 }
5046 let materialized_profiles = self
5047 .environment_materialization_profiles
5048 .read()
5049 .unwrap_or_else(|poisoned| poisoned.into_inner())
5050 .keys()
5051 .cloned()
5052 .collect::<Vec<_>>();
5053 let ownership = self
5054 .materializations
5055 .lock()
5056 .unwrap_or_else(|poisoned| poisoned.into_inner())
5057 .values()
5058 .cloned()
5059 .collect::<Vec<_>>();
5060 let changed = {
5061 let mut session_records = self
5062 .session_records
5063 .lock()
5064 .unwrap_or_else(|poisoned| poisoned.into_inner());
5065 let mut changed = false;
5066 for record in session_records.records.values_mut() {
5067 let environment_profile = record.environment_profile.as_ref();
5068 if environment_profile.is_none()
5069 && record.bundle.is_none()
5070 && record.context.is_none()
5071 {
5072 continue;
5073 }
5074 if record.bundle.is_none()
5075 && environment_profile.is_some_and(|profile| {
5076 !materialized_profiles.contains(&profile.profile_id)
5077 })
5078 {
5079 continue;
5080 }
5081 let has_exact_ownership = ownership.iter().any(|candidate| {
5082 candidate.state() == MaterializationState::Ready
5083 && candidate.environment_profile() == environment_profile
5084 && candidate.bundle() == record.bundle.as_ref()
5085 && candidate.context() == record.context.as_ref()
5086 && candidate.owner()
5087 == &MaterializationOwner::Record {
5088 record_id: record.record_id.clone(),
5089 }
5090 });
5091 if !has_exact_ownership {
5092 record.provider_session = None;
5093 record.active_session = None;
5094 record.state = ManagedSessionState::Unavailable;
5095 record.last_error = Some(if record.bundle.is_some() {
5096 "bundle-unavailable".to_owned()
5097 } else if record.context.is_some() {
5098 "context-unavailable".to_owned()
5099 } else {
5100 "environment-profile-unavailable".to_owned()
5101 });
5102 record.updated_at_unix_ms = unix_time_ms();
5103 changed = true;
5104 }
5105 }
5106 changed
5107 };
5108 if changed && self.persist_state().is_err() {
5109 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5110 }
5111 }
5112
5113 fn replay_spawn_spec(
5114 &self,
5115 spec: &SpawnSpec,
5116 now: Instant,
5117 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5118 let mut cache = self
5119 .spawn_idempotency
5120 .lock()
5121 .unwrap_or_else(|poisoned| poisoned.into_inner());
5122 cache.entries.retain(|_, entry| entry.expires_at > now);
5123 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5124 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5125 return Err(failure(
5126 NodeFailureCode::SpawnIdempotencyConflict,
5127 "spawn idempotency key was reused across spawn kinds",
5128 ));
5129 };
5130 if existing != spec {
5131 return Err(failure(
5132 NodeFailureCode::SpawnIdempotencyConflict,
5133 "spawn idempotency key was reused with a different specification",
5134 ));
5135 }
5136 return Ok(Some(result.clone()));
5137 }
5138 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5139 return Err(failure(
5140 NodeFailureCode::SpawnIdempotencyCapacity,
5141 "spawn idempotency cache reached its bounded capacity",
5142 ));
5143 }
5144 Ok(None)
5145 }
5146
5147 fn remember_spawn_spec(
5148 &self,
5149 spec: SpawnSpec,
5150 result: Result<ResolvedSpawnReceipt, NodeFailure>,
5151 accepted_at: Instant,
5152 ) {
5153 self
5154 .spawn_idempotency
5155 .lock()
5156 .unwrap_or_else(|poisoned| poisoned.into_inner())
5157 .entries
5158 .insert(
5159 spec.idempotency_key.clone(),
5160 SpawnIdempotencyEntry {
5161 value: SpawnIdempotencyValue::Standard { spec, result },
5162 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5163 },
5164 );
5165 }
5166
5167 fn claim_harness_mcp_spawn(
5168 &self,
5169 _reservation_id: &HarnessMcpReservationId,
5170 _activation_digest: &HarnessMcpActivationDigest,
5171 spec: &SpawnSpec,
5172 now: Instant,
5173 ) -> Result<(), NodeFailure> {
5174 let mut cache = self.spawn_idempotency.lock()
5175 .unwrap_or_else(|poisoned| poisoned.into_inner());
5176 cache.entries.retain(|_, entry| entry.expires_at > now);
5177 if cache.entries.contains_key(&spec.idempotency_key) {
5178 return Err(failure(
5179 NodeFailureCode::SpawnIdempotencyConflict,
5180 "spawn idempotency key was reused across spawn kinds",
5181 ));
5182 }
5183 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5184 return Err(failure(
5185 NodeFailureCode::SpawnIdempotencyCapacity,
5186 "spawn idempotency cache reached its bounded capacity",
5187 ));
5188 }
5189 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5190 value: SpawnIdempotencyValue::HarnessMcp,
5191 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5192 });
5193 Ok(())
5194 }
5195
5196 fn replay_managed_spawn(
5197 &self,
5198 request: &ManagedWorktreeSpawnRequest,
5199 now: Instant,
5200 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5201 let mut cache = self.spawn_idempotency.lock()
5202 .unwrap_or_else(|poisoned| poisoned.into_inner());
5203 cache.entries.retain(|_, entry| entry.expires_at > now);
5204 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5205 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5206 return Err(failure(
5207 NodeFailureCode::SpawnIdempotencyConflict,
5208 "spawn idempotency key was reused across spawn kinds",
5209 ));
5210 };
5211 if existing != request {
5212 return Err(failure(
5213 NodeFailureCode::SpawnIdempotencyConflict,
5214 "spawn idempotency key was reused with a different managed specification",
5215 ));
5216 }
5217 return Ok(Some(result.clone()));
5218 }
5219 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5220 return Err(failure(
5221 NodeFailureCode::SpawnIdempotencyCapacity,
5222 "spawn idempotency cache reached its bounded capacity",
5223 ));
5224 }
5225 Ok(None)
5226 }
5227
5228 fn remember_managed_spawn(
5229 &self,
5230 request: ManagedWorktreeSpawnRequest,
5231 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5232 accepted_at: Instant,
5233 ) {
5234 self.spawn_idempotency.lock()
5235 .unwrap_or_else(|poisoned| poisoned.into_inner())
5236 .entries
5237 .insert(
5238 request.spawn_spec.idempotency_key.clone(),
5239 SpawnIdempotencyEntry {
5240 value: SpawnIdempotencyValue::Managed { request, result },
5241 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5242 },
5243 );
5244 }
5245
5246 fn remember_managed_spawn_attempt(
5247 &self,
5248 durable_v2_key: Option<&SpawnIdempotencyKey>,
5249 request: ManagedWorktreeSpawnRequest,
5250 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5251 accepted_at: Instant,
5252 ) {
5253 if durable_v2_key.is_none() {
5254 self.remember_managed_spawn(request, result, accepted_at);
5255 }
5256 }
5257
5258 fn acquire_durable_managed_spawn_v2(
5259 &self,
5260 request: &ManagedWorktreeSpawnRequestV2,
5261 request_digest: String,
5262 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5263 if self.state_path.is_none() {
5264 return Err(failure(
5265 NodeFailureCode::BackendOperationFailed,
5266 "managed worktree V2 requires durable node state",
5267 ));
5268 }
5269 let _transaction = self.state_transaction
5270 .lock()
5271 .unwrap_or_else(|poisoned| poisoned.into_inner());
5272 let key = request.spawn_spec.idempotency_key.clone();
5273 let mut previous = None;
5274 {
5275 let mut replays = self.managed_spawn_replays
5276 .lock()
5277 .unwrap_or_else(|poisoned| poisoned.into_inner());
5278 if let Some(record) = replays.get_mut(&key) {
5279 if record.request_digest != request_digest {
5280 return Err(failure(
5281 NodeFailureCode::SpawnIdempotencyConflict,
5282 "managed worktree V2 idempotency key was reused with a different request",
5283 ));
5284 }
5285 match &record.state {
5286 ManagedWorktreeSpawnReplayStateV10::Pending {
5287 owner_incarnation_id,
5288 lease_id: None,
5289 } if owner_incarnation_id != &self.incarnation_id => {
5290 previous = Some(record.clone());
5291 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
5292 owner_incarnation_id: self.incarnation_id,
5293 lease_id: None,
5294 };
5295 record.updated_at_unix_ms = unix_time_ms();
5296 }
5297 ManagedWorktreeSpawnReplayStateV10::Pending {
5298 lease_id: None,
5299 ..
5300 } => {
5301 return Err(failure(
5302 NodeFailureCode::BackendBusy,
5303 "managed worktree V2 reservation is already active",
5304 ));
5305 }
5306 ManagedWorktreeSpawnReplayStateV10::Pending {
5307 lease_id: Some(_),
5308 ..
5309 } => {
5310 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
5311 }
5312 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
5313 return Ok(DurableManagedSpawnReplayDecision::Committed(
5314 receipt.clone(),
5315 ));
5316 }
5317 }
5318 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
5319 return Err(failure(
5320 NodeFailureCode::SpawnIdempotencyCapacity,
5321 "durable managed worktree replay ledger reached its bounded capacity",
5322 ));
5323 } else {
5324 let now = unix_time_ms();
5325 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
5326 idempotency_key: key.clone(),
5327 request_digest,
5328 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
5329 profile_id: request.worktree_profile_id.clone(),
5330 expected_profile_revision: request.expected_profile_revision.clone(),
5331 state: ManagedWorktreeSpawnReplayStateV10::Pending {
5332 owner_incarnation_id: self.incarnation_id,
5333 lease_id: None,
5334 },
5335 created_at_unix_ms: now,
5336 updated_at_unix_ms: now,
5337 });
5338 }
5339 }
5340 if let Err(error) = self.persist_state_locked() {
5341 let mut replays = self.managed_spawn_replays
5342 .lock()
5343 .unwrap_or_else(|poisoned| poisoned.into_inner());
5344 match previous {
5345 Some(previous) => {
5346 replays.insert(key, previous);
5347 }
5348 None => {
5349 replays.remove(&key);
5350 }
5351 }
5352 return Err(persistence_failure(error));
5353 }
5354 Ok(DurableManagedSpawnReplayDecision::Reserved)
5355 }
5356
5357 fn link_durable_managed_spawn_v2_lease_locked(
5358 &self,
5359 key: &SpawnIdempotencyKey,
5360 lease_id: &ManagedWorktreeLeaseId,
5361 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
5362 let mut replays = self.managed_spawn_replays
5363 .lock()
5364 .unwrap_or_else(|poisoned| poisoned.into_inner());
5365 let record = replays.get_mut(key).ok_or_else(|| {
5366 failure(
5367 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5368 "managed worktree V2 reservation disappeared before allocation",
5369 )
5370 })?;
5371 let previous = record.clone();
5372 match &mut record.state {
5373 ManagedWorktreeSpawnReplayStateV10::Pending {
5374 lease_id: current,
5375 ..
5376 }
5377 if current.is_none() =>
5378 {
5379 *current = Some(lease_id.clone());
5380 record.updated_at_unix_ms = unix_time_ms();
5381 Ok(previous)
5382 }
5383 _ => Err(failure(
5384 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5385 "managed worktree V2 reservation is not allocation-ready",
5386 )),
5387 }
5388 }
5389
5390 fn restore_durable_managed_spawn_v2_locked(
5391 &self,
5392 key: &SpawnIdempotencyKey,
5393 previous: ManagedWorktreeSpawnReplayRecordV10,
5394 ) {
5395 self.managed_spawn_replays
5396 .lock()
5397 .unwrap_or_else(|poisoned| poisoned.into_inner())
5398 .insert(key.clone(), previous);
5399 }
5400
5401 fn commit_durable_managed_spawn_v2(
5402 &self,
5403 request: &ManagedWorktreeSpawnRequestV2,
5404 receipt: ManagedWorktreeSpawnReceipt,
5405 ) -> Result<(), NodeFailure> {
5406 let _transaction = self.state_transaction
5407 .lock()
5408 .unwrap_or_else(|poisoned| poisoned.into_inner());
5409 let key = &request.spawn_spec.idempotency_key;
5410 let previous = {
5411 let mut replays = self.managed_spawn_replays
5412 .lock()
5413 .unwrap_or_else(|poisoned| poisoned.into_inner());
5414 let record = replays.get_mut(key).ok_or_else(|| {
5415 failure(
5416 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5417 "managed worktree V2 reservation disappeared before commit",
5418 )
5419 })?;
5420 let previous = record.clone();
5421 match &record.state {
5422 ManagedWorktreeSpawnReplayStateV10::Pending {
5423 lease_id: Some(lease_id),
5424 ..
5425 } if lease_id == &receipt.lease.lease_id => {}
5426 _ => {
5427 return Err(failure(
5428 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5429 "managed worktree V2 reservation is not linked to the spawned lease",
5430 ));
5431 }
5432 }
5433 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
5434 record.updated_at_unix_ms = unix_time_ms();
5435 previous
5436 };
5437 if let Err(error) = self.persist_state_locked() {
5438 self.restore_durable_managed_spawn_v2_locked(key, previous);
5439 return Err(persistence_failure(error));
5440 }
5441 Ok(())
5442 }
5443
5444 fn remove_unallocated_durable_managed_spawn_v2(
5445 &self,
5446 request: &ManagedWorktreeSpawnRequestV2,
5447 ) -> Result<(), NodeFailure> {
5448 let _transaction = self.state_transaction
5449 .lock()
5450 .unwrap_or_else(|poisoned| poisoned.into_inner());
5451 let key = &request.spawn_spec.idempotency_key;
5452 let removed = {
5453 let mut replays = self.managed_spawn_replays
5454 .lock()
5455 .unwrap_or_else(|poisoned| poisoned.into_inner());
5456 match replays.get(key).map(|record| &record.state) {
5457 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
5458 lease_id: None,
5459 ..
5460 }) => {
5461 replays.remove(key)
5462 }
5463 _ => None,
5464 }
5465 };
5466 let Some(removed) = removed else {
5467 return Ok(());
5468 };
5469 if let Err(error) = self.persist_state_locked() {
5470 self.managed_spawn_replays
5471 .lock()
5472 .unwrap_or_else(|poisoned| poisoned.into_inner())
5473 .insert(key.clone(), removed);
5474 return Err(persistence_failure(error));
5475 }
5476 Ok(())
5477 }
5478
5479 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
5480 if spec.target.node_id != self.node_id {
5481 return Err(failure(
5482 NodeFailureCode::SpawnTargetMismatch,
5483 "spawn target does not match this node",
5484 ));
5485 }
5486 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
5487 failure(
5488 NodeFailureCode::UnknownSpawnProfile,
5489 "spawn profile is unavailable on this node",
5490 )
5491 })?;
5492 let resolved = spec.resolve(defaults).map_err(|error| match error {
5493 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
5494 NodeFailureCode::SpawnProfileRevisionMismatch,
5495 "spawn profile revision does not match the loaded profile",
5496 ),
5497 _ => failure(
5498 NodeFailureCode::InvalidRequest,
5499 "spawn specification could not be resolved",
5500 ),
5501 })?;
5502 if resolved.target.worktree_id.is_some() {
5503 self.require_worktree_service(&resolved.target.workspace_id)?;
5504 }
5505 let environment_profile = self.resolve_environment_profile(&resolved)?;
5506 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
5507 self.resolve_context(&resolved)?;
5508 Ok(resolved)
5509 }
5510
5511 async fn resolve_spawn_workspace(
5512 &self,
5513 resolved: &ResolvedSpawnSpec,
5514 deadline: Instant,
5515 ) -> Result<WorkspaceId, NodeFailure> {
5516 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
5517 return Ok(resolved.target.workspace_id.clone());
5518 };
5519 if self.managed_worktrees.lock()
5520 .unwrap_or_else(|poisoned| poisoned.into_inner())
5521 .lease_for_workspace(worktree_id)
5522 .is_some()
5523 {
5524 return Err(failure(
5525 NodeFailureCode::ManagedWorktreeOwnershipConflict,
5526 "managed worktree targets may only be spawned through their lease request",
5527 ));
5528 }
5529 self.require_worktree_service(&resolved.target.workspace_id)?;
5530 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
5531 let selected_root = self.workspace_root(worktree_id)?;
5532 let remaining = spawn_deadline_remaining(deadline)?;
5533 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
5534 .await
5535 .map_err(|_| {
5536 failure(
5537 NodeFailureCode::SpawnDeadlineExceeded,
5538 "Git worktree selection exceeded the spawn deadline",
5539 )
5540 })?
5541 .map_err(git_worktree_failure)?;
5542 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
5543 Ok(worktree_id.clone())
5544 }
5545
5546 async fn spawn_from_spec(
5547 &self,
5548 spec: SpawnSpec,
5549 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
5550 self.spawn_from_spec_with_proxy(spec, None).await
5551 }
5552
5553 async fn spawn_from_spec_with_proxy(
5554 &self,
5555 spec: SpawnSpec,
5556 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
5557 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
5558 let accepted_at = Instant::now();
5559 if harness_mcp.is_none() {
5560 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
5561 return replayed;
5562 }
5563 }
5564 let resolved = self.resolve_spawn_spec(&spec)?;
5565 let environment_profile = self.resolve_environment_profile(&resolved)?;
5566 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
5567 let context = self.resolve_context(&resolved)?;
5568 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
5569 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
5570 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
5571 let spawn_workspace_id = match spawn_workspace_id {
5572 Ok(workspace_id) => workspace_id,
5573 Err(error) => {
5574 let result = Err(error);
5575 if harness_mcp.is_none() {
5576 self.remember_spawn_spec(spec, result.clone(), accepted_at);
5577 }
5578 return result;
5579 }
5580 };
5581 if self.managed_worktrees.lock()
5582 .unwrap_or_else(|poisoned| poisoned.into_inner())
5583 .lease_for_workspace(&spawn_workspace_id)
5584 .is_some()
5585 {
5586 let result = Err(failure(
5587 NodeFailureCode::ManagedWorktreeOwnershipConflict,
5588 "managed worktree targets may only be spawned through their lease request",
5589 ));
5590 if harness_mcp.is_none() {
5591 self.remember_spawn_spec(spec, result.clone(), accepted_at);
5592 }
5593 return result;
5594 }
5595 let result = self
5596 .spawn_session_with_deadline(
5597 spawn_workspace_id,
5598 resolved.provider.clone(),
5599 resolved.mode,
5600 resolved.terminal_size,
5601 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
5602 environment_profile.clone(),
5603 bundle.clone(),
5604 context.clone(),
5605 None,
5606 None,
5607 SpawnRecordPolicy::Always,
5608 Some(deadline),
5609 &required_capabilities,
5610 harness_mcp,
5611 resolved.approval_level,
5612 resolved.network_allowlist.clone(),
5613 resolved.browser_profile_id.clone(),
5614 )
5615 .await
5616 .map(|(session, _runtime_policy)| {
5617 resolved.receipt_with_materialization(
5618 self.incarnation_id,
5619 session,
5620 environment_profile,
5621 bundle,
5622 context,
5623 )
5624 });
5625 if harness_mcp.is_none() {
5626 self.remember_spawn_spec(spec, result.clone(), accepted_at);
5627 }
5628 result
5629 }
5630
5631 async fn spawn_from_spec_with_harness_mcp(
5632 &self,
5633 reservation_id: HarnessMcpReservationId,
5634 activation_digest: HarnessMcpActivationDigest,
5635 spec: SpawnSpec,
5636 deadline_unix_ms: u64,
5637 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
5638 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
5639 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
5640 })?;
5641 if let Some(receipt) = registry
5642 .replay_spawn(&reservation_id, &activation_digest, &spec)
5643 .map_err(harness_mcp_failure)?
5644 {
5645 return Ok(receipt);
5646 }
5647 let resolved = self.resolve_spawn_spec(&spec)?;
5648 let prepared = registry
5649 .prepare_spawn(
5650 &reservation_id,
5651 &activation_digest,
5652 &spec,
5653 deadline_unix_ms,
5654 resolved.provider.clone(),
5655 )
5656 .map_err(harness_mcp_failure)?;
5657 self.claim_harness_mcp_spawn(
5658 &reservation_id,
5659 &activation_digest,
5660 &spec,
5661 Instant::now(),
5662 )?;
5663 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
5664 let mut receipt = match spawn {
5665 Ok(receipt) => receipt,
5666 Err(error) => {
5667 let _ = registry.abort(&reservation_id, &activation_digest);
5668 return Err(error);
5669 }
5670 };
5671 let record_id = self
5672 .session_bindings
5673 .lock()
5674 .unwrap_or_else(|poisoned| poisoned.into_inner())
5675 .get(&receipt.session.session.instance_id)
5676 .and_then(|binding| binding.record_id.clone());
5677 let record_id = match record_id {
5678 Some(record_id) => record_id,
5679 None => {
5680 let rollback = self.remove_session(&receipt.session).await;
5681 let _ = registry.abort(&reservation_id, &activation_digest);
5682 if let Err(rollback_error) = rollback {
5683 return Err(rollback_error);
5684 }
5685 return Err(failure(
5686 NodeFailureCode::BindingMismatch,
5687 "harness MCP spawn did not retain its exact managed record",
5688 ));
5689 }
5690 };
5691 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
5692 reservation_id: reservation_id.clone(),
5693 activation_digest: activation_digest.clone(),
5694 });
5695 if let Err(error) = registry.mark_spawned(
5696 &prepared,
5697 receipt.session.clone(),
5698 record_id,
5699 receipt.clone(),
5700 ) {
5701 let rollback = self.remove_session(&receipt.session).await;
5702 let _ = registry.abort(&reservation_id, &activation_digest);
5703 if let Err(rollback_error) = rollback {
5704 return Err(rollback_error);
5705 }
5706 return Err(harness_mcp_failure(error));
5707 }
5708 Ok(receipt)
5709 }
5710
5711 async fn spawn_managed_worktree(
5712 &self,
5713 request: ManagedWorktreeSpawnRequest,
5714 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
5715 self.spawn_managed_worktree_inner(request, None).await
5716 }
5717
5718 async fn spawn_managed_worktree_inner(
5719 &self,
5720 request: ManagedWorktreeSpawnRequest,
5721 durable_v2_key: Option<&SpawnIdempotencyKey>,
5722 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
5723 let accepted_at = Instant::now();
5724 if durable_v2_key.is_none() {
5725 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
5726 return replayed;
5727 }
5728 }
5729 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
5730 if request.spawn_spec.target.worktree_id.is_some() {
5731 let result = Err(failure(
5732 NodeFailureCode::InvalidRequest,
5733 "managed worktree spawn must not select a caller-provided worktree",
5734 ));
5735 self.remember_managed_spawn_attempt(
5736 durable_v2_key,
5737 request,
5738 result.clone(),
5739 accepted_at,
5740 );
5741 return result;
5742 }
5743 let environment_profile = self.resolve_environment_profile(&resolved)?;
5744 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
5745 let context = self.resolve_context(&resolved)?;
5746 let source_workspace_id = resolved.target.workspace_id.clone();
5747 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
5748 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
5749 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
5750 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
5751 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
5752 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
5753 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
5754 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
5755 };
5756 let runtime_policy = timeout(
5757 spawn_deadline_remaining(deadline)?,
5758 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
5759 ).await.map_err(|_| failure(
5760 NodeFailureCode::SpawnDeadlineExceeded,
5761 "provider admission exceeded the managed spawn deadline",
5762 ))??;
5763 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
5764 return Err(failure(
5765 NodeFailureCode::UnsupportedSpawnCapability,
5766 "provider runtime does not admit a required spawn capability",
5767 ));
5768 }
5769 self.ensure_binding_capacity()?;
5770 let source_root = self.workspace_root(&source_workspace_id)?;
5771 profile.validate_for_workspace(&source_root)
5772 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
5773 let base_timeout_ms = u64::try_from(
5774 spawn_deadline_remaining(deadline)?.as_millis(),
5775 ).unwrap_or(u64::MAX).max(1);
5776 let base_commit = resolve_base_commit_with_timeout(
5777 &source_root,
5778 profile.base(),
5779 base_timeout_ms,
5780 ).await.map_err(|error| {
5781 if Instant::now() >= deadline || error.message.contains("timed out") {
5782 failure(
5783 NodeFailureCode::SpawnDeadlineExceeded,
5784 "managed worktree base resolution exceeded the spawn deadline",
5785 )
5786 } else {
5787 managed_git_worktree_failure(error)
5788 }
5789 })?;
5790
5791 let lease = {
5792 let _transaction = self.state_transaction.lock()
5793 .unwrap_or_else(|poisoned| poisoned.into_inner());
5794 let previous = self.managed_worktrees.lock()
5795 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
5796 let lease = self.managed_worktrees.lock()
5797 .unwrap_or_else(|poisoned| poisoned.into_inner())
5798 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
5799 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
5800 let previous_replay = match durable_v2_key
5801 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
5802 .transpose()
5803 {
5804 Ok(previous) => previous,
5805 Err(error) => {
5806 *self.managed_worktrees.lock()
5807 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
5808 return Err(error);
5809 }
5810 };
5811 if let Err(error) = self.persist_state_locked() {
5812 *self.managed_worktrees.lock()
5813 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
5814 if let (Some(key), Some(previous_replay)) =
5815 (durable_v2_key, previous_replay)
5816 {
5817 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
5818 }
5819 return Err(persistence_failure(error));
5820 }
5821 lease
5822 };
5823 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
5824 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
5825 self.managed_worktrees.lock()
5826 .unwrap_or_else(|poisoned| poisoned.into_inner())
5827 .remove_unmutated(&lease.lease_id);
5828 if self.persist_state().is_err() {
5829 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5830 }
5831 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
5832 self.remember_managed_spawn_attempt(
5833 durable_v2_key,
5834 request,
5835 result.clone(),
5836 accepted_at,
5837 );
5838 return result;
5839 }
5840 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
5841 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
5842 Err(error) => {
5843 self.reconcile_failed_allocation(&lease, &source_root).await;
5844 let result = Err(error);
5845 self.remember_managed_spawn_attempt(
5846 durable_v2_key,
5847 request,
5848 result.clone(),
5849 accepted_at,
5850 );
5851 return result;
5852 }
5853 };
5854 let created = match create_git_worktree_with_timeout(
5855 &source_root,
5856 &lease.target_root,
5857 &lease.branch,
5858 Some(&base_commit),
5859 mutation_timeout_ms,
5860 ).await {
5861 Ok(created) => created,
5862 Err(error) => {
5863 self.reconcile_failed_allocation(&lease, &source_root).await;
5864 let result = Err(if Instant::now() >= deadline
5865 || error.message.contains("timed out")
5866 || error.message.contains("deadline")
5867 {
5868 failure(
5869 NodeFailureCode::SpawnDeadlineExceeded,
5870 "managed worktree creation exceeded the spawn deadline",
5871 )
5872 } else {
5873 managed_git_worktree_failure(error)
5874 });
5875 self.remember_managed_spawn_attempt(
5876 durable_v2_key,
5877 request,
5878 result.clone(),
5879 accepted_at,
5880 );
5881 return result;
5882 }
5883 };
5884 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
5885 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
5886 let result = Err(failure(
5887 NodeFailureCode::ManagedWorktreeRecoveryRequired,
5888 "created worktree identity did not match its durable lease",
5889 ));
5890 self.remember_managed_spawn_attempt(
5891 durable_v2_key,
5892 request,
5893 result.clone(),
5894 accepted_at,
5895 );
5896 return result;
5897 }
5898 {
5899 let mut registry = self.managed_worktrees.lock()
5900 .unwrap_or_else(|poisoned| poisoned.into_inner());
5901 let current = registry.get_mut(&lease.lease_id)
5902 .expect("allocated managed worktree lease remains present");
5903 current.expected_head = Some(created.head.clone());
5904 current.state = ManagedWorktreeLeaseState::Ready;
5905 current.updated_at_unix_ms = unix_time_ms();
5906 }
5907 if let Err(error) = self.persist_state() {
5908 self.mark_managed_recovery_required(
5909 &lease.lease_id,
5910 ManagedWorktreeCleanupFailure::Backend,
5911 );
5912 let result = Err(persistence_failure(error));
5913 self.remember_managed_spawn_attempt(
5914 durable_v2_key,
5915 request,
5916 result.clone(),
5917 accepted_at,
5918 );
5919 return result;
5920 }
5921 let ready = self.managed_worktrees.lock()
5922 .unwrap_or_else(|poisoned| poisoned.into_inner())
5923 .get(&lease.lease_id)
5924 .expect("ready managed worktree lease remains present")
5925 .snapshot();
5926 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
5927 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
5928 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
5929 let result = Err(error);
5930 self.remember_managed_spawn_attempt(
5931 durable_v2_key,
5932 request,
5933 result.clone(),
5934 accepted_at,
5935 );
5936 return result;
5937 }
5938 let spawn = self.spawn_session_with_deadline(
5939 lease.workspace_id.clone(),
5940 resolved.provider.clone(),
5941 resolved.mode,
5942 resolved.terminal_size,
5943 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
5944 environment_profile.clone(),
5945 bundle.clone(),
5946 context.clone(),
5947 Some(lease.lease_id.clone()),
5948 Some(runtime_policy),
5949 SpawnRecordPolicy::Always,
5950 Some(deadline),
5951 &required_capabilities,
5952 None,
5953 resolved.approval_level,
5954 resolved.network_allowlist.clone(),
5955 resolved.browser_profile_id.clone(),
5956 ).await;
5957 let (session, effective_runtime_policy) = match spawn {
5958 Ok(spawned) => spawned,
5959 Err(error) => {
5960 if self
5961 .cleanup_managed_worktree(&lease.lease_id, true)
5962 .await
5963 .is_err()
5964 {
5965 self.mark_managed_recovery_required(
5966 &lease.lease_id,
5967 ManagedWorktreeCleanupFailure::Backend,
5968 );
5969 }
5970 let result = Err(error);
5971 self.remember_managed_spawn_attempt(
5972 durable_v2_key,
5973 request,
5974 result.clone(),
5975 accepted_at,
5976 );
5977 return result;
5978 }
5979 };
5980 let (record_id, raw_inventory_record_id) = {
5981 let mut bindings = self.session_bindings.lock()
5982 .unwrap_or_else(|poisoned| poisoned.into_inner());
5983 let binding = bindings.get_mut(&session.session.instance_id)
5984 .expect("successful spawn retains its session binding");
5985 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
5986 managed_spawn_record_ownership(binding, effective_runtime_policy)
5987 };
5988 let bound = self.managed_worktrees.lock()
5989 .unwrap_or_else(|poisoned| poisoned.into_inner())
5990 .bind_session(
5991 &lease.lease_id,
5992 ManagedWorktreeSessionHolder {
5993 incarnation_id: self.incarnation_id,
5994 instance_id: session.session.instance_id,
5995 generation: session.session.generation,
5996 },
5997 record_id,
5998 unix_time_ms(),
5999 )
6000 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6001 let snapshot = match bound {
6002 Ok(snapshot) => snapshot,
6003 Err(error) => {
6004 if raw_inventory_record_id
6005 .as_ref()
6006 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6007 {
6008 self.mark_managed_recovery_required(
6009 &lease.lease_id,
6010 ManagedWorktreeCleanupFailure::Backend,
6011 );
6012 }
6013 let _ = self.remove_session(&session).await;
6014 self.mark_managed_recovery_required(
6015 &lease.lease_id,
6016 ManagedWorktreeCleanupFailure::Backend,
6017 );
6018 let result = Err(error);
6019 self.remember_managed_spawn_attempt(
6020 durable_v2_key,
6021 request,
6022 result.clone(),
6023 accepted_at,
6024 );
6025 return result;
6026 }
6027 };
6028 if let Err(error) = self.persist_state() {
6029 if raw_inventory_record_id
6030 .as_ref()
6031 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6032 {
6033 self.mark_managed_recovery_required(
6034 &lease.lease_id,
6035 ManagedWorktreeCleanupFailure::Backend,
6036 );
6037 }
6038 let _ = self.remove_session(&session).await;
6039 if self
6040 .cleanup_managed_worktree(&lease.lease_id, true)
6041 .await
6042 .is_err()
6043 {
6044 self.mark_managed_recovery_required(
6045 &lease.lease_id,
6046 ManagedWorktreeCleanupFailure::Backend,
6047 );
6048 }
6049 let result = Err(persistence_failure(error));
6050 self.remember_managed_spawn_attempt(
6051 durable_v2_key,
6052 request,
6053 result.clone(),
6054 accepted_at,
6055 );
6056 return result;
6057 }
6058 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6059 let receipt = managed_spawn_receipt(
6060 &resolved,
6061 self.incarnation_id,
6062 session,
6063 snapshot,
6064 environment_profile,
6065 bundle,
6066 context,
6067 );
6068 let result = Ok(receipt);
6069 self.remember_managed_spawn_attempt(
6070 durable_v2_key,
6071 request,
6072 result.clone(),
6073 accepted_at,
6074 );
6075 result
6076 }
6077
6078 async fn spawn_managed_worktree_v2(
6079 &self,
6080 request: ManagedWorktreeSpawnRequestV2,
6081 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6082 if request.spawn_spec.target.node_id != self.node_id {
6083 return Err(failure(
6084 NodeFailureCode::SpawnTargetMismatch,
6085 "spawn target does not match this node",
6086 ));
6087 }
6088 if request.spawn_spec.target.worktree_id.is_some() {
6089 return Err(failure(
6090 NodeFailureCode::InvalidRequest,
6091 "managed worktree spawn must not select a caller-provided worktree",
6092 ));
6093 }
6094 let request_digest = managed_spawn_request_digest_v2(&request)?;
6095 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6096 match replay {
6097 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6098 DurableManagedSpawnReplayDecision::PendingLinked => {
6099 return Err(failure(
6100 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6101 "managed worktree V2 reservation has an incomplete durable allocation",
6102 ));
6103 }
6104 DurableManagedSpawnReplayDecision::Reserved => {}
6105 }
6106 let validated = (|| {
6107 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6108 let profile = self.managed_profile(
6109 &resolved.target.workspace_id,
6110 &request.worktree_profile_id,
6111 )?;
6112 if profile.revision() != &request.expected_profile_revision {
6113 return Err(failure(
6114 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6115 "managed worktree profile revision does not match the loaded profile",
6116 ));
6117 }
6118 Ok(())
6119 })();
6120 match validated {
6121 Ok(()) => {}
6122 Err(error) => {
6123 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6124 return Err(error);
6125 }
6126 }
6127 let legacy = ManagedWorktreeSpawnRequest {
6128 spawn_spec: request.spawn_spec.clone(),
6129 worktree_profile_id: request.worktree_profile_id.clone(),
6130 };
6131 match self
6132 .spawn_managed_worktree_inner(
6133 legacy,
6134 Some(&request.spawn_spec.idempotency_key),
6135 )
6136 .await
6137 {
6138 Ok(receipt) => {
6139 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6140 Ok(receipt)
6141 }
6142 Err(error) => {
6143 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6144 Err(error)
6145 }
6146 }
6147 }
6148
6149 fn managed_profile(
6150 &self,
6151 workspace_id: &WorkspaceId,
6152 profile_id: &WorktreeProfileId,
6153 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6154 if self.worktree_service_modes.get(workspace_id).copied()
6155 != Some(WorktreeServiceMode::Managed)
6156 {
6157 return Err(failure(
6158 NodeFailureCode::UnsupportedSpawnCapability,
6159 "workspace does not enable managed worktrees",
6160 ));
6161 }
6162 self.managed_worktree_profiles.get(workspace_id)
6163 .and_then(|profiles| profiles.get(profile_id))
6164 .cloned()
6165 .ok_or_else(|| failure(
6166 NodeFailureCode::InvalidRequest,
6167 "managed worktree profile is unavailable for this workspace",
6168 ))
6169 }
6170
6171 async fn reconcile_failed_allocation(
6172 &self,
6173 lease: &ManagedWorktreeLeaseRecord,
6174 source_root: &str,
6175 ) {
6176 match list_git_worktrees(source_root).await {
6177 Ok(worktrees) => {
6178 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6179 None => {
6180 match std::fs::symlink_metadata(&lease.target_root) {
6181 Err(error) if error.kind() == io::ErrorKind::NotFound => {
6182 self.managed_worktrees.lock()
6183 .unwrap_or_else(|poisoned| poisoned.into_inner())
6184 .remove_unmutated(&lease.lease_id);
6185 let _ = self.persist_state();
6186 }
6187 _ => self.mark_managed_recovery_required(
6188 &lease.lease_id,
6189 ManagedWorktreeCleanupFailure::OwnershipConflict,
6190 ),
6191 }
6192 }
6193 Some(item) if exact_owned_worktree(
6194 lease,
6195 &item.path,
6196 item.branch.as_deref(),
6197 &item.head,
6198 ) => {
6199 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6200 }
6201 Some(_) => self.mark_managed_recovery_required(
6202 &lease.lease_id,
6203 ManagedWorktreeCleanupFailure::OwnershipConflict,
6204 ),
6205 }
6206 }
6207 Err(_) => self.mark_managed_recovery_required(
6208 &lease.lease_id,
6209 ManagedWorktreeCleanupFailure::Backend,
6210 ),
6211 }
6212 }
6213
6214 async fn reconcile_managed_worktrees(&self) {
6215 let leases = self.managed_worktrees.lock()
6216 .unwrap_or_else(|poisoned| poisoned.into_inner())
6217 .active_records()
6218 .cloned()
6219 .collect::<Vec<_>>();
6220 for lease in leases {
6221 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6222 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6223 _ => {
6224 self.mark_managed_recovery_required(
6225 &lease.lease_id,
6226 ManagedWorktreeCleanupFailure::OwnershipConflict,
6227 );
6228 continue;
6229 }
6230 };
6231 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6232 self.mark_managed_recovery_required(
6233 &lease.lease_id,
6234 ManagedWorktreeCleanupFailure::OwnershipConflict,
6235 );
6236 continue;
6237 }
6238 let source_root = match self.workspace_root(&lease.source_workspace_id) {
6239 Ok(root) => root,
6240 Err(_) => {
6241 self.mark_managed_recovery_required(
6242 &lease.lease_id,
6243 ManagedWorktreeCleanupFailure::OwnershipConflict,
6244 );
6245 continue;
6246 }
6247 };
6248 let worktrees = match list_git_worktrees(&source_root).await {
6249 Ok(worktrees) => worktrees,
6250 Err(_) => {
6251 self.mark_managed_recovery_required(
6252 &lease.lease_id,
6253 ManagedWorktreeCleanupFailure::Backend,
6254 );
6255 continue;
6256 }
6257 };
6258 let listed = worktrees.iter()
6259 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6260 let Some(listed) = listed else {
6261 match std::fs::symlink_metadata(&lease.target_root) {
6262 Err(error) if error.kind() == io::ErrorKind::NotFound
6263 && lease.state == ManagedWorktreeLeaseState::Allocating
6264 && !lease.has_holders() => {
6265 let removed = self.managed_worktrees.lock()
6266 .unwrap_or_else(|poisoned| poisoned.into_inner())
6267 .tombstone(&lease.lease_id, unix_time_ms());
6268 if self.persist_state().is_ok() && removed.is_some() {
6269 self.publish(NodeEvent::ManagedWorktreeRemoved {
6270 lease_id: lease.lease_id.clone(),
6271 });
6272 }
6273 }
6274 _ => self.mark_managed_recovery_required(
6275 &lease.lease_id,
6276 ManagedWorktreeCleanupFailure::OwnershipConflict,
6277 ),
6278 }
6279 continue;
6280 };
6281 if listed.is_main || listed.is_bare || !exact_owned_worktree(
6282 &lease,
6283 &listed.path,
6284 listed.branch.as_deref(),
6285 &listed.head,
6286 ) {
6287 self.mark_managed_recovery_required(
6288 &lease.lease_id,
6289 ManagedWorktreeCleanupFailure::OwnershipConflict,
6290 );
6291 continue;
6292 }
6293 if !self.workspaces.read()
6294 .unwrap_or_else(|poisoned| poisoned.into_inner())
6295 .contains_key(&lease.workspace_id)
6296 {
6297 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
6298 self.mark_managed_recovery_required(
6299 &lease.lease_id,
6300 ManagedWorktreeCleanupFailure::Backend,
6301 );
6302 continue;
6303 }
6304 }
6305 let snapshot = {
6306 let mut registry = self.managed_worktrees.lock()
6307 .unwrap_or_else(|poisoned| poisoned.into_inner());
6308 let current = registry.get_mut(&lease.lease_id)
6309 .expect("reconciled managed lease remains present");
6310 current.expected_head = Some(listed.head.clone());
6311 current.cleanup_failure = None;
6312 current.state = if current.has_holders() {
6313 ManagedWorktreeLeaseState::InUse
6314 } else if current.retention == ManagedWorktreeRetention::Retain {
6315 ManagedWorktreeLeaseState::Retained
6316 } else {
6317 ManagedWorktreeLeaseState::Ready
6318 };
6319 current.updated_at_unix_ms = unix_time_ms();
6320 current.snapshot()
6321 };
6322 if self.persist_state().is_err() {
6323 self.mark_managed_recovery_required(
6324 &lease.lease_id,
6325 ManagedWorktreeCleanupFailure::Backend,
6326 );
6327 continue;
6328 }
6329 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6330 if snapshot.state == ManagedWorktreeLeaseState::Ready {
6331 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
6332 }
6333 }
6334 }
6335
6336 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
6356 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
6357 .session_records
6358 .lock()
6359 .unwrap_or_else(|poisoned| poisoned.into_inner())
6360 .records
6361 .values()
6362 .filter(|record| {
6363 record.state == ManagedSessionState::Live
6364 && record.exported_context.is_none()
6365 && record.provider_session.is_some()
6366 })
6367 .filter_map(|record| {
6368 record
6369 .active_session
6370 .clone()
6371 .map(|session| (record.record_id.clone(), session))
6372 })
6373 .collect();
6374 for (record_id, session) in candidates {
6375 let shared = Arc::clone(self);
6376 tokio::spawn(async move {
6377 shared
6378 .reconcile_context_pack_export_for_record(&record_id, session)
6379 .await;
6380 });
6381 }
6382 }
6383
6384 async fn reconcile_context_pack_export_for_record(
6403 &self,
6404 record_id: &SessionRecordId,
6405 session: SessionAddress,
6406 ) {
6407 let Ok(record) = self.record(record_id) else {
6408 return;
6409 };
6410 if !matches!(
6411 record.state,
6412 ManagedSessionState::Live | ManagedSessionState::Dormant
6413 ) || record.exported_context.is_some()
6414 || record.provider_session.is_none()
6415 {
6416 return;
6417 }
6418 let Ok(receipt) = self
6425 .export_context_pack_for_session_record(&record.record_id, &session, true)
6426 .await
6427 else {
6428 return;
6429 };
6430 self.set_exported_context(&record.record_id, receipt);
6431 }
6432
6433 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
6438 let transaction = self
6439 .state_transaction
6440 .lock()
6441 .unwrap_or_else(|poisoned| poisoned.into_inner());
6442 let (previous, updated) = {
6443 let mut records = self
6444 .session_records
6445 .lock()
6446 .unwrap_or_else(|poisoned| poisoned.into_inner());
6447 let Some(record) = records.records.get_mut(record_id) else {
6448 return;
6449 };
6450 if record.exported_context.is_some() {
6451 return;
6452 }
6453 let previous = record.clone();
6454 record.exported_context = Some(receipt);
6455 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
6456 (previous, record.clone())
6457 };
6458 if self.persist_state_locked().is_err() {
6459 self.session_records
6460 .lock()
6461 .unwrap_or_else(|poisoned| poisoned.into_inner())
6462 .records
6463 .insert(record_id.clone(), previous);
6464 return;
6465 }
6466 drop(transaction);
6467 self.publish_record(updated);
6468 }
6469
6470 fn managed_record_export_target_for_instance(
6480 &self,
6481 instance_id: AgentInstanceId,
6482 generation: SessionGeneration,
6483 ) -> Option<(SessionRecordId, SessionAddress)> {
6484 let bindings = self
6485 .session_bindings
6486 .lock()
6487 .unwrap_or_else(|poisoned| poisoned.into_inner());
6488 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
6489 let record_id = binding.record_id.clone()?;
6490 Some((
6491 record_id,
6492 SessionAddress {
6493 workspace_id: binding.workspace_id.clone(),
6494 session: SessionKey { instance_id, generation },
6495 },
6496 ))
6497 }
6498
6499 fn mark_managed_recovery_required(
6500 &self,
6501 lease_id: &ManagedWorktreeLeaseId,
6502 failure_kind: ManagedWorktreeCleanupFailure,
6503 ) {
6504 let snapshot = {
6505 let mut registry = self.managed_worktrees.lock()
6506 .unwrap_or_else(|poisoned| poisoned.into_inner());
6507 let Some(lease) = registry.get_mut(lease_id) else { return };
6508 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
6509 lease.cleanup_failure = Some(failure_kind);
6510 lease.updated_at_unix_ms = unix_time_ms();
6511 lease.snapshot()
6512 };
6513 let _ = self.persist_state();
6514 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
6515 }
6516
6517 fn set_persistence_error(&self, error: Option<String>) {
6518 *self
6519 .persistence_error
6520 .write()
6521 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
6522 .map(|message| session_registry::sanitized_persistence_summary(&message));
6523 }
6524
6525 fn persist_state(&self) -> io::Result<()> {
6526 let _transaction = self
6527 .state_transaction
6528 .lock()
6529 .unwrap_or_else(|poisoned| poisoned.into_inner());
6530 self.persist_state_locked()
6531 }
6532
6533 fn persist_state_locked(&self) -> io::Result<()> {
6534 let workspaces = self
6535 .workspaces
6536 .read()
6537 .unwrap_or_else(|poisoned| poisoned.into_inner())
6538 .clone();
6539 let records = self
6540 .session_records
6541 .lock()
6542 .unwrap_or_else(|poisoned| poisoned.into_inner())
6543 .records
6544 .values()
6545 .cloned()
6546 .collect::<Vec<_>>();
6547 let mut managed = self.managed_worktrees
6548 .lock()
6549 .unwrap_or_else(|poisoned| poisoned.into_inner());
6550 managed.reattach_record_holders(
6551 &managed_worktree_record_holders(&records),
6552 unix_time_ms(),
6553 );
6554 let materializations = self
6555 .materializations
6556 .lock()
6557 .unwrap_or_else(|poisoned| poisoned.into_inner())
6558 .values()
6559 .cloned()
6560 .collect::<Vec<_>>();
6561 let managed_spawn_replays = self.managed_spawn_replays
6562 .lock()
6563 .unwrap_or_else(|poisoned| poisoned.into_inner())
6564 .values()
6565 .cloned()
6566 .collect::<Vec<_>>();
6567 let result = session_registry::save_v10(
6568 self.state_path.as_deref(),
6569 &self.node_id,
6570 &workspaces,
6571 &records,
6572 &managed.records(),
6573 &managed.tombstones(),
6574 &materializations,
6575 &managed_spawn_replays,
6576 );
6577 match &result {
6578 Ok(warning) => self.set_persistence_error(warning.clone()),
6579 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
6580 }
6581 result.map(|_| ())
6582 }
6583
6584 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
6585 let _mutation_guard = self.mutation_gate.lock().await;
6586 self.begin_shutdown_locked().await
6587 }
6588
6589 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
6590 if self.shutdown.swap(true, Ordering::AcqRel) {
6591 return Ok(());
6592 }
6593 if let Some(registry) = self.harness_mcp_registry.as_ref() {
6594 let instances = registry.shutdown();
6595 if let Some(control) = self.native_launch_profile_control.as_ref() {
6596 for instance_id in instances {
6597 control.clear_native_mcp_server_launch_overlay(instance_id);
6598 }
6599 }
6600 }
6601 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
6602 let mut first_error = None;
6603 for session in &self.handle.snapshot().sessions {
6604 let remaining = deadline.saturating_duration_since(Instant::now());
6605 if remaining.is_zero() {
6606 first_error.get_or_insert_with(|| {
6607 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
6608 });
6609 break;
6610 }
6611 if let Err(error) = self
6612 .dispatch_bounded(
6613 ControlCommand::Stop {
6614 instance_id: session.instance_id,
6615 force: true,
6616 },
6617 remaining,
6618 )
6619 .await
6620 {
6621 first_error.get_or_insert(error);
6622 }
6623 }
6624 self.shutdown_notify.notify_waiters();
6625 match first_error {
6626 Some(error) => Err(error),
6627 None => Ok(()),
6628 }
6629 }
6630
6631 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
6632 let bindings = self
6633 .session_bindings
6634 .lock()
6635 .unwrap_or_else(|poisoned| poisoned.into_inner());
6636 if bindings.len() >= CONTROL_SESSIONS_MAX {
6637 Err(failure(
6638 NodeFailureCode::BackendBusy,
6639 "node session-binding capacity is full; remove an existing session first",
6640 ))
6641 } else {
6642 Ok(())
6643 }
6644 }
6645
6646 fn ensure_workspace_environment_profile_compatible(
6653 &self,
6654 workspace_id: &WorkspaceId,
6655 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
6656 ) -> Result<(), NodeFailure> {
6657 let bindings = self
6658 .session_bindings
6659 .lock()
6660 .unwrap_or_else(|poisoned| poisoned.into_inner());
6661 for binding in bindings.values() {
6662 if &binding.workspace_id != workspace_id {
6663 continue;
6664 }
6665 if binding.environment_profile.as_ref() != environment_profile {
6666 return Err(failure(
6667 NodeFailureCode::WorkspaceBusy,
6668 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
6669 ));
6670 }
6671 }
6672 Ok(())
6673 }
6674
6675 #[cfg(test)]
6676 fn bind_session(
6677 &self,
6678 instance_id: AgentInstanceId,
6679 workspace_id: WorkspaceId,
6680 generation: SessionGeneration,
6681 ) {
6682 self.bind_session_with_policy(
6683 &SessionAddress {
6684 workspace_id,
6685 session: SessionKey { instance_id, generation },
6686 },
6687 ProviderRuntimePolicy::raw_pty(),
6688 None,
6689 );
6690 }
6691
6692 #[cfg(test)]
6693 fn bind_session_with_policy(
6694 &self,
6695 address: &SessionAddress,
6696 runtime_policy: ProviderRuntimePolicy,
6697 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6698 ) {
6699 self.bind_session_with_materialization(
6700 address,
6701 runtime_policy,
6702 environment_profile,
6703 None,
6704 None,
6705 None,
6706 );
6707 }
6708
6709 fn bind_session_with_materialization(
6710 &self,
6711 address: &SessionAddress,
6712 runtime_policy: ProviderRuntimePolicy,
6713 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6714 bundle: Option<ResolvedBundleReceipt>,
6715 context: Option<ResolvedContextPackReceipt>,
6716 materialization_id: Option<MaterializationId>,
6717 ) {
6718 let mut bindings = self
6719 .session_bindings
6720 .lock()
6721 .unwrap_or_else(|poisoned| poisoned.into_inner());
6722 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
6723 debug_assert!(!bindings.contains_key(&address.session.instance_id));
6724 bindings.insert(address.session.instance_id, SessionBinding {
6725 workspace_id: address.workspace_id.clone(),
6726 generation: address.session.generation,
6727 runtime_policy,
6728 pending_resume: None,
6729 record_id: None,
6730 managed_worktree_lease_id: None,
6731 environment_profile,
6732 bundle,
6733 context,
6734 materialization_id,
6735 });
6736 }
6737
6738 #[cfg(test)]
6739 fn bind_managed_session(
6740 &self,
6741 address: &SessionAddress,
6742 record_id: SessionRecordId,
6743 runtime_policy: ProviderRuntimePolicy,
6744 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6745 ) {
6746 self.bind_managed_session_with_materialization(
6747 address,
6748 record_id,
6749 runtime_policy,
6750 environment_profile,
6751 None,
6752 None,
6753 None,
6754 );
6755 }
6756
6757 fn bind_managed_session_with_materialization(
6758 &self,
6759 address: &SessionAddress,
6760 record_id: SessionRecordId,
6761 runtime_policy: ProviderRuntimePolicy,
6762 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6763 bundle: Option<ResolvedBundleReceipt>,
6764 context: Option<ResolvedContextPackReceipt>,
6765 materialization_id: Option<MaterializationId>,
6766 ) {
6767 let mut bindings = self
6768 .session_bindings
6769 .lock()
6770 .unwrap_or_else(|poisoned| poisoned.into_inner());
6771 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
6772 debug_assert!(!bindings.contains_key(&address.session.instance_id));
6773 bindings.insert(
6774 address.session.instance_id,
6775 SessionBinding {
6776 workspace_id: address.workspace_id.clone(),
6777 generation: address.session.generation,
6778 runtime_policy,
6779 pending_resume: None,
6780 record_id: Some(record_id),
6781 managed_worktree_lease_id: None,
6782 environment_profile,
6783 bundle,
6784 context,
6785 materialization_id,
6786 },
6787 );
6788 }
6789
6790 #[cfg(test)]
6791 fn bind_spawn_session(
6792 &self,
6793 address: &SessionAddress,
6794 provider: AgentId,
6795 mode: SessionMode,
6796 runtime_policy: ProviderRuntimePolicy,
6797 record_policy: SpawnRecordPolicy,
6798 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6799 ) -> Result<Option<SessionRecordId>, NodeFailure> {
6800 self.bind_spawn_session_with_materialization(
6801 address,
6802 provider,
6803 mode,
6804 runtime_policy,
6805 record_policy,
6806 environment_profile,
6807 None,
6808 None,
6809 None,
6810 )
6811 }
6812
6813 fn bind_spawn_session_with_materialization(
6814 &self,
6815 address: &SessionAddress,
6816 provider: AgentId,
6817 mode: SessionMode,
6818 runtime_policy: ProviderRuntimePolicy,
6819 record_policy: SpawnRecordPolicy,
6820 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6821 bundle: Option<ResolvedBundleReceipt>,
6822 context: Option<ResolvedContextPackReceipt>,
6823 materialization_id: Option<MaterializationId>,
6824 ) -> Result<Option<SessionRecordId>, NodeFailure> {
6825 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
6826 && !runtime_policy.provider_session_identity
6827 {
6828 self.bind_session_with_materialization(
6829 address,
6830 runtime_policy,
6831 environment_profile,
6832 bundle,
6833 context,
6834 materialization_id,
6835 );
6836 return Ok(None);
6837 }
6838 let record_owns_lifecycle = runtime_policy.provider_session_identity;
6839 let record = self.new_record(
6840 address,
6841 provider,
6842 mode,
6843 active_record_state(runtime_policy.provider_session_identity, false),
6844 None,
6845 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
6846 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
6847 record_owns_lifecycle.then(|| context.clone()).flatten(),
6848 )?;
6849 let record_id = record.record_id.clone();
6850 let transaction = self
6851 .state_transaction
6852 .lock()
6853 .unwrap_or_else(|poisoned| poisoned.into_inner());
6854 self.insert_record(record.clone())?;
6855 let previous_materialization = if record_owns_lifecycle {
6856 if let Some(materialization_id) = materialization_id.as_ref() {
6857 let mut materializations = self
6858 .materializations
6859 .lock()
6860 .unwrap_or_else(|poisoned| poisoned.into_inner());
6861 let Some(ownership) = materializations.get_mut(materialization_id) else {
6862 drop(materializations);
6863 self.remove_record_memory(&record_id);
6864 return Err(failure(
6865 NodeFailureCode::BackendOperationFailed,
6866 "session environment ownership disappeared before binding",
6867 ));
6868 };
6869 let previous = ownership.clone();
6870 if ownership
6871 .transfer_to_record(record_id.clone(), unix_time_ms())
6872 .is_err()
6873 {
6874 drop(materializations);
6875 self.remove_record_memory(&record_id);
6876 return Err(failure(
6877 NodeFailureCode::BackendOperationFailed,
6878 "session environment ownership transfer failed",
6879 ));
6880 }
6881 Some(previous)
6882 } else {
6883 None
6884 }
6885 } else {
6886 None
6887 };
6888 self.bind_managed_session_with_materialization(
6889 address,
6890 record_id.clone(),
6891 runtime_policy,
6892 environment_profile,
6893 bundle,
6894 context,
6895 materialization_id.clone(),
6896 );
6897 if let Err(error) = self.persist_state_locked() {
6898 self.remove_binding(address);
6899 self.remove_record_memory(&record_id);
6900 if let (Some(materialization_id), Some(previous)) =
6901 (materialization_id.as_ref(), previous_materialization)
6902 {
6903 self.materializations
6904 .lock()
6905 .unwrap_or_else(|poisoned| poisoned.into_inner())
6906 .insert(materialization_id.clone(), previous);
6907 }
6908 return Err(persistence_failure(error));
6909 }
6910 drop(transaction);
6911 self.publish_record(record);
6912 Ok(Some(record_id))
6913 }
6914
6915 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
6916 for _ in 0..8 {
6917 let bytes = random_nonce().map_err(|error| {
6918 failure(
6919 NodeFailureCode::BackendOperationFailed,
6920 &format!("session record identity generation failed: {error}"),
6921 )
6922 })?;
6923 let mut value = String::with_capacity(27);
6924 value.push_str("sr-");
6925 for byte in &bytes[..12] {
6926 use std::fmt::Write as _;
6927 let _ = write!(&mut value, "{byte:02x}");
6928 }
6929 let record_id = SessionRecordId::new(value).map_err(|error| {
6930 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
6931 })?;
6932 let records = self
6933 .session_records
6934 .lock()
6935 .unwrap_or_else(|poisoned| poisoned.into_inner());
6936 if !records.records.contains_key(&record_id) {
6937 return Ok(record_id);
6938 }
6939 }
6940 Err(failure(
6941 NodeFailureCode::BackendBusy,
6942 "could not allocate a unique session record ID",
6943 ))
6944 }
6945
6946 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
6947 for _ in 0..8 {
6948 let bytes = random_nonce().map_err(|error| {
6949 failure(
6950 NodeFailureCode::BackendOperationFailed,
6951 &format!("task identity generation failed: {error}"),
6952 )
6953 })?;
6954 let mut nonce = [0_u8; 12];
6955 nonce.copy_from_slice(&bytes[..12]);
6956 let task_id = TaskId::from_nonce(nonce);
6957 let records = self
6958 .session_records
6959 .lock()
6960 .unwrap_or_else(|poisoned| poisoned.into_inner());
6961 if !records.records.values().any(|record| {
6962 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
6963 == Some(&task_id)
6964 }) {
6965 return Ok(task_id);
6966 }
6967 }
6968 Err(failure(
6969 NodeFailureCode::BackendBusy,
6970 "could not allocate a unique task ID",
6971 ))
6972 }
6973
6974 fn default_record_name(&self, provider: &AgentId) -> String {
6975 let records = self
6976 .session_records
6977 .lock()
6978 .unwrap_or_else(|poisoned| poisoned.into_inner());
6979 let ordinal = records
6980 .records
6981 .values()
6982 .filter(|record| &record.provider == provider)
6983 .count()
6984 .saturating_add(1);
6985 format!("{} #{ordinal}", provider.as_str())
6986 }
6987
6988 fn new_record(
6989 &self,
6990 address: &SessionAddress,
6991 provider: AgentId,
6992 mode: SessionMode,
6993 state: ManagedSessionState,
6994 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
6995 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6996 bundle: Option<ResolvedBundleReceipt>,
6997 context: Option<ResolvedContextPackReceipt>,
6998 ) -> Result<ManagedSessionRecord, NodeFailure> {
6999 let canonical_root = self.workspace_root(&address.workspace_id)?;
7000 let now = unix_time_ms();
7001 Ok(ManagedSessionRecord {
7002 record_id: self.allocate_record_id()?,
7003 display_name: self.default_record_name(&provider),
7004 provider,
7005 mode,
7006 state,
7007 workspace_id: address.workspace_id.clone(),
7008 canonical_root: opaque_windows_path(canonical_root),
7009 provider_session,
7010 active_session: Some(address.clone()),
7011 environment_profile,
7012 bundle,
7013 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7014 context,
7015 exported_context: None,
7016 task_binding: None,
7017 created_at_unix_ms: now,
7018 updated_at_unix_ms: now,
7019 last_error: None,
7020 })
7021 }
7022
7023 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7024 let mut records = self
7025 .session_records
7026 .lock()
7027 .unwrap_or_else(|poisoned| poisoned.into_inner());
7028 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7029 return Err(failure(
7030 NodeFailureCode::BackendBusy,
7031 "managed session record capacity is full; forget an inactive record first",
7032 ));
7033 }
7034 if let Some(identity) = record.provider_session.as_ref() {
7035 if records.records.values().any(|candidate| {
7036 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7037 session_registry::same_provider_session(
7038 &candidate.provider,
7039 candidate_identity,
7040 &record.provider,
7041 identity,
7042 )
7043 })
7044 }) {
7045 return Err(failure(
7046 NodeFailureCode::SessionRecordConflict,
7047 "provider session is already represented by another managed record",
7048 ));
7049 }
7050 }
7051 match records.records.entry(record.record_id.clone()) {
7052 std::collections::btree_map::Entry::Vacant(entry) => {
7053 entry.insert(record);
7054 }
7055 std::collections::btree_map::Entry::Occupied(_) => {
7056 return Err(failure(
7057 NodeFailureCode::SessionRecordConflict,
7058 "session record ID collision",
7059 ));
7060 }
7061 }
7062 Ok(())
7063 }
7064
7065 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7066 self.session_records
7067 .lock()
7068 .unwrap_or_else(|poisoned| poisoned.into_inner())
7069 .records
7070 .get(record_id)
7071 .cloned()
7072 .ok_or_else(|| {
7073 failure(
7074 NodeFailureCode::UnknownSessionRecord,
7075 "managed session record does not exist",
7076 )
7077 })
7078 }
7079
7080 fn revalidate_session_record_preview(
7081 &self,
7082 expected: &ManagedSessionRecord,
7083 identity: &ProviderSessionIdentity,
7084 ) -> Result<(), NodeFailure> {
7085 let current = self.record(&expected.record_id)?;
7086 let current_root = self.workspace_root(¤t.workspace_id)?;
7087 if current.provider != expected.provider
7088 || current.workspace_id != expected.workspace_id
7089 || current.canonical_root != expected.canonical_root
7090 || current.provider_session.as_ref() != Some(identity)
7091 || !platform::roots_equal(
7092 ¤t_root,
7093 windows_path_text(¤t.canonical_root),
7094 )
7095 {
7096 return Err(failure(
7097 NodeFailureCode::SessionRecordConflict,
7098 "managed session changed while its preview was loading",
7099 ));
7100 }
7101 Ok(())
7102 }
7103
7104 fn revalidate_session_record_context_export(
7105 &self,
7106 expected: &ManagedSessionRecord,
7107 identity: Option<&ProviderSessionIdentity>,
7108 session: &SessionAddress,
7109 allow_clean_detachment: bool,
7110 ) -> Result<(), NodeFailure> {
7111 let current = self.record(&expected.record_id)?;
7112 let current_root = self.workspace_root(¤t.workspace_id)?;
7113 let snapshot = self.internal_session_snapshot(session)?;
7114 let binding = self
7115 .session_bindings
7116 .lock()
7117 .unwrap_or_else(|poisoned| poisoned.into_inner())
7118 .get(&session.session.instance_id)
7119 .cloned()
7120 .ok_or_else(|| {
7121 failure(
7122 NodeFailureCode::SessionRecordConflict,
7123 "managed session runtime binding disappeared during context export",
7124 )
7125 })?;
7126 if !session_record_context_export_binding_is_exact(
7127 expected,
7128 ¤t,
7129 identity,
7130 session,
7131 &binding,
7132 &snapshot.agent_id,
7133 ¤t_root,
7134 allow_clean_detachment,
7135 ) || !session_record_context_export_source_is_usable(
7136 ¤t,
7137 &snapshot.status,
7138 allow_clean_detachment,
7139 ) {
7140 return Err(failure(
7141 NodeFailureCode::SessionRecordConflict,
7142 "managed session binding changed during context export",
7143 ));
7144 }
7145 Ok(())
7146 }
7147
7148 fn revalidate_loaded_history(
7149 &self,
7150 session: &SessionAddress,
7151 candidate_id: &str,
7152 loaded: &HistorySessionRecord,
7153 ) -> Result<(), NodeFailure> {
7154 let snapshot = self.internal_session_snapshot(session)?;
7155 if snapshot.history.pending.is_some()
7156 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7157 || snapshot.history.loaded.as_ref() != Some(loaded)
7158 {
7159 return Err(failure(
7160 NodeFailureCode::SessionRecordConflict,
7161 "managed session history changed during context export",
7162 ));
7163 }
7164 Ok(())
7165 }
7166
7167 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7168 self.session_records
7169 .lock()
7170 .unwrap_or_else(|poisoned| poisoned.into_inner())
7171 .records
7172 .remove(record_id)
7173 }
7174
7175 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7176 if self.destroy_record_materialization(record_id)? {
7177 self.publish(NodeEvent::SessionRecordRemoved {
7178 record_id: record_id.clone(),
7179 });
7180 return Ok(());
7181 }
7182 let transaction = self
7183 .state_transaction
7184 .lock()
7185 .unwrap_or_else(|poisoned| poisoned.into_inner());
7186 let Some(removed) = self.remove_record_memory(record_id) else {
7187 return Ok(());
7188 };
7189 if let Err(error) = self.persist_state_locked() {
7190 let _ = self.insert_record(removed);
7191 return Err(persistence_failure(error));
7192 }
7193 drop(transaction);
7194 self.publish(NodeEvent::SessionRecordRemoved {
7195 record_id: record_id.clone(),
7196 });
7197 Ok(())
7198 }
7199
7200 fn publish_record(&self, record: ManagedSessionRecord) {
7201 self.publish(NodeEvent::SessionRecordUpserted { record });
7202 }
7203
7204 fn mark_record_error(
7205 &self,
7206 record_id: &SessionRecordId,
7207 message: &str,
7208 ) -> Result<(), NodeFailure> {
7209 let transaction = self
7210 .state_transaction
7211 .lock()
7212 .unwrap_or_else(|poisoned| poisoned.into_inner());
7213 let previous = {
7214 let mut records = self
7215 .session_records
7216 .lock()
7217 .unwrap_or_else(|poisoned| poisoned.into_inner());
7218 let Some(record) = records.records.get_mut(record_id) else {
7219 return Ok(());
7220 };
7221 let previous = record.clone();
7222 record.state = ManagedSessionState::Unavailable;
7223 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7224 record.updated_at_unix_ms = unix_time_ms();
7225 previous
7226 };
7227 if let Err(error) = self.persist_state_locked() {
7228 self.session_records
7229 .lock()
7230 .unwrap_or_else(|poisoned| poisoned.into_inner())
7231 .records
7232 .insert(record_id.clone(), previous);
7233 return Err(persistence_failure(error));
7234 }
7235 let record = self.record(record_id)?;
7236 drop(transaction);
7237 self.publish_record(record);
7238 Ok(())
7239 }
7240
7241 fn rename_session_record(
7242 &self,
7243 record_id: &SessionRecordId,
7244 display_name: String,
7245 ) -> Result<ManagedSessionRecord, NodeFailure> {
7246 validate_display_name(&display_name)
7247 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7248 let _transaction = self
7249 .state_transaction
7250 .lock()
7251 .unwrap_or_else(|poisoned| poisoned.into_inner());
7252 let previous = {
7253 let mut records = self
7254 .session_records
7255 .lock()
7256 .unwrap_or_else(|poisoned| poisoned.into_inner());
7257 let record = records.records.get_mut(record_id).ok_or_else(|| {
7258 failure(
7259 NodeFailureCode::UnknownSessionRecord,
7260 "managed session record does not exist",
7261 )
7262 })?;
7263 let previous = record.clone();
7264 record.display_name = display_name;
7265 record.updated_at_unix_ms = unix_time_ms();
7266 previous
7267 };
7268 if let Err(error) = self.persist_state_locked() {
7269 self.session_records
7270 .lock()
7271 .unwrap_or_else(|poisoned| poisoned.into_inner())
7272 .records
7273 .insert(record_id.clone(), previous);
7274 return Err(persistence_failure(error));
7275 }
7276 let updated = self.record(record_id)?;
7277 self.publish_record(updated.clone());
7278 Ok(updated)
7279 }
7280
7281 fn set_session_task(
7282 &self,
7283 record_id: &SessionRecordId,
7284 expected_revision: u64,
7285 target: SessionTaskTargetV1,
7286 ) -> Result<ManagedSessionRecord, NodeFailure> {
7287 let transaction = self
7288 .state_transaction
7289 .lock()
7290 .unwrap_or_else(|poisoned| poisoned.into_inner());
7291 let current = self.record(record_id)?;
7292 let current_task_id = current
7293 .task_binding
7294 .as_ref()
7295 .and_then(|binding| binding.task_id.as_ref());
7296 let current_revision = current
7297 .task_binding
7298 .as_ref()
7299 .map_or(0, |binding| binding.revision);
7300 if expected_revision != current_revision {
7301 return Err(failure(
7302 NodeFailureCode::SessionRecordConflict,
7303 "managed session task binding revision changed",
7304 ));
7305 }
7306 let exact = match &target {
7307 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
7308 SessionTaskTargetV1::Clear => current_task_id.is_none(),
7309 SessionTaskTargetV1::New => false,
7310 };
7311 if exact {
7312 return Ok(current);
7313 }
7314 let revision = current_revision.checked_add(1).ok_or_else(|| {
7315 failure(
7316 NodeFailureCode::SessionRecordConflict,
7317 "managed session task binding revision is exhausted",
7318 )
7319 })?;
7320 let task_id = match target {
7321 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
7322 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
7323 SessionTaskTargetV1::Clear => None,
7324 };
7325 let changed_at_unix_ms = unix_time_ms()
7326 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
7327 failure(
7328 NodeFailureCode::SessionRecordConflict,
7329 "managed session record timestamp is exhausted",
7330 )
7331 })?);
7332 let updated = {
7333 let mut records = self
7334 .session_records
7335 .lock()
7336 .unwrap_or_else(|poisoned| poisoned.into_inner());
7337 let record = records.records.get_mut(record_id).ok_or_else(|| {
7338 failure(
7339 NodeFailureCode::UnknownSessionRecord,
7340 "managed session record does not exist",
7341 )
7342 })?;
7343 record.task_binding = Some(SessionTaskBindingV1 {
7344 revision,
7345 task_id,
7346 changed_at_unix_ms,
7347 });
7348 record.updated_at_unix_ms = changed_at_unix_ms;
7349 record.clone()
7350 };
7351 if let Err(error) = self.persist_state_locked() {
7352 self.session_records
7353 .lock()
7354 .unwrap_or_else(|poisoned| poisoned.into_inner())
7355 .records
7356 .insert(record_id.clone(), current);
7357 return Err(persistence_failure(error));
7358 }
7359 drop(transaction);
7360 self.publish_record(updated.clone());
7361 Ok(updated)
7362 }
7363
7364 fn index_provider_session(
7365 &self,
7366 workspace_id: WorkspaceId,
7367 provider: AgentId,
7368 identity: ProviderSessionIdentity,
7369 display_name: String,
7370 ) -> Result<ManagedSessionRecord, NodeFailure> {
7371 self.index_provider_session_with_policy(
7372 workspace_id,
7373 provider,
7374 identity,
7375 display_name,
7376 false,
7377 )
7378 }
7379
7380 fn index_provider_session_with_policy(
7381 &self,
7382 workspace_id: WorkspaceId,
7383 provider: AgentId,
7384 identity: ProviderSessionIdentity,
7385 display_name: String,
7386 allow_validated_transcript_path: bool,
7387 ) -> Result<ManagedSessionRecord, NodeFailure> {
7388 validate_display_name(&display_name)
7389 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7390 identity
7391 .validate()
7392 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7393 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
7394 return Err(failure(
7395 NodeFailureCode::InvalidRequest,
7396 "provider session reference index v1 does not accept transcript paths",
7397 ));
7398 }
7399 let canonical_root = self.workspace_root(&workspace_id)?;
7400 if !self.enabled_providers.contains(&provider) {
7401 return Err(failure(
7402 NodeFailureCode::InvalidRequest,
7403 "provider is not enabled on this node",
7404 ));
7405 }
7406
7407 let transaction = self
7408 .state_transaction
7409 .lock()
7410 .unwrap_or_else(|poisoned| poisoned.into_inner());
7411 {
7412 let records = self
7413 .session_records
7414 .lock()
7415 .unwrap_or_else(|poisoned| poisoned.into_inner());
7416 if let Some(existing) = records.records.values().find(|candidate| {
7417 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7418 candidate_identity.key == identity.key && candidate_identity.id == identity.id
7419 })
7420 }) {
7421 if existing.provider == provider && existing.workspace_id == workspace_id {
7422 return Ok(existing.clone());
7423 }
7424 return Err(failure(
7425 NodeFailureCode::SessionRecordConflict,
7426 "provider session reference conflicts with another provider or workspace",
7427 ));
7428 }
7429 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7430 return Err(failure(
7431 NodeFailureCode::BackendBusy,
7432 "managed session record capacity is full; forget an inactive record first",
7433 ));
7434 }
7435 }
7436
7437 let now = unix_time_ms();
7438 let record = ManagedSessionRecord {
7439 record_id: self.allocate_record_id()?,
7440 display_name,
7441 provider,
7442 mode: SessionMode::Pty,
7443 state: ManagedSessionState::Dormant,
7444 workspace_id,
7445 canonical_root: opaque_windows_path(canonical_root),
7446 provider_session: Some(identity),
7447 active_session: None,
7448 environment_profile: None,
7449 bundle: None,
7450 context_id: None,
7451 context: None,
7452 exported_context: None,
7453 task_binding: None,
7454 created_at_unix_ms: now,
7455 updated_at_unix_ms: now,
7456 last_error: None,
7457 };
7458 self.insert_record(record.clone())?;
7459 if let Err(error) = self.persist_state_locked() {
7460 self.remove_record_memory(&record.record_id);
7461 return Err(persistence_failure(error));
7462 }
7463 drop(transaction);
7464 self.publish_record(record.clone());
7465 Ok(record)
7466 }
7467
7468 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
7469 self.session_bindings
7470 .lock()
7471 .unwrap_or_else(|poisoned| poisoned.into_inner())
7472 .iter()
7473 .find_map(|(instance_id, binding)| {
7474 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
7475 workspace_id: binding.workspace_id.clone(),
7476 session: SessionKey {
7477 instance_id: *instance_id,
7478 generation: binding.generation,
7479 },
7480 })
7481 })
7482 }
7483
7484 async fn forget_session_record(
7485 &self,
7486 record_id: &SessionRecordId,
7487 ) -> Result<(), NodeFailure> {
7488 let record = self.record(record_id)?;
7489 if record.active_session.is_some()
7490 || matches!(
7491 record.state,
7492 ManagedSessionState::Live | ManagedSessionState::IdentityPending
7493 )
7494 {
7495 return Err(failure(
7496 NodeFailureCode::SessionRecordBusy,
7497 "stop the managed session before forgetting it",
7498 ));
7499 }
7500 if let Some(address) = self.bound_address_for_record(record_id) {
7501 self.remove_session(&address).await?;
7502 }
7503 self.remove_managed_session_record(record_id, &record.workspace_id).await
7504 }
7505
7506 async fn remove_managed_session_record(
7521 &self,
7522 record_id: &SessionRecordId,
7523 workspace_id: &WorkspaceId,
7524 ) -> Result<(), NodeFailure> {
7525 let managed_lease_id = self.managed_worktrees.lock()
7526 .unwrap_or_else(|poisoned| poisoned.into_inner())
7527 .lease_for_workspace(workspace_id);
7528 if !self.destroy_record_materialization(record_id)? {
7529 let transaction = self
7530 .state_transaction
7531 .lock()
7532 .unwrap_or_else(|poisoned| poisoned.into_inner());
7533 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
7534 failure(
7535 NodeFailureCode::UnknownSessionRecord,
7536 "managed session record does not exist",
7537 )
7538 })?;
7539 if let Err(error) = self.persist_state_locked() {
7540 let _ = self.insert_record(removed);
7541 return Err(persistence_failure(error));
7542 }
7543 drop(transaction);
7544 }
7545 self.publish(NodeEvent::SessionRecordRemoved {
7546 record_id: record_id.clone(),
7547 });
7548 if let Some(lease_id) = managed_lease_id {
7549 if let Some(snapshot) = self.managed_worktrees.lock()
7550 .unwrap_or_else(|poisoned| poisoned.into_inner())
7551 .get(&lease_id)
7552 .map(ManagedWorktreeLeaseRecord::snapshot)
7553 {
7554 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7555 }
7556 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
7557 }
7558 Ok(())
7559 }
7560
7561 async fn retire_unavailable_session_records(&self) {
7574 let policy = self.session_record_retention;
7575 let age_enabled = policy.age_ms > 0;
7576 let keep_enabled = policy.keep_per_workspace > 0;
7577 if !age_enabled && !keep_enabled {
7578 return;
7579 }
7580 let now = unix_time_ms();
7581 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
7582 self.session_records
7583 .lock()
7584 .unwrap_or_else(|poisoned| poisoned.into_inner())
7585 .records
7586 .values()
7587 .filter(|record| record.state == ManagedSessionState::Unavailable)
7588 .map(|record| {
7589 (
7590 record.record_id.clone(),
7591 record.workspace_id.clone(),
7592 record.provider.clone(),
7593 record.updated_at_unix_ms,
7594 )
7595 })
7596 .collect()
7597 };
7598 if candidates.is_empty() {
7599 return;
7600 }
7601 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
7602 BTreeMap::new();
7603 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
7604 by_workspace
7605 .entry(workspace_id)
7606 .or_default()
7607 .push((record_id, provider, updated_at_unix_ms));
7608 }
7609 let reason = if age_enabled && keep_enabled {
7610 "age+keep-n"
7611 } else if age_enabled {
7612 "age"
7613 } else {
7614 "keep-n"
7615 };
7616 let mut retired: u64 = 0;
7617 for (workspace_id, mut records) in by_workspace {
7618 records.sort_by(|left, right| right.2.cmp(&left.2));
7621 for (index, (record_id, provider, updated_at_unix_ms)) in
7622 records.into_iter().enumerate()
7623 {
7624 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
7625 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
7626 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
7627 let should_retire = if age_enabled && keep_enabled {
7628 age_hit && keep_hit
7629 } else {
7630 age_hit || keep_hit
7631 };
7632 if !should_retire {
7633 continue;
7634 }
7635 match self.remove_managed_session_record(&record_id, &workspace_id).await {
7636 Ok(()) => {
7637 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
7638 retired += 1;
7639 tracing::info!(
7640 node_id = %self.node_id,
7641 record_id = %record_id,
7642 workspace_id = %workspace_id,
7643 provider = %provider,
7644 age_ms = record_age_ms,
7645 reason,
7646 "managed session record retired"
7647 );
7648 }
7649 Err(error) => tracing::warn!(
7650 node_id = %self.node_id,
7651 record_id = %record_id,
7652 workspace_id = %workspace_id,
7653 code = ?error.code,
7654 cause = %error.message,
7655 "session record retention sweep failed to retire a record"
7656 ),
7657 }
7658 }
7659 }
7660 if retired > 0 {
7661 let remaining_unavailable = self
7662 .session_records
7663 .lock()
7664 .unwrap_or_else(|poisoned| poisoned.into_inner())
7665 .records
7666 .values()
7667 .filter(|record| record.state == ManagedSessionState::Unavailable)
7668 .count();
7669 tracing::info!(
7670 node_id = %self.node_id,
7671 retired,
7672 remaining_unavailable,
7673 "session record retention sweep completed"
7674 );
7675 }
7676 }
7677
7678 async fn resume_session_record(
7679 &self,
7680 record_id: &SessionRecordId,
7681 terminal_size: gate4agent_types::TerminalSize,
7682 initial_prompt: Option<String>,
7683 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
7684 let record = self.record(record_id)?;
7685 if record.active_session.is_some()
7686 || matches!(
7687 record.state,
7688 ManagedSessionState::Live | ManagedSessionState::IdentityPending
7689 )
7690 {
7691 return Err(failure(
7692 NodeFailureCode::SessionRecordBusy,
7693 "managed session already has a live runtime binding",
7694 ));
7695 }
7696 let identity = record.provider_session.clone().ok_or_else(|| {
7697 failure(
7698 NodeFailureCode::SessionRecordNotResumable,
7699 "managed session has no verified provider session identity",
7700 )
7701 })?;
7702 let working_directory = self.workspace_root(&record.workspace_id)?;
7703 if !platform::roots_equal(
7704 &working_directory,
7705 windows_path_text(&record.canonical_root),
7706 ) {
7707 return Err(failure(
7708 NodeFailureCode::SessionRecordConflict,
7709 "managed session workspace root changed; refusing to resume in another directory",
7710 ));
7711 }
7712 {
7713 let records = self
7714 .session_records
7715 .lock()
7716 .unwrap_or_else(|poisoned| poisoned.into_inner());
7717 if records.records.values().any(|candidate| {
7718 candidate.record_id != *record_id
7719 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7720 session_registry::same_provider_session(
7721 &candidate.provider,
7722 candidate_identity,
7723 &record.provider,
7724 &identity,
7725 )
7726 })
7727 && (candidate.active_session.is_some()
7728 || matches!(
7729 candidate.state,
7730 ManagedSessionState::Live | ManagedSessionState::IdentityPending
7731 ))
7732 }) {
7733 return Err(failure(
7734 NodeFailureCode::SessionRecordConflict,
7735 "provider session is already active through another managed record",
7736 ));
7737 }
7738 }
7739 let runtime_requirement = if initial_prompt.is_some() {
7740 ProviderRuntimeRequirement::ResumeWithPrompt
7741 } else {
7742 ProviderRuntimeRequirement::Resume
7743 };
7744 let runtime_policy = self.admit_provider_runtime(
7745 &record.provider,
7746 runtime_requirement,
7747 )
7748 .await?;
7749 let request = ResumeLaunchRequest {
7750 working_directory,
7751 terminal_size,
7752 initial_prompt,
7753 };
7754 request
7755 .validate()
7756 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7757 self.ensure_binding_capacity()?;
7758 let resolved_materialization = match self.resolve_record_materialization(
7759 record_id,
7760 &record.provider,
7761 record.mode,
7762 record.environment_profile.as_ref(),
7763 record.bundle.as_ref(),
7764 record.context.as_ref(),
7765 ) {
7766 Ok(materialization) => materialization,
7767 Err(error) => {
7768 let label = match error.code {
7769 NodeFailureCode::UnknownBundle => "bundle-unavailable",
7770 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
7771 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
7772 _ => "environment-profile-unavailable",
7773 };
7774 self.mark_record_error(record_id, label)?;
7775 return Err(error);
7776 }
7777 };
7778 let (materialization_id, environment_overlay) = match resolved_materialization {
7779 Some((id, overlay)) => (Some(id), overlay),
7780 None => (None, None),
7781 };
7782 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
7783 let mut environment_selection = match self.select_environment_profile(
7784 instance_id,
7785 &record.provider,
7786 record.mode,
7787 record.environment_profile.as_ref(),
7788 ) {
7789 Ok(selection) => selection,
7790 Err(error) => {
7791 self.mark_record_error(record_id, "environment-profile-unavailable")?;
7792 return Err(error);
7793 }
7794 };
7795 let mut instance_overlay = environment_overlay
7796 .map(|overlay| self.install_prepared_launch_overlay(instance_id, overlay))
7797 .transpose()?
7798 .flatten();
7799 if let Some(stale_address) = self.bound_address_for_record(record_id) {
7800 self.remove_session(&stale_address).await?;
7801 }
7802 let address = SessionAddress {
7803 workspace_id: record.workspace_id.clone(),
7804 session: SessionKey {
7805 instance_id,
7806 generation: SessionGeneration(1),
7807 },
7808 };
7809 let transaction = self
7810 .state_transaction
7811 .lock()
7812 .unwrap_or_else(|poisoned| poisoned.into_inner());
7813 let current = self.record(record_id)?;
7814 if current.active_session.is_some()
7815 || matches!(
7816 current.state,
7817 ManagedSessionState::Live | ManagedSessionState::IdentityPending
7818 )
7819 {
7820 return Err(failure(
7821 NodeFailureCode::SessionRecordBusy,
7822 "managed session became active while resume was being prepared",
7823 ));
7824 }
7825 self.bind_managed_session_with_materialization(
7826 &address,
7827 record_id.clone(),
7828 runtime_policy,
7829 record.environment_profile.clone(),
7830 record.bundle.clone(),
7831 record.context.clone(),
7832 materialization_id,
7833 );
7834 if let Some(selection) = environment_selection.take() {
7835 selection.retain();
7836 }
7837 if let Some(overlay) = instance_overlay.take() {
7838 overlay.retain();
7839 }
7840 drop(transaction);
7841
7842 let transport = match record.mode {
7843 SessionMode::Pty => TransportKind::Pty,
7844 SessionMode::Inline => TransportKind::Pipe,
7845 SessionMode::Acp => TransportKind::Acp,
7846 };
7847 let agent_id = record.provider.clone();
7848 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7849 if let Err(error) = self
7850 .dispatch_bounded(
7851 ControlCommand::Register {
7852 instance_id,
7853 agent_id,
7854 transport,
7855 },
7856 dispatch_timeout,
7857 )
7858 .await
7859 {
7860 if let Some(binding) = self.remove_binding(&address) {
7861 self.cleanup_session_owned_materialization(&address, &binding)?;
7862 }
7863 return Err(error);
7864 }
7865 let command = self.prepare_command(ControlCommand::Resume {
7866 instance_id,
7867 target: ResumeTarget::ProviderSession { identity },
7868 runtime_policy,
7869 request,
7870 });
7871 let started_after = self.current_sequence();
7872 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
7873 self.rollback_spawn(&address, dispatch_timeout).await?;
7874 return Err(error);
7875 }
7876 if let Err(error) = self.dispatch_envelope(command) {
7877 self.clear_armed_resume(&address);
7878 self.rollback_spawn(&address, dispatch_timeout).await?;
7879 return Err(error);
7880 }
7881 match self
7882 .wait_for_record_resume(&address, started_after)
7883 .await
7884 {
7885 Ok(settled_address) => {
7886 let managed_lease_id = {
7887 self.managed_worktrees.lock()
7888 .unwrap_or_else(|poisoned| poisoned.into_inner())
7889 .lease_for_workspace(&record.workspace_id)
7890 };
7891 if let Some(lease_id) = managed_lease_id {
7892 {
7893 let mut bindings = self.session_bindings.lock()
7894 .unwrap_or_else(|poisoned| poisoned.into_inner());
7895 let binding = bindings.get_mut(&settled_address.session.instance_id)
7896 .ok_or_else(|| failure(
7897 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7898 "resumed managed session lost its exact binding",
7899 ))?;
7900 if binding.generation != settled_address.session.generation {
7901 return Err(failure(
7902 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7903 "resumed managed session generation diverged",
7904 ));
7905 }
7906 binding.managed_worktree_lease_id = Some(lease_id.clone());
7907 }
7908 let snapshot = self.managed_worktrees.lock()
7909 .unwrap_or_else(|poisoned| poisoned.into_inner())
7910 .bind_session(
7911 &lease_id,
7912 ManagedWorktreeSessionHolder {
7913 incarnation_id: self.incarnation_id,
7914 instance_id: settled_address.session.instance_id,
7915 generation: settled_address.session.generation,
7916 },
7917 Some(record_id.clone()),
7918 unix_time_ms(),
7919 ).map_err(|message| failure(
7920 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7921 &message,
7922 ))?;
7923 if let Err(error) = self.persist_state() {
7924 let _ = self.remove_session(&settled_address).await;
7925 return Err(persistence_failure(error));
7926 }
7927 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7928 }
7929 Ok((self.record(record_id)?, settled_address))
7930 }
7931 Err(error) => {
7932 if matches!(
7933 error.code,
7934 NodeFailureCode::SessionRecordNotResumable
7935 | NodeFailureCode::BackendOperationFailed
7936 ) {
7937 if let Some(bound) = self.bound_address_for_record(record_id) {
7938 let _ = self.remove_session(&bound).await;
7939 }
7940 }
7941 Err(error)
7942 }
7943 }
7944 }
7945
7946 async fn wait_for_record_resume(
7947 &self,
7948 initial_address: &SessionAddress,
7949 after_sequence: u64,
7950 ) -> Result<SessionAddress, NodeFailure> {
7951 let deadline = Instant::now()
7952 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
7953 let mut scan_after = after_sequence;
7954 loop {
7955 let events = self
7956 .history
7957 .lock()
7958 .unwrap_or_else(|poisoned| poisoned.into_inner())
7959 .events
7960 .iter()
7961 .filter(|event| event.sequence > scan_after)
7962 .cloned()
7963 .collect::<Vec<_>>();
7964 for envelope in events {
7965 scan_after = scan_after.max(envelope.sequence);
7966 let NodeEvent::Control { address, event } = envelope.event else {
7967 continue;
7968 };
7969 if address.session.instance_id != initial_address.session.instance_id {
7970 continue;
7971 }
7972 match event.event {
7973 ControlEventKind::Resumed { .. } => return Ok(address),
7974 ControlEventKind::ResumeDenied { reason } => {
7975 return Err(failure(
7976 NodeFailureCode::SessionRecordNotResumable,
7977 &reason,
7978 ));
7979 }
7980 ControlEventKind::ResumeFailed { message }
7981 | ControlEventKind::CommandRejected { message } => {
7982 return Err(failure(
7983 NodeFailureCode::BackendOperationFailed,
7984 &message,
7985 ));
7986 }
7987 _ => {}
7988 }
7989 }
7990 if Instant::now() >= deadline {
7991 return Err(failure(
7992 NodeFailureCode::BackendBusy,
7993 "managed resume did not settle before the bounded deadline",
7994 ));
7995 }
7996 sleep(Duration::from_millis(5)).await;
7997 }
7998 }
7999
8000 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8001 self.workspaces
8002 .read()
8003 .unwrap_or_else(|poisoned| poisoned.into_inner())
8004 .get(workspace_id)
8005 .cloned()
8006 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8007 }
8008
8009 fn workspace_roots(&self) -> Vec<PathBuf> {
8010 self.workspaces
8011 .read()
8012 .unwrap_or_else(|poisoned| poisoned.into_inner())
8013 .values()
8014 .map(PathBuf::from)
8015 .collect()
8016 }
8017
8018 async fn inspect_workspace(
8019 &self,
8020 workspace_id: WorkspaceId,
8021 ) -> Result<WorkspaceInspection, NodeFailure> {
8022 let _permit = self
8023 .inspection_slots
8024 .clone()
8025 .try_acquire_owned()
8026 .map_err(|_| {
8027 tracing::warn!(
8028 workspace_id = %workspace_id,
8029 "workspace inspection rejected: inspection capacity is busy",
8030 );
8031 failure(
8032 NodeFailureCode::BackendBusy,
8033 "workspace inspection capacity is busy",
8034 )
8035 })?;
8036 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8037 tracing::warn!(
8038 workspace_id = %workspace_id,
8039 code = ?error.code,
8040 "workspace inspection rejected: workspace is not registered",
8041 );
8042 error
8043 })?;
8044 let tree_root = canonical_root.clone();
8045 let started_at = Instant::now();
8046 let time_budget_ms = workspace_inspection_time_budget_ms();
8047 let entry_cap = workspace_inspection_entry_cap();
8048 let deadline = started_at + Duration::from_millis(time_budget_ms);
8049 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8050 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8051 })
8052 .await
8053 .map_err(|error| {
8054 tracing::warn!(
8055 workspace_id = %workspace_id,
8056 error = %error,
8057 "workspace inspection rejected: walk task failed",
8058 );
8059 failure(
8060 NodeFailureCode::BackendOperationFailed,
8061 &format!("workspace inspection task failed: {error}"),
8062 )
8063 })?;
8064 let (mut git, git_time_budget_exceeded) =
8065 inspect_git_workspace(&canonical_root, deadline).await;
8066 if !git.worktrees.is_empty() {
8067 let registered = self
8068 .workspaces
8069 .read()
8070 .unwrap_or_else(|poisoned| poisoned.into_inner())
8071 .iter()
8072 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8073 .collect::<Vec<_>>();
8074 for worktree in &mut git.worktrees {
8075 worktree.workspace_id = registered
8076 .iter()
8077 .find(|(_, root)| {
8078 worktree_paths_equal(root, windows_path_text(&worktree.path))
8079 })
8080 .map(|(workspace_id, _)| workspace_id.clone());
8081 }
8082 }
8083 if git.is_repository {
8084 git.managed_worktree = self
8085 .managed_worktrees
8086 .lock()
8087 .unwrap_or_else(|poisoned| poisoned.into_inner())
8088 .git_scope(&workspace_id, git.branch.as_deref());
8089 }
8090 let truncation = workspace_inspection_truncation(
8091 &walk_budget,
8092 git_time_budget_exceeded,
8093 started_at.elapsed(),
8094 );
8095 if let Some(truncation) = &truncation {
8096 tracing::info!(
8097 workspace_id = %workspace_id,
8098 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8099 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8100 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8101 entries_visited = truncation.entries_visited,
8102 elapsed_ms = truncation.elapsed_ms,
8103 "workspace inspection truncated by its inner budget",
8104 );
8105 }
8106 Ok(WorkspaceInspection {
8107 workspace_id,
8108 entries,
8109 tree_truncated,
8110 git,
8111 truncation,
8112 })
8113 }
8114
8115 async fn browse_host_directories(
8116 &self,
8117 directory: Option<OpaqueHostPath>,
8118 after: Option<OpaqueHostPath>,
8119 ) -> Result<HostDirectoryListing, NodeFailure> {
8120 let permit = self
8121 .inspection_slots
8122 .clone()
8123 .try_acquire_owned()
8124 .map_err(|_| {
8125 failure(
8126 NodeFailureCode::BackendBusy,
8127 "host directory browse capacity is busy",
8128 )
8129 })?;
8130 let task = tokio::task::spawn_blocking(move || {
8131 let _permit = permit;
8132 browse_host_directories(directory, after)
8133 });
8134 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8135 .await
8136 .map_err(|_| {
8137 failure(
8138 NodeFailureCode::HostDirectoryReadTimedOut,
8139 "host directory browse exceeded its bounded deadline",
8140 )
8141 })?
8142 .map_err(|_| {
8143 failure(
8144 NodeFailureCode::HostDirectoryReadFailed,
8145 "host directory browse task failed",
8146 )
8147 })?
8148 .map_err(host_directory_failure)
8149 }
8150
8151 async fn read_workspace_file(
8152 &self,
8153 workspace_id: WorkspaceId,
8154 path: RepositoryPath,
8155 ) -> Result<WorkspaceFileRead, NodeFailure> {
8156 let canonical_root = self.workspace_root(&workspace_id)?;
8157 let permit = self
8158 .inspection_slots
8159 .clone()
8160 .try_acquire_owned()
8161 .map_err(|_| {
8162 failure(
8163 NodeFailureCode::BackendBusy,
8164 "workspace file read capacity is busy",
8165 )
8166 })?;
8167 let path_to_read = path.clone();
8168 let task = tokio::task::spawn_blocking(move || {
8169 let _permit = permit;
8170 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8171 });
8172 let content = timeout(
8173 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8174 task,
8175 )
8176 .await
8177 .map_err(|_| {
8178 failure(
8179 NodeFailureCode::RepositoryFileReadTimedOut,
8180 "repository file read exceeded its bounded deadline",
8181 )
8182 })?
8183 .map_err(|_| {
8184 failure(
8185 NodeFailureCode::RepositoryFileReadFailed,
8186 "repository file read task failed",
8187 )
8188 })?
8189 .map_err(workspace_file_failure)?;
8190 let content = match content {
8191 WorkspaceFileBytes::Utf8(text) => {
8192 let revision = Some(workspace_file_revision(text.as_bytes()));
8193 return Ok(WorkspaceFileRead {
8194 workspace_id,
8195 path,
8196 content: WorkspaceFileContent::Utf8 {
8197 byte_len: u32::try_from(text.len())
8198 .expect("bounded workspace text length must fit u32"),
8199 text,
8200 },
8201 revision,
8202 });
8203 }
8204 WorkspaceFileBytes::NonUtf8 { byte_length } => {
8205 WorkspaceFileContent::NonUtf8 {
8206 byte_len: u32::try_from(byte_length)
8207 .expect("bounded workspace file length must fit u32"),
8208 }
8209 }
8210 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8211 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8212 },
8213 };
8214 Ok(WorkspaceFileRead {
8215 workspace_id,
8216 path,
8217 content,
8218 revision: None,
8219 })
8220 }
8221
8222 async fn write_workspace_file(
8223 &self,
8224 workspace_id: WorkspaceId,
8225 path: RepositoryPath,
8226 expected_revision: WorkspaceFileRevision,
8227 text: String,
8228 ) -> Result<WorkspaceFileRead, NodeFailure> {
8229 let canonical_root = self.workspace_root(&workspace_id)?;
8230 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8231 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8232 })?;
8233 let path_to_write = path.clone();
8234 let expected = expected_revision.as_str().to_owned();
8235 let text_to_write = text.clone();
8236 let task = tokio::task::spawn_blocking(move || {
8237 let _permit = permit;
8238 write_workspace_file_to_disk(
8239 Path::new(&canonical_root),
8240 &path_to_write,
8241 &expected,
8242 &text_to_write,
8243 )
8244 });
8245 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8246 .await
8247 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8248 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8249 .map_err(workspace_file_write_failure)?;
8250 Ok(WorkspaceFileRead {
8251 workspace_id,
8252 path,
8253 content: WorkspaceFileContent::Utf8 {
8254 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8255 text,
8256 },
8257 revision: Some(WorkspaceFileRevision::new(revision)
8258 .expect("workspace writer returns a valid SHA-256 revision")),
8259 })
8260 }
8261
8262 async fn create_workspace_file(
8263 &self,
8264 workspace_id: WorkspaceId,
8265 path: RepositoryPath,
8266 ) -> Result<WorkspaceFileRead, NodeFailure> {
8267 let canonical_root = self.workspace_root(&workspace_id)?;
8268 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8269 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8270 })?;
8271 let path_to_create = path.clone();
8272 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8273 let task_commit_state = Arc::clone(&commit_state);
8274 let task = tokio::task::spawn_blocking(move || {
8275 let _permit = permit;
8276 create_workspace_file_on_disk(
8277 Path::new(&canonical_root),
8278 &path_to_create,
8279 &task_commit_state,
8280 )
8281 });
8282 let revision = settle_workspace_entry_create(
8283 task,
8284 commit_state,
8285 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8286 "repository file create exceeded its bounded deadline",
8287 "repository file create task failed",
8288 )
8289 .await?;
8290 Ok(WorkspaceFileRead {
8291 workspace_id,
8292 path,
8293 content: WorkspaceFileContent::Utf8 {
8294 text: String::new(),
8295 byte_len: 0,
8296 },
8297 revision: Some(WorkspaceFileRevision::new(revision)
8298 .expect("workspace creator returns a valid SHA-256 revision")),
8299 })
8300 }
8301
8302 async fn create_workspace_directory(
8303 &self,
8304 workspace_id: WorkspaceId,
8305 path: RepositoryPath,
8306 ) -> Result<WorkspaceEntry, NodeFailure> {
8307 let canonical_root = self.workspace_root(&workspace_id)?;
8308 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8309 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8310 })?;
8311 let path_to_create = path.clone();
8312 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8313 let task_commit_state = Arc::clone(&commit_state);
8314 let task = tokio::task::spawn_blocking(move || {
8315 let _permit = permit;
8316 create_workspace_directory_on_disk(
8317 Path::new(&canonical_root),
8318 &path_to_create,
8319 &task_commit_state,
8320 )
8321 });
8322 settle_workspace_entry_create(
8323 task,
8324 commit_state,
8325 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8326 "repository directory create exceeded its bounded deadline",
8327 "repository directory create task failed",
8328 )
8329 .await?;
8330 Ok(WorkspaceEntry {
8331 relative_path: path,
8332 kind: WorkspaceEntryKind::Directory,
8333 })
8334 }
8335
8336 async fn read_git_history(
8337 &self,
8338 workspace_id: WorkspaceId,
8339 path: Option<RepositoryPath>,
8340 before: Option<GitObjectId>,
8341 limit: u16,
8342 ) -> Result<GitHistoryPage, NodeFailure> {
8343 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8344 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
8345 })?;
8346 let root = self.workspace_root(&workspace_id)?;
8347 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
8348 }
8349
8350 async fn read_git_diff(
8351 &self,
8352 workspace_id: WorkspaceId,
8353 request: GitDiffRequest,
8354 ) -> Result<GitDiff, NodeFailure> {
8355 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8356 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
8357 })?;
8358 let root = self.workspace_root(&workspace_id)?;
8359 read_git_diff_bounded(&root, request).await
8360 }
8361
8362 fn native_session_route_root(
8363 &self,
8364 route: &NativeSessionCatalogRoute,
8365 ) -> Result<Option<String>, NodeFailure> {
8366 route
8367 .validate()
8368 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8369 match route.scope {
8370 gate4agent_types::NativeSessionCatalogScope::Workspace => route
8371 .workspace_id
8372 .as_ref()
8373 .map(|workspace_id| self.workspace_root(workspace_id))
8374 .transpose(),
8375 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
8376 }
8377 }
8378
8379 fn project_native_session_entries(
8380 &self,
8381 route: &NativeSessionCatalogRoute,
8382 entries: Vec<ScopedNativeSessionCatalogEntry>,
8383 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
8384 let current_root = route
8385 .workspace_id
8386 .as_ref()
8387 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
8388 let records = self
8389 .session_records
8390 .lock()
8391 .unwrap_or_else(|poisoned| poisoned.into_inner());
8392 let mut seen_records = std::collections::HashSet::new();
8393 entries
8394 .into_iter()
8395 .map(|entry| {
8396 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
8397 let matches = records
8398 .records
8399 .values()
8400 .filter(|record| {
8401 record.provider == route.provider
8402 && record.workspace_id == *workspace_id
8403 && current_root.as_deref().is_some_and(|root| {
8404 platform::roots_equal(
8405 root,
8406 windows_path_text(&record.canonical_root),
8407 )
8408 })
8409 && record.provider_session.as_ref().is_some_and(|identity| {
8410 session_registry::same_provider_session(
8411 &record.provider,
8412 identity,
8413 &route.provider,
8414 &entry.provider_session,
8415 )
8416 })
8417 })
8418 .map(|record| record.record_id.clone())
8419 .collect::<Vec<_>>();
8420 (matches.len() == 1
8421 && seen_records.insert(matches[0].clone()))
8422 .then(|| matches[0].clone())
8423 });
8424 let metadata = entry.metadata;
8425 crate::protocol::NativeSessionCatalogEntry {
8426 selection_id: metadata.selection_id,
8427 title: metadata.title,
8428 modified_at_unix_ms: metadata.modified_at_unix_ms,
8429 model: metadata.model,
8430 message_count: metadata.message_count,
8431 completed_turn_count: metadata.completed_turn_count,
8432 external_group: entry.external_group,
8433 record_id,
8434 }
8435 })
8436 .collect()
8437 }
8438
8439 async fn catalog_native_sessions(
8440 &self,
8441 route: NativeSessionCatalogRoute,
8442 limit: u16,
8443 ) -> Result<(
8444 Vec<crate::protocol::NativeSessionCatalogEntry>,
8445 crate::protocol::NativeSessionCatalogSummary,
8446 ), NodeFailure> {
8447 let canonical_root = self.native_session_route_root(&route)?;
8448 let registered_workspace_roots = self.workspace_roots();
8449 let provider = route.provider.clone();
8450 let scope = route.scope;
8451 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8452 failure(
8453 NodeFailureCode::InvalidRequest,
8454 "native session catalog is not configured",
8455 )
8456 })?;
8457 let _permit = Arc::clone(&self.inspection_slots)
8458 .try_acquire_owned()
8459 .map_err(|_| {
8460 failure(
8461 NodeFailureCode::BackendBusy,
8462 "native session catalog capacity is busy",
8463 )
8464 })?;
8465 let page = run_native_session_catalog_operation(
8466 catalog,
8467 "native session catalog",
8468 move |catalog| {
8469 catalog.catalog_initial_for_scope(
8470 &provider,
8471 scope,
8472 canonical_root.as_deref().map(Path::new),
8473 ®istered_workspace_roots,
8474 limit,
8475 )
8476 },
8477 )
8478 .await?;
8479 let summary = crate::protocol::NativeSessionCatalogSummary {
8480 catalog_revision: page.revision,
8481 recent_cutoff_unix_ms: page.cutoff_unix_ms,
8482 recent_total_count: page.recent_total_count,
8483 older_total_count: page.older_total_count,
8484 recent_next_after_selection_id: page.next_after_selection_id,
8485 recent_has_more: page.remaining_count > 0,
8486 };
8487 Ok((self.project_native_session_entries(&route, page.entries), summary))
8488 }
8489
8490 async fn page_native_sessions(
8491 &self,
8492 route: NativeSessionCatalogRoute,
8493 window: crate::protocol::NativeSessionCatalogWindow,
8494 catalog_revision: u64,
8495 recent_cutoff_unix_ms: u64,
8496 after_selection_id: Option<String>,
8497 limit: u16,
8498 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
8499 let canonical_root = self.native_session_route_root(&route)?;
8500 let registered_workspace_roots = self.workspace_roots();
8501 let provider = route.provider.clone();
8502 let scope = route.scope;
8503 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8504 failure(
8505 NodeFailureCode::InvalidRequest,
8506 "native session catalog is not configured",
8507 )
8508 })?;
8509 let _permit = Arc::clone(&self.inspection_slots)
8510 .try_acquire_owned()
8511 .map_err(|_| {
8512 failure(
8513 NodeFailureCode::BackendBusy,
8514 "native session catalog capacity is busy",
8515 )
8516 })?;
8517 let page = run_native_session_catalog_operation(
8518 catalog,
8519 "native session catalog page",
8520 move |catalog| {
8521 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
8522 &provider,
8523 scope,
8524 canonical_root.as_deref().map(Path::new),
8525 ®istered_workspace_roots,
8526 window,
8527 catalog_revision,
8528 recent_cutoff_unix_ms,
8529 after_selection_id.as_deref(),
8530 limit,
8531 ))
8532 },
8533 )
8534 .await?;
8535 let page = page.map_err(|error| match error {
8536 NativeSessionCatalogError::StaleCatalog => failure(
8537 NodeFailureCode::StaleNativeSessionCatalog,
8538 "native session catalog revision or cursor is stale",
8539 ),
8540 _ => failure(
8541 NodeFailureCode::BackendOperationFailed,
8542 "native session catalog page failed",
8543 ),
8544 })?;
8545 let entries = self.project_native_session_entries(&route, page.entries);
8546 Ok(crate::protocol::NativeSessionCatalogPage {
8547 window: page.window,
8548 revision: page.revision,
8549 entries,
8550 next_after_selection_id: page.next_after_selection_id,
8551 remaining_count: page.remaining_count,
8552 has_more: page.remaining_count > 0,
8553 })
8554 }
8555
8556 async fn preview_native_session(
8557 &self,
8558 selection: NativeSessionSelection,
8559 message_limit: u16,
8560 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
8561 selection
8562 .validate()
8563 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8564 let canonical_root = self.native_session_route_root(&selection.route)?;
8565 let registered_workspace_roots = self.workspace_roots();
8566 let provider = selection.route.provider.clone();
8567 let scope = selection.route.scope;
8568 let catalog_revision = selection.catalog_revision;
8569 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
8570 let selection_id = selection.selection_id.clone();
8571 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8572 failure(
8573 NodeFailureCode::InvalidRequest,
8574 "native session preview is not configured",
8575 )
8576 })?;
8577 let _permit = Arc::clone(&self.inspection_slots)
8578 .try_acquire_owned()
8579 .map_err(|_| {
8580 failure(
8581 NodeFailureCode::BackendBusy,
8582 "native session preview capacity is busy",
8583 )
8584 })?;
8585 let preview = run_native_session_catalog_operation(
8586 catalog,
8587 "native session preview",
8588 move |catalog| {
8589 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
8590 &provider,
8591 scope,
8592 canonical_root.as_deref().map(Path::new),
8593 ®istered_workspace_roots,
8594 catalog_revision,
8595 recent_cutoff_unix_ms,
8596 &selection_id,
8597 message_limit,
8598 ))
8599 },
8600 )
8601 .await?;
8602 preview
8603 .map(Into::into)
8604 .map_err(native_session_preview_failure)
8605 }
8606
8607 async fn index_native_session(
8608 &self,
8609 selection: NativeSessionSelection,
8610 display_name: String,
8611 ) -> Result<ManagedSessionRecord, NodeFailure> {
8612 selection
8613 .validate()
8614 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8615 if selection.route.scope
8616 != gate4agent_types::NativeSessionCatalogScope::Workspace
8617 {
8618 return Err(failure(
8619 NodeFailureCode::WorkspaceRegistrationRequired,
8620 "register the external project as a workspace before indexing or resuming it",
8621 ));
8622 }
8623 let workspace_id = selection
8624 .route
8625 .workspace_id
8626 .clone()
8627 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
8628 let canonical_root = self
8629 .native_session_route_root(&selection.route)?
8630 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
8631 let registered_workspace_roots = self.workspace_roots();
8632 let provider = selection.route.provider.clone();
8633 let catalog_revision = selection.catalog_revision;
8634 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
8635 let selection_id = selection.selection_id.clone();
8636 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8637 failure(
8638 NodeFailureCode::InvalidRequest,
8639 "native session index is not configured",
8640 )
8641 })?;
8642 let _permit = Arc::clone(&self.inspection_slots)
8643 .try_acquire_owned()
8644 .map_err(|_| {
8645 failure(
8646 NodeFailureCode::BackendBusy,
8647 "native session index capacity is busy",
8648 )
8649 })?;
8650 let resolved = run_native_session_catalog_operation(
8651 catalog,
8652 "native session index resolution",
8653 move |catalog| {
8654 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
8655 &provider,
8656 gate4agent_types::NativeSessionCatalogScope::Workspace,
8657 Some(Path::new(&canonical_root)),
8658 ®istered_workspace_roots,
8659 catalog_revision,
8660 recent_cutoff_unix_ms,
8661 &selection_id,
8662 ))
8663 },
8664 )
8665 .await?
8666 .map_err(native_session_preview_failure)?;
8667 self.index_provider_session_with_policy(
8668 workspace_id,
8669 selection.route.provider,
8670 resolved.identity,
8671 display_name,
8672 true,
8673 )
8674 }
8675
8676 async fn preview_native_session_by_session_id(
8677 &self,
8678 workspace_id: WorkspaceId,
8679 provider: AgentId,
8680 session_id: String,
8681 message_limit: u16,
8682 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
8683 let canonical_root = self.workspace_root(&workspace_id)?;
8684 let registered_workspace_roots = self.workspace_roots();
8685 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8686 failure(
8687 NodeFailureCode::InvalidRequest,
8688 "native session preview is not configured",
8689 )
8690 })?;
8691 let _permit = Arc::clone(&self.inspection_slots)
8692 .try_acquire_owned()
8693 .map_err(|_| {
8694 failure(
8695 NodeFailureCode::BackendBusy,
8696 "native session preview capacity is busy",
8697 )
8698 })?;
8699 run_native_session_catalog_operation(
8700 catalog,
8701 "native session preview",
8702 move |catalog| {
8703 catalog.preview_session_id_for_workspace(
8704 &provider,
8705 Path::new(&canonical_root),
8706 ®istered_workspace_roots,
8707 &session_id,
8708 message_limit,
8709 )
8710 },
8711 )
8712 .await
8713 }
8714
8715 async fn register_workspace(
8716 &self,
8717 workspace_id: WorkspaceId,
8718 root: String,
8719 ) -> Result<WorkspaceSnapshot, NodeFailure> {
8720 validate_workspace_request_root(&root)?;
8721 let canonicalize_workspace_id = workspace_id.clone();
8722 let workspace = tokio::task::spawn_blocking(move || {
8723 WorkspaceConfig::new(canonicalize_workspace_id, root)
8724 })
8725 .await
8726 .map_err(|error| {
8727 failure(
8728 NodeFailureCode::BackendOperationFailed,
8729 &format!("workspace canonicalization task failed: {error}"),
8730 )
8731 })?
8732 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
8733 let transaction = self
8734 .state_transaction
8735 .lock()
8736 .unwrap_or_else(|poisoned| poisoned.into_inner());
8737 let mut workspaces = self
8738 .workspaces
8739 .write()
8740 .unwrap_or_else(|poisoned| poisoned.into_inner());
8741 if workspaces.contains_key(&workspace_id) {
8742 return Err(failure(
8743 NodeFailureCode::DuplicateWorkspaceId,
8744 "workspace ID is already registered",
8745 ));
8746 }
8747 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
8748 platform::roots_equal(existing_root, workspace.canonical_root())
8749 }) {
8750 return Err(failure(
8751 NodeFailureCode::DuplicateWorkspaceRoot,
8752 &format!("workspace root is already registered as '{existing_id}'"),
8753 ));
8754 }
8755 {
8756 let records = self
8757 .session_records
8758 .lock()
8759 .unwrap_or_else(|poisoned| poisoned.into_inner());
8760 if let Some(record) = records.records.values().find(|record| {
8761 record.workspace_id == workspace_id
8762 && !record
8763 .canonical_root
8764 .as_utf8()
8765 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
8766 }) {
8767 return Err(failure(
8768 NodeFailureCode::SessionRecordConflict,
8769 &format!(
8770 "workspace ID is retained by session record '{}' at another root",
8771 record.record_id,
8772 ),
8773 ));
8774 }
8775 }
8776 let snapshot = WorkspaceSnapshot {
8777 workspace_id: workspace_id.clone(),
8778 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
8779 sessions: Vec::new(),
8780 worktree_service_mode: Some(WorktreeServiceMode::Manual),
8781 managed_worktree_profiles: Some(WorktreeProfileInventory {
8782 profiles: Vec::new(),
8783 }),
8784 };
8785 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
8786 drop(workspaces);
8787 let previous_records = {
8788 let mut records = self
8789 .session_records
8790 .lock()
8791 .unwrap_or_else(|poisoned| poisoned.into_inner());
8792 let previous = records.records.clone();
8793 for record in records.records.values_mut().filter(|record| {
8794 record.workspace_id == workspace_id
8795 && record
8796 .canonical_root
8797 .as_utf8()
8798 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
8799 }) {
8800 if record.active_session.is_none() {
8801 record.state = detached_record_state(record);
8802 }
8803 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
8804 record.last_error = None;
8805 }
8806 record.updated_at_unix_ms = unix_time_ms();
8807 }
8808 previous
8809 };
8810 if let Err(error) = self.persist_state_locked() {
8811 self.workspaces
8812 .write()
8813 .unwrap_or_else(|poisoned| poisoned.into_inner())
8814 .remove(&workspace_id);
8815 self.session_records
8816 .lock()
8817 .unwrap_or_else(|poisoned| poisoned.into_inner())
8818 .records = previous_records;
8819 return Err(persistence_failure(error));
8820 }
8821 let restored = self
8822 .session_records
8823 .lock()
8824 .unwrap_or_else(|poisoned| poisoned.into_inner())
8825 .records
8826 .values()
8827 .filter(|record| record.workspace_id == workspace_id)
8828 .cloned()
8829 .collect::<Vec<_>>();
8830 drop(transaction);
8831 self.publish(NodeEvent::WorkspaceAdded {
8832 workspace: snapshot.clone(),
8833 });
8834 for record in restored {
8835 self.publish_record(record);
8836 }
8837 Ok(snapshot)
8838 }
8839
8840 async fn create_standalone_workspace(
8841 &self,
8842 workspace_id: WorkspaceId,
8843 root: String,
8844 initial_branch: Option<String>,
8845 ) -> Result<WorkspaceSnapshot, NodeFailure> {
8846 validate_workspace_request_root(&root)?;
8847 validate_git_revision("initial branch", initial_branch.as_deref())?;
8848 let preflight_workspace_id = workspace_id.clone();
8849 let preflight_root = root.clone();
8850 let canonical_candidate = tokio::task::spawn_blocking(move || {
8851 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
8852 })
8853 .await
8854 .map_err(|_| failure(
8855 NodeFailureCode::BackendOperationFailed,
8856 "standalone workspace root preflight task failed",
8857 ))?
8858 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
8859 {
8860 let workspaces = self
8861 .workspaces
8862 .read()
8863 .unwrap_or_else(|poisoned| poisoned.into_inner());
8864 if workspaces.contains_key(&workspace_id) {
8865 return Err(failure(
8866 NodeFailureCode::DuplicateWorkspaceId,
8867 "workspace ID is already registered",
8868 ));
8869 }
8870 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
8871 platform::roots_equal(existing_root, &canonical_candidate)
8872 }) {
8873 return Err(failure(
8874 NodeFailureCode::DuplicateWorkspaceRoot,
8875 &format!("workspace root is already registered as '{existing_id}'"),
8876 ));
8877 }
8878 }
8879 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
8880 .await
8881 .map_err(standalone_workspace_failure)?;
8882 prepared
8883 .verify_for_registration()
8884 .await
8885 .map_err(standalone_workspace_failure)?;
8886 match self
8887 .register_workspace(workspace_id, prepared.root().to_owned())
8888 .await
8889 {
8890 Ok(workspace) => Ok(workspace),
8891 Err(registration_error) => {
8892 if prepared.compensate_registration_failure().await {
8893 Err(registration_error)
8894 } else {
8895 Err(failure(
8896 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
8897 "standalone workspace initialization succeeded but registration failed; recovery is required",
8898 ))
8899 }
8900 }
8901 }
8902 }
8903
8904 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
8905 let bindings = self
8906 .session_bindings
8907 .lock()
8908 .unwrap_or_else(|poisoned| poisoned.into_inner());
8909 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
8910 return Err(failure(
8911 NodeFailureCode::WorkspaceBusy,
8912 "workspace retains a bound session; remove every session before unregistering it",
8913 ));
8914 }
8915 drop(bindings);
8916 let transaction = self
8917 .state_transaction
8918 .lock()
8919 .unwrap_or_else(|poisoned| poisoned.into_inner());
8920 let mut workspaces = self
8921 .workspaces
8922 .write()
8923 .unwrap_or_else(|poisoned| poisoned.into_inner());
8924 if !workspaces.contains_key(workspace_id) {
8925 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
8926 }
8927 if workspaces.len() == 1 {
8928 return Err(failure(
8929 NodeFailureCode::LastWorkspace,
8930 "node must retain at least one workspace",
8931 ));
8932 }
8933 let removed_root = workspaces
8934 .remove(workspace_id)
8935 .expect("workspace presence was validated");
8936 drop(workspaces);
8937 let previous_records = {
8938 let mut records = self
8939 .session_records
8940 .lock()
8941 .unwrap_or_else(|poisoned| poisoned.into_inner());
8942 let previous = records.records.clone();
8943 for record in records
8944 .records
8945 .values_mut()
8946 .filter(|record| &record.workspace_id == workspace_id)
8947 {
8948 record.active_session = None;
8949 record.state = ManagedSessionState::Unavailable;
8950 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
8951 record.updated_at_unix_ms = unix_time_ms();
8952 }
8953 previous
8954 };
8955 if let Err(error) = self.persist_state_locked() {
8956 self.workspaces
8957 .write()
8958 .unwrap_or_else(|poisoned| poisoned.into_inner())
8959 .insert(workspace_id.clone(), removed_root);
8960 self.session_records
8961 .lock()
8962 .unwrap_or_else(|poisoned| poisoned.into_inner())
8963 .records = previous_records;
8964 return Err(persistence_failure(error));
8965 }
8966 let unavailable = self
8967 .session_records
8968 .lock()
8969 .unwrap_or_else(|poisoned| poisoned.into_inner())
8970 .records
8971 .values()
8972 .filter(|record| &record.workspace_id == workspace_id)
8973 .cloned()
8974 .collect::<Vec<_>>();
8975 drop(transaction);
8976 self.publish(NodeEvent::WorkspaceRemoved {
8977 workspace_id: workspace_id.clone(),
8978 });
8979 for record in unavailable {
8980 self.publish_record(record);
8981 }
8982 Ok(())
8983 }
8984
8985 async fn create_worktree(
8986 &self,
8987 source_workspace_id: WorkspaceId,
8988 workspace_id: WorkspaceId,
8989 target_root: String,
8990 branch: String,
8991 base: Option<String>,
8992 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
8993 self.require_worktree_service(&source_workspace_id)?;
8994 validate_workspace_request_root(&target_root)?;
8995 validate_git_revision("worktree base", base.as_deref())?;
8996 let source_root = self.workspace_root(&source_workspace_id)?;
8997 {
8998 let workspaces = self
8999 .workspaces
9000 .read()
9001 .unwrap_or_else(|poisoned| poisoned.into_inner());
9002 if workspaces.contains_key(&workspace_id) {
9003 return Err(failure(
9004 NodeFailureCode::DuplicateWorkspaceId,
9005 "workspace ID is already registered",
9006 ));
9007 }
9008 if let Some((existing_id, _)) = workspaces
9009 .iter()
9010 .find(|(_, root)| worktree_paths_equal(root, &target_root))
9011 {
9012 return Err(failure(
9013 NodeFailureCode::DuplicateWorkspaceRoot,
9014 &format!("workspace root is already registered as '{existing_id}'"),
9015 ));
9016 }
9017 }
9018 let mut worktree = create_git_worktree(
9019 &source_root,
9020 &target_root,
9021 &branch,
9022 base.as_deref(),
9023 )
9024 .await
9025 .map_err(git_worktree_failure)?;
9026 let workspace = match self
9027 .register_workspace(workspace_id, worktree.path.clone())
9028 .await
9029 {
9030 Ok(workspace) => workspace,
9031 Err(registration_error) => {
9032 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9033 return Err(failure(
9034 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9035 "worktree registration failed and compensation requires recovery",
9036 ));
9037 }
9038 return Err(failure(
9039 registration_error.code,
9040 ®istration_error.message,
9041 ));
9042 }
9043 };
9044 worktree.workspace_id = Some(workspace.workspace_id.clone());
9045 Ok((protocol_worktree(worktree), workspace))
9046 }
9047
9048 async fn remove_worktree(
9049 &self,
9050 source_workspace_id: WorkspaceId,
9051 target_root: String,
9052 ) -> Result<Option<WorkspaceId>, NodeFailure> {
9053 self.require_worktree_service(&source_workspace_id)?;
9054 validate_workspace_request_root(&target_root)?;
9055 let source_root = self.workspace_root(&source_workspace_id)?;
9056 let target_root = normalize_worktree_removal_target(&target_root)
9057 .map_err(git_worktree_failure)?;
9058 let target = list_git_worktrees(&source_root)
9059 .await
9060 .map_err(git_worktree_failure)?
9061 .into_iter()
9062 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9063 .ok_or_else(|| {
9064 failure(
9065 NodeFailureCode::WorktreeProtected,
9066 "refusing to remove a path that is not in Git's worktree listing",
9067 )
9068 })?;
9069 let registered_workspace_id = {
9070 let workspaces = self
9071 .workspaces
9072 .read()
9073 .unwrap_or_else(|poisoned| poisoned.into_inner());
9074 let matched = workspaces
9075 .iter()
9076 .find(|(_, root)| worktree_paths_equal(root, &target.path))
9077 .map(|(workspace_id, _)| workspace_id.clone());
9078 if matched.is_some() && workspaces.len() == 1 {
9079 return Err(failure(
9080 NodeFailureCode::LastWorkspace,
9081 "node must retain at least one workspace",
9082 ));
9083 }
9084 matched
9085 };
9086 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9087 let bindings = self
9088 .session_bindings
9089 .lock()
9090 .unwrap_or_else(|poisoned| poisoned.into_inner());
9091 if bindings
9092 .values()
9093 .any(|binding| &binding.workspace_id == workspace_id)
9094 {
9095 return Err(failure(
9096 NodeFailureCode::WorkspaceBusy,
9097 "worktree workspace retains a bound session; remove every session first",
9098 ));
9099 }
9100 }
9101 remove_git_worktree(&source_root, &target.path)
9102 .await
9103 .map_err(git_worktree_failure)?;
9104 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9105 self.unregister_workspace(workspace_id)
9106 .map_err(|error| failure(error.code, &error.message))?;
9107 }
9108 Ok(registered_workspace_id)
9109 }
9110
9111 async fn cleanup_managed_worktree(
9112 &self,
9113 lease_id: &ManagedWorktreeLeaseId,
9114 explicit: bool,
9115 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9116 let lease = self.managed_worktrees.lock()
9117 .unwrap_or_else(|poisoned| poisoned.into_inner())
9118 .get(lease_id)
9119 .cloned()
9120 .ok_or_else(|| failure(
9121 NodeFailureCode::UnknownManagedWorktreeLease,
9122 "managed worktree lease does not exist",
9123 ))?;
9124 if lease.state == ManagedWorktreeLeaseState::Removed {
9125 return Ok(lease.snapshot());
9126 }
9127 if lease.has_holders() {
9128 return Err(failure(
9129 NodeFailureCode::ManagedWorktreeBusy,
9130 "managed worktree lease still has a session or durable-record holder",
9131 ));
9132 }
9133 if self
9134 .materializations
9135 .lock()
9136 .unwrap_or_else(|poisoned| poisoned.into_inner())
9137 .values()
9138 .any(|record| record.managed_lease_id() == Some(lease_id))
9139 {
9140 return Err(failure(
9141 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9142 "managed worktree cleanup is blocked by session environment ownership",
9143 ));
9144 }
9145 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9146 let snapshot = {
9147 let mut registry = self.managed_worktrees.lock()
9148 .unwrap_or_else(|poisoned| poisoned.into_inner());
9149 let current = registry.get_mut(lease_id)
9150 .expect("managed worktree lease remains present");
9151 current.state = ManagedWorktreeLeaseState::Retained;
9152 current.cleanup_failure = None;
9153 current.updated_at_unix_ms = unix_time_ms();
9154 current.snapshot()
9155 };
9156 if self.persist_state().is_err() {
9157 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9158 }
9159 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9160 return Ok(snapshot);
9161 }
9162 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9163 if profile.revision() != &lease.profile_revision {
9164 self.mark_managed_recovery_required(
9165 lease_id,
9166 ManagedWorktreeCleanupFailure::OwnershipConflict,
9167 );
9168 return Err(failure(
9169 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9170 "managed worktree profile revision changed",
9171 ));
9172 }
9173 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9174 self.mark_managed_recovery_required(
9175 lease_id,
9176 ManagedWorktreeCleanupFailure::OwnershipConflict,
9177 );
9178 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9179 })?;
9180 let source_root = self.workspace_root(&lease.source_workspace_id)?;
9181 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9182 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9183 managed_git_worktree_failure(error)
9184 })?;
9185 let target = listed.iter()
9186 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9187 if let Some(target) = target {
9188 if target.is_main || target.is_bare || !exact_owned_worktree(
9189 &lease,
9190 &target.path,
9191 target.branch.as_deref(),
9192 &target.head,
9193 ) {
9194 self.mark_managed_recovery_required(
9195 lease_id,
9196 ManagedWorktreeCleanupFailure::OwnershipConflict,
9197 );
9198 return Err(failure(
9199 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9200 "Git worktree identity no longer matches the managed lease",
9201 ));
9202 }
9203 if target.prunable {
9204 self.mark_managed_cleanup_blocked(
9205 lease_id,
9206 ManagedWorktreeCleanupFailure::Prunable,
9207 );
9208 return Err(failure(
9209 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9210 "managed worktree is prunable and requires recovery",
9211 ));
9212 }
9213 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9214 let failure_kind = match error.kind {
9215 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9216 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9217 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9218 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9219 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9220 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9221 };
9222 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9223 self.mark_managed_recovery_required(lease_id, failure_kind);
9224 } else {
9225 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9226 }
9227 return Err(managed_git_worktree_failure(error));
9228 }
9229 } else {
9230 match std::fs::symlink_metadata(&lease.target_root) {
9231 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9232 Ok(_) => {
9233 self.mark_managed_recovery_required(
9234 lease_id,
9235 ManagedWorktreeCleanupFailure::OwnershipConflict,
9236 );
9237 return Err(failure(
9238 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9239 "managed target exists but is not owned by Git",
9240 ));
9241 }
9242 Err(_) => {
9243 self.mark_managed_cleanup_blocked(
9244 lease_id,
9245 ManagedWorktreeCleanupFailure::Backend,
9246 );
9247 return Err(failure(
9248 NodeFailureCode::BackendOperationFailed,
9249 "managed target identity could not be inspected",
9250 ));
9251 }
9252 }
9253 }
9254 let registered = self.workspaces.read()
9255 .unwrap_or_else(|poisoned| poisoned.into_inner())
9256 .contains_key(&lease.workspace_id);
9257 if registered {
9258 self.unregister_workspace(&lease.workspace_id)?;
9259 }
9260 let removed = {
9261 self.managed_worktrees.lock()
9262 .unwrap_or_else(|poisoned| poisoned.into_inner())
9263 .tombstone(lease_id, unix_time_ms())
9264 .expect("managed worktree lease remains present during cleanup")
9265 };
9266 self.persist_state().map_err(persistence_failure)?;
9267 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9268 Ok(removed.snapshot())
9269 }
9270
9271 fn mark_managed_cleanup_blocked(
9272 &self,
9273 lease_id: &ManagedWorktreeLeaseId,
9274 failure_kind: ManagedWorktreeCleanupFailure,
9275 ) {
9276 let snapshot = {
9277 let mut registry = self.managed_worktrees.lock()
9278 .unwrap_or_else(|poisoned| poisoned.into_inner());
9279 let Some(lease) = registry.get_mut(lease_id) else { return };
9280 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9281 lease.cleanup_failure = Some(failure_kind);
9282 lease.updated_at_unix_ms = unix_time_ms();
9283 lease.snapshot()
9284 };
9285 let _ = self.persist_state();
9286 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9287 }
9288
9289 fn require_worktree_service(
9290 &self,
9291 source_workspace_id: &WorkspaceId,
9292 ) -> Result<(), NodeFailure> {
9293 if self.managed_worktrees.lock()
9294 .unwrap_or_else(|poisoned| poisoned.into_inner())
9295 .lease_for_workspace(source_workspace_id)
9296 .is_some()
9297 {
9298 return Err(failure(
9299 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9300 "managed worktree targets are internal-only workspaces",
9301 ));
9302 }
9303 match self
9304 .worktree_service_modes
9305 .get(source_workspace_id)
9306 .copied()
9307 .unwrap_or(WorktreeServiceMode::Manual)
9308 {
9309 WorktreeServiceMode::Manual => Ok(()),
9310 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
9311 NodeFailureCode::UnsupportedSpawnCapability,
9312 "Git worktree service mode is unavailable in this node build",
9313 )),
9314 }
9315 }
9316
9317 fn managed_reservation_conflict(
9318 &self,
9319 workspace_id: Option<&WorkspaceId>,
9320 root: Option<&str>,
9321 ) -> bool {
9322 self.managed_worktrees.lock()
9323 .unwrap_or_else(|poisoned| poisoned.into_inner())
9324 .active_records()
9325 .any(|lease| {
9326 workspace_id.is_some_and(|workspace_id| {
9327 workspace_id == &lease.source_workspace_id
9328 || workspace_id == &lease.workspace_id
9329 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
9330 })
9331 }
9332
9333 fn reject_managed_reservation(
9334 &self,
9335 workspace_id: Option<&WorkspaceId>,
9336 root: Option<&str>,
9337 ) -> Result<(), NodeFailure> {
9338 if self.managed_reservation_conflict(workspace_id, root) {
9339 Err(failure(
9340 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9341 "managed worktree ownership may only be mutated through its lease cleanup",
9342 ))
9343 } else {
9344 Ok(())
9345 }
9346 }
9347
9348 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
9349 self.validate_address(address)?;
9350 self.workspace_root(&address.workspace_id)
9351 }
9352
9353 fn require_session_runtime_policy(
9387 &self,
9388 address: &SessionAddress,
9389 requirement: ProviderRuntimeRequirement,
9390 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
9391 let runtime_policy = {
9392 let bindings = self
9393 .session_bindings
9394 .lock()
9395 .unwrap_or_else(|poisoned| poisoned.into_inner());
9396 let binding = bindings
9397 .get(&address.session.instance_id)
9398 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
9399 if binding.workspace_id != address.workspace_id {
9400 return Err(failure(
9401 NodeFailureCode::SessionWorkspaceMismatch,
9402 "session belongs to another workspace",
9403 ));
9404 }
9405 if binding.generation != address.session.generation {
9406 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
9407 }
9408 binding.runtime_policy
9409 };
9410 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
9411 let snapshot = self.internal_session_snapshot(address)?;
9412 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
9413 && snapshot.provider.lead_activity != ProviderActivity::Idle
9414 {
9415 return Err(failure(
9416 NodeFailureCode::TurnInFlight,
9417 "session already has a turn in flight; wait for it to complete before sending another prompt",
9418 ));
9419 }
9420 }
9421 require_policy(runtime_policy, false, false, requirement)
9422 .map(|()| runtime_policy)
9423 .map_err(|_| failure(
9424 NodeFailureCode::UnsupportedCapability,
9425 "session runtime policy does not admit this semantic operation",
9426 ))
9427 }
9428
9429 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
9430 let mut bindings = self
9431 .session_bindings
9432 .lock()
9433 .unwrap_or_else(|poisoned| poisoned.into_inner());
9434 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
9435 binding.workspace_id == address.workspace_id
9436 && binding.generation == address.session.generation
9437 }) {
9438 let removed = bindings.remove(&address.session.instance_id);
9439 #[cfg(feature = "dig2-station-probe")]
9442 {
9443 crate::dig2_station_lease::release_lease_for_holder(
9444 &self.browser_station_leases,
9445 address.session.instance_id,
9446 );
9447 }
9448 self.clear_terminal_frame_watermark(address);
9449 if let Some(control) = self.native_launch_profile_control.as_ref() {
9450 control.clear_native_mcp_server_launch_overlay(
9451 address.session.instance_id,
9452 );
9453 }
9454 if let Some(registry) = self.harness_mcp_registry.as_ref() {
9455 registry.revoke_session(address);
9456 }
9457 if removed
9458 .as_ref()
9459 .is_some_and(|binding| {
9460 binding.environment_profile.is_some()
9461 || binding.bundle.is_some()
9462 || binding.context.is_some()
9463 })
9464 {
9465 if let Some(control) = self.native_launch_profile_control.as_ref() {
9466 control.clear_native_launch_profile_selection(
9467 address.session.instance_id,
9468 );
9469 control.clear_native_instance_launch_overlay(
9470 address.session.instance_id,
9471 );
9472 }
9473 }
9474 return removed;
9475 }
9476 None
9477 }
9478
9479 fn cleanup_session_owned_materialization(
9480 &self,
9481 address: &SessionAddress,
9482 binding: &SessionBinding,
9483 ) -> Result<(), NodeFailure> {
9484 let Some(id) = binding.materialization_id.as_ref() else {
9485 return Ok(());
9486 };
9487 let owner = self
9488 .materializations
9489 .lock()
9490 .unwrap_or_else(|poisoned| poisoned.into_inner())
9491 .get(id)
9492 .map(|record| record.owner().clone());
9493 match owner {
9494 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
9495 Some(MaterializationOwner::Session {
9496 incarnation_id,
9497 instance_id,
9498 generation,
9499 }) if incarnation_id == self.incarnation_id
9500 && instance_id == address.session.instance_id
9501 && generation == address.session.generation =>
9502 {
9503 self.cleanup_materialization(id)
9504 }
9505 Some(MaterializationOwner::Session { .. }) => Err(failure(
9506 NodeFailureCode::BackendOperationFailed,
9507 "session environment ownership requires recovery",
9508 )),
9509 }
9510 }
9511
9512 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
9513 let mut watermarks = self
9514 .terminal_frame_watermarks
9515 .lock()
9516 .unwrap_or_else(|poisoned| poisoned.into_inner());
9517 if watermarks
9518 .get(&address.session.instance_id)
9519 .is_some_and(|(current, _)| current == address)
9520 {
9521 watermarks.remove(&address.session.instance_id);
9522 }
9523 }
9524
9525 #[cfg(test)]
9526 fn address_for(
9527 &self,
9528 instance_id: AgentInstanceId,
9529 generation: SessionGeneration,
9530 ) -> Option<SessionAddress> {
9531 let binding = self.session_bindings
9532 .lock()
9533 .unwrap_or_else(|poisoned| poisoned.into_inner())
9534 .get(&instance_id)
9535 .cloned()?;
9536 (binding.generation == generation).then_some(SessionAddress {
9537 workspace_id: binding.workspace_id,
9538 session: SessionKey { instance_id, generation },
9539 })
9540 }
9541
9542 fn arm_resume(
9543 &self,
9544 address: &SessionAddress,
9545 command_id: CommandId,
9546 runtime_policy: ProviderRuntimePolicy,
9547 ) -> Result<(), NodeFailure> {
9548 let mut bindings = self
9549 .session_bindings
9550 .lock()
9551 .unwrap_or_else(|poisoned| poisoned.into_inner());
9552 let binding = bindings
9553 .get_mut(&address.session.instance_id)
9554 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
9555 if binding.workspace_id != address.workspace_id {
9556 return Err(failure(
9557 NodeFailureCode::SessionWorkspaceMismatch,
9558 "session belongs to another workspace",
9559 ));
9560 }
9561 if binding.generation != address.session.generation {
9562 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
9563 }
9564 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
9565 Ok(())
9566 }
9567
9568 fn clear_armed_resume(&self, address: &SessionAddress) {
9569 let mut bindings = self
9570 .session_bindings
9571 .lock()
9572 .unwrap_or_else(|poisoned| poisoned.into_inner());
9573 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
9574 if binding.workspace_id == address.workspace_id
9575 && binding.generation == address.session.generation
9576 {
9577 binding.pending_resume = None;
9578 }
9579 }
9580 }
9581
9582 fn publish_control(&self, event: ControlEvent) {
9583 let revokes_harness_mcp = matches!(
9584 &event.event,
9585 ControlEventKind::Exited { .. }
9586 | ControlEventKind::Failed { .. }
9587 | ControlEventKind::Removed
9588 );
9589 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
9590 let mut bindings = self
9591 .session_bindings
9592 .lock()
9593 .unwrap_or_else(|poisoned| poisoned.into_inner());
9594 let Some(binding) = bindings.get_mut(&event.instance_id) else {
9595 return;
9596 };
9597 let previous_address = SessionAddress {
9598 workspace_id: binding.workspace_id.clone(),
9599 session: SessionKey {
9600 instance_id: event.instance_id,
9601 generation: binding.generation,
9602 },
9603 };
9604 let mut resume_command_rejected = false;
9605 if binding.generation == event.generation {
9606 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
9607 let rejected_resume = matches!(
9608 &event.event,
9609 ControlEventKind::CommandRejected { .. }
9610 ) && event.command_id == Some(pending_command_id);
9611 let resume_failed = matches!(
9612 &event.event,
9613 ControlEventKind::ResumeDenied { .. }
9614 | ControlEventKind::ResumeFailed { .. }
9615 );
9616 if pending_generation == binding.generation
9617 && (rejected_resume || resume_failed)
9618 {
9619 resume_command_rejected = rejected_resume;
9620 binding.pending_resume = None;
9621 }
9622 }
9623 } else {
9624 let expected = binding.generation.0.checked_add(1);
9625 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
9626 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
9627 generation == binding.generation
9628 })
9629 || expected != Some(event.generation.0)
9630 || !authorized
9631 {
9632 return;
9633 }
9634 let (_, _, runtime_policy) = binding
9635 .pending_resume
9636 .expect("authorized resume retains its pending runtime policy");
9637 binding.generation = event.generation;
9638 binding.runtime_policy = runtime_policy;
9639 binding.pending_resume = None;
9640 }
9641 (
9642 SessionAddress {
9643 workspace_id: binding.workspace_id.clone(),
9644 session: SessionKey {
9645 instance_id: event.instance_id,
9646 generation: event.generation,
9647 },
9648 },
9649 (previous_address.session.generation != event.generation)
9650 .then_some(previous_address),
9651 binding.record_id.clone(),
9652 binding.runtime_policy.provider_session_identity,
9653 resume_command_rejected,
9654 )
9655 };
9656 if let Some(previous_address) = previous_address.as_ref() {
9657 self.clear_terminal_frame_watermark(previous_address);
9658 }
9659 if let Some(record_id) = record_id {
9660 self.reconcile_managed_record(
9661 &record_id,
9662 &address,
9663 &event,
9664 identity_admitted,
9665 resume_command_rejected,
9666 );
9667 }
9668 match &event.event {
9676 ControlEventKind::Running { process_id } => {
9677 tracing::info!(
9678 node_id = %self.node_id,
9679 workspace_id = %address.workspace_id,
9680 session = ?address.session,
9681 pid = ?process_id,
9682 "session process is running",
9683 );
9684 }
9685 ControlEventKind::Exited { exit_code, forced } => {
9686 tracing::info!(
9687 node_id = %self.node_id,
9688 workspace_id = %address.workspace_id,
9689 session = ?address.session,
9690 exit_code = ?exit_code,
9691 forced = forced,
9692 "session process exited",
9693 );
9694 }
9695 ControlEventKind::Failed { message } => {
9696 tracing::warn!(
9697 node_id = %self.node_id,
9698 workspace_id = %address.workspace_id,
9699 session = ?address.session,
9700 cause = %message,
9701 "session failed",
9702 );
9703 }
9704 ControlEventKind::CommandRejected { message } => {
9705 tracing::warn!(
9706 node_id = %self.node_id,
9707 workspace_id = %address.workspace_id,
9708 session = ?address.session,
9709 cause = %message,
9710 "session command rejected",
9711 );
9712 }
9713 ControlEventKind::Removed => {
9714 tracing::info!(
9715 node_id = %self.node_id,
9716 workspace_id = %address.workspace_id,
9717 session = ?address.session,
9718 "session removed",
9719 );
9720 }
9721 ControlEventKind::ResumeAuthorized { session: provider_session } => {
9722 tracing::info!(
9723 node_id = %self.node_id,
9724 workspace_id = %address.workspace_id,
9725 session = ?address.session,
9726 provider_session = ?provider_session,
9727 "session resume authorized",
9728 );
9729 }
9730 ControlEventKind::Resumed { session: provider_session, process_id } => {
9731 tracing::info!(
9732 node_id = %self.node_id,
9733 workspace_id = %address.workspace_id,
9734 session = ?address.session,
9735 provider_session = ?provider_session,
9736 pid = ?process_id,
9737 "session resumed",
9738 );
9739 }
9740 ControlEventKind::ResumeDenied { reason } => {
9741 tracing::warn!(
9742 node_id = %self.node_id,
9743 workspace_id = %address.workspace_id,
9744 session = ?address.session,
9745 cause = %reason,
9746 "session resume denied",
9747 );
9748 }
9749 ControlEventKind::ResumeFailed { 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 resume failed",
9756 );
9757 }
9758 _ => {}
9759 }
9760 let stream_chunk = agent_stream_chunk(&event);
9761 self.publish(NodeEvent::Control {
9762 address: address.clone(),
9763 event,
9764 });
9765 if let Some(chunk) = stream_chunk {
9766 self.publish_agent_stream_chunk(address.clone(), chunk);
9767 }
9768 if revokes_harness_mcp {
9769 if let Some(registry) = self.harness_mcp_registry.as_ref() {
9770 registry.revoke_session(&address);
9771 }
9772 if let Some(control) = self.native_launch_profile_control.as_ref() {
9773 control.clear_native_mcp_server_launch_overlay(
9774 address.session.instance_id,
9775 );
9776 }
9777 }
9778 }
9779
9780 fn reconcile_managed_record(
9781 &self,
9782 record_id: &SessionRecordId,
9783 address: &SessionAddress,
9784 event: &ControlEvent,
9785 identity_admitted: bool,
9786 resume_command_rejected: bool,
9787 ) {
9788 let _transaction = self
9789 .state_transaction
9790 .lock()
9791 .unwrap_or_else(|poisoned| poisoned.into_inner());
9792 let observed_identity = match &event.event {
9793 ControlEventKind::ProviderEvent {
9794 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
9795 ..
9796 } if identity_admitted => Some(identity.clone()),
9797 _ => None,
9798 };
9799 let identity_was_observed = observed_identity.is_some();
9800 let replacement_id = observed_identity
9801 .as_ref()
9802 .and_then(|_| self.allocate_record_id().ok());
9803 let mut upserts = Vec::new();
9804 let mut removals = Vec::new();
9805 let mut rebind = None;
9806 let original_records;
9807 let original_materializations = self
9808 .materializations
9809 .lock()
9810 .unwrap_or_else(|poisoned| poisoned.into_inner())
9811 .clone();
9812 {
9813 let mut records = self
9814 .session_records
9815 .lock()
9816 .unwrap_or_else(|poisoned| poisoned.into_inner());
9817 original_records = records.records.clone();
9818 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
9819 return;
9820 };
9821 if let Some(identity) = observed_identity {
9822 let existing_id = records
9823 .records
9824 .values()
9825 .find(|record| {
9826 record.record_id != *record_id
9827 && record.provider_session.as_ref().is_some_and(|record_identity| {
9828 session_registry::same_provider_session(
9829 &record.provider,
9830 record_identity,
9831 ¤t_snapshot.provider,
9832 &identity,
9833 )
9834 })
9835 })
9836 .map(|record| record.record_id.clone());
9837 if let Some(existing_id) = existing_id {
9838 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
9839 record.mode == current_snapshot.mode
9840 && record.workspace_id == current_snapshot.workspace_id
9841 && record.environment_profile == current_snapshot.environment_profile
9842 && record.bundle == current_snapshot.bundle
9843 && record.context_id == current_snapshot.context_id
9844 && record.context == current_snapshot.context
9845 && record
9846 .canonical_root
9847 .as_utf8()
9848 .zip(current_snapshot.canonical_root.as_utf8())
9849 .is_some_and(|(left, right)| platform::roots_equal(left, right))
9850 });
9851 if !scope_matches {
9852 if let Some(current) = records.records.get_mut(record_id) {
9853 current.state = ManagedSessionState::Unavailable;
9854 current.last_error =
9855 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
9856 current.updated_at_unix_ms = unix_time_ms();
9857 upserts.push(current.clone());
9858 }
9859 } else {
9860 let existing_is_active = records
9861 .records
9862 .get(&existing_id)
9863 .is_some_and(|record| record.active_session.is_some());
9864 if existing_is_active {
9865 if let Some(current) = records.records.get_mut(record_id) {
9866 current.state = ManagedSessionState::Unavailable;
9867 current.last_error =
9868 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
9869 current.updated_at_unix_ms = unix_time_ms();
9870 upserts.push(current.clone());
9871 }
9872 } else {
9873 if current_snapshot.provider_session.is_some() {
9874 if let Some(current) = records.records.get_mut(record_id) {
9875 current.active_session = None;
9876 current.state = ManagedSessionState::Dormant;
9877 current.updated_at_unix_ms = unix_time_ms();
9878 upserts.push(current.clone());
9879 }
9880 } else {
9881 records.records.remove(record_id);
9882 removals.push(record_id.clone());
9883 }
9884 if let Some(existing) = records.records.get_mut(&existing_id) {
9885 existing.provider_session = Some(identity.clone());
9886 existing.active_session = Some(address.clone());
9887 existing.state = ManagedSessionState::Live;
9888 existing.updated_at_unix_ms = unix_time_ms();
9889 existing.last_error = None;
9890 upserts.push(existing.clone());
9891 }
9892 rebind = Some(existing_id);
9893 }
9894 }
9895 } else if current_snapshot
9896 .provider_session
9897 .as_ref()
9898 .is_some_and(|current| {
9899 !session_registry::same_provider_session(
9900 ¤t_snapshot.provider,
9901 current,
9902 ¤t_snapshot.provider,
9903 &identity,
9904 )
9905 })
9906 {
9907 if let Some(current) = records.records.get_mut(record_id) {
9908 current.active_session = None;
9909 if current.bundle.is_some() || current.context.is_some() {
9910 let had_bundle = current.bundle.is_some();
9911 current.provider_session = None;
9912 current.bundle = None;
9913 current.context_id = None;
9914 current.context = None;
9915 current.state = ManagedSessionState::Unavailable;
9916 current.last_error = Some(if had_bundle {
9917 "bundle-unavailable".to_owned()
9918 } else {
9919 "context-unavailable".to_owned()
9920 });
9921 } else {
9922 current.state = ManagedSessionState::Dormant;
9923 }
9924 current.updated_at_unix_ms = unix_time_ms();
9925 upserts.push(current.clone());
9926 }
9927 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
9928 if let Some(current) = records.records.get_mut(record_id) {
9929 current.state = ManagedSessionState::Unavailable;
9930 current.last_error =
9931 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
9932 current.updated_at_unix_ms = unix_time_ms();
9933 upserts.push(current.clone());
9934 }
9935 } else if let Some(new_record_id) = replacement_id {
9936 let now = unix_time_ms();
9937 let replacement = ManagedSessionRecord {
9938 record_id: new_record_id.clone(),
9939 display_name: format!(
9940 "{} #{}",
9941 current_snapshot.provider.as_str(),
9942 records
9943 .records
9944 .values()
9945 .filter(|record| {
9946 record.provider == current_snapshot.provider
9947 })
9948 .count()
9949 .saturating_add(1),
9950 ),
9951 provider: current_snapshot.provider.clone(),
9952 mode: current_snapshot.mode,
9953 state: ManagedSessionState::Live,
9954 workspace_id: current_snapshot.workspace_id,
9955 canonical_root: current_snapshot.canonical_root,
9956 provider_session: Some(identity),
9957 active_session: Some(address.clone()),
9958 environment_profile: current_snapshot.environment_profile,
9959 bundle: current_snapshot.bundle,
9960 context_id: current_snapshot.context_id,
9961 context: current_snapshot.context,
9962 exported_context: None,
9968 task_binding: current_snapshot.task_binding,
9969 created_at_unix_ms: now,
9970 updated_at_unix_ms: now,
9971 last_error: None,
9972 };
9973 records
9974 .records
9975 .insert(new_record_id.clone(), replacement.clone());
9976 upserts.push(replacement);
9977 rebind = Some(new_record_id);
9978 } else if let Some(current) = records.records.get_mut(record_id) {
9979 current.state = ManagedSessionState::Unavailable;
9980 current.last_error =
9981 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
9982 upserts.push(current.clone());
9983 }
9984 } else if let Some(current) = records.records.get_mut(record_id) {
9985 current.provider_session = Some(identity);
9986 current.active_session = Some(address.clone());
9987 current.state = ManagedSessionState::Live;
9988 current.updated_at_unix_ms = unix_time_ms();
9989 current.last_error = None;
9990 upserts.push(current.clone());
9991 }
9992 } else if let Some(current) = records.records.get_mut(record_id) {
9993 if resume_command_rejected {
9994 current.active_session = None;
9995 current.state = detached_record_state(current);
9996 current.updated_at_unix_ms = unix_time_ms();
9997 current.last_error =
9998 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
9999 upserts.push(current.clone());
10000 } else {
10001 match &event.event {
10002 ControlEventKind::Running { .. }
10003 | ControlEventKind::ResumeAuthorized { .. }
10004 | ControlEventKind::Resumed { .. } => {
10005 current.active_session = Some(address.clone());
10006 current.state = active_record_state(
10007 identity_admitted,
10008 current.provider_session.is_some(),
10009 );
10010 current.updated_at_unix_ms = unix_time_ms();
10011 current.last_error = None;
10012 upserts.push(current.clone());
10013 }
10014 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10015 current.active_session = None;
10016 current.state = detached_record_state(current);
10017 current.updated_at_unix_ms = unix_time_ms();
10018 upserts.push(current.clone());
10019 }
10020 ControlEventKind::Failed { message }
10021 | ControlEventKind::ResumeFailed { message } => {
10022 current.active_session = None;
10023 current.state = detached_record_state(current);
10024 current.updated_at_unix_ms = unix_time_ms();
10025 current.last_error = Some(
10026 session_registry::sanitized_record_error_summary(message),
10027 );
10028 upserts.push(current.clone());
10029 }
10030 ControlEventKind::ResumeDenied { reason } => {
10031 current.active_session = None;
10032 current.state = detached_record_state(current);
10033 current.updated_at_unix_ms = unix_time_ms();
10034 current.last_error = Some(
10035 session_registry::sanitized_record_error_summary(reason),
10036 );
10037 upserts.push(current.clone());
10038 }
10039 _ => {}
10040 }
10041 }
10042 }
10043 }
10044 let materialization_owner_record = identity_was_observed.then(|| {
10045 rebind.clone().unwrap_or_else(|| record_id.clone())
10046 }).and_then(|candidate| {
10047 self.session_records
10048 .lock()
10049 .unwrap_or_else(|poisoned| poisoned.into_inner())
10050 .records
10051 .get(&candidate)
10052 .filter(|record| {
10053 record.provider_session.is_some()
10054 && record.active_session.as_ref() == Some(address)
10055 })
10056 .map(|_| candidate)
10057 });
10058 if let Some(owner_record_id) = materialization_owner_record {
10059 let materialization_id = self
10060 .session_bindings
10061 .lock()
10062 .unwrap_or_else(|poisoned| poisoned.into_inner())
10063 .get(&address.session.instance_id)
10064 .and_then(|binding| binding.materialization_id.clone());
10065 if let Some(materialization_id) = materialization_id {
10066 let mut transfer_failed = false;
10067 {
10068 let mut materializations = self
10069 .materializations
10070 .lock()
10071 .unwrap_or_else(|poisoned| poisoned.into_inner());
10072 if let Some(ownership) = materializations.get_mut(&materialization_id) {
10073 match ownership.owner() {
10074 MaterializationOwner::Session {
10075 incarnation_id,
10076 instance_id,
10077 generation,
10078 } if *incarnation_id == self.incarnation_id
10079 && *instance_id == address.session.instance_id
10080 && *generation == address.session.generation =>
10081 {
10082 transfer_failed = ownership
10083 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10084 .is_err();
10085 }
10086 MaterializationOwner::Record { record_id }
10087 if record_id == &owner_record_id => {}
10088 MaterializationOwner::Record { record_id } => {
10089 let expected = record_id.clone();
10090 transfer_failed = ownership
10091 .transfer_record_owner(
10092 &expected,
10093 owner_record_id.clone(),
10094 unix_time_ms(),
10095 )
10096 .is_err();
10097 }
10098 _ => transfer_failed = true,
10099 }
10100 if transfer_failed {
10101 let _ = ownership.mark_recovery_required(unix_time_ms());
10102 }
10103 } else {
10104 transfer_failed = true;
10105 }
10106 }
10107 if transfer_failed {
10108 if let Some(record) = self
10109 .session_records
10110 .lock()
10111 .unwrap_or_else(|poisoned| poisoned.into_inner())
10112 .records
10113 .get_mut(&owner_record_id)
10114 {
10115 record.state = ManagedSessionState::Unavailable;
10116 record.last_error = Some("environment-profile-unavailable".to_owned());
10117 record.updated_at_unix_ms = unix_time_ms();
10118 upserts.push(record.clone());
10119 }
10120 }
10121 }
10122 }
10123 if let Some(new_record_id) = rebind.clone() {
10124 if let Some(binding) = self
10125 .session_bindings
10126 .lock()
10127 .unwrap_or_else(|poisoned| poisoned.into_inner())
10128 .get_mut(&address.session.instance_id)
10129 {
10130 binding.record_id = Some(new_record_id);
10131 }
10132 }
10133 if upserts.is_empty() && removals.is_empty() {
10134 return;
10135 }
10136 if self.persist_state_locked().is_err() {
10137 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10138 let mut records = self
10139 .session_records
10140 .lock()
10141 .unwrap_or_else(|poisoned| poisoned.into_inner());
10142 records.records = original_records;
10143 *self
10144 .materializations
10145 .lock()
10146 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10147 if let Some(current) = records.records.get_mut(record_id) {
10148 current.state = ManagedSessionState::Unavailable;
10149 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10150 current.updated_at_unix_ms = unix_time_ms();
10151 upserts = vec![current.clone()];
10152 } else {
10153 upserts.clear();
10154 }
10155 removals.clear();
10156 drop(records);
10157 if let Some(binding) = self
10158 .session_bindings
10159 .lock()
10160 .unwrap_or_else(|poisoned| poisoned.into_inner())
10161 .get_mut(&address.session.instance_id)
10162 {
10163 binding.record_id = Some(record_id.clone());
10164 }
10165 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10166 }
10167 for record in upserts {
10168 self.publish_record(record);
10169 }
10170 for record_id in removals {
10171 self.publish(NodeEvent::SessionRecordRemoved { record_id });
10172 }
10173 }
10174
10175 fn snapshot(&self) -> NodeSnapshot {
10176 let control = self.handle.snapshot();
10177 let bindings = self
10178 .session_bindings
10179 .lock()
10180 .unwrap_or_else(|poisoned| poisoned.into_inner())
10181 .clone();
10182 let workspace_roots = self
10183 .workspaces
10184 .read()
10185 .unwrap_or_else(|poisoned| poisoned.into_inner())
10186 .clone();
10187 let environment_profiles = self
10188 .environment_profiles
10189 .read()
10190 .unwrap_or_else(|poisoned| poisoned.into_inner());
10191 let mut workspaces = workspace_roots
10192 .iter()
10193 .map(|(workspace_id, canonical_root)| {
10194 (
10195 workspace_id.clone(),
10196 WorkspaceSnapshot {
10197 workspace_id: workspace_id.clone(),
10198 canonical_root: opaque_windows_path(canonical_root.clone()),
10199 sessions: Vec::new(),
10200 worktree_service_mode: Some(
10201 self.worktree_service_modes
10202 .get(workspace_id)
10203 .copied()
10204 .unwrap_or(WorktreeServiceMode::Manual),
10205 ),
10206 managed_worktree_profiles: Some(WorktreeProfileInventory {
10207 profiles: self
10208 .managed_worktree_profiles
10209 .get(workspace_id)
10210 .into_iter()
10211 .flat_map(BTreeMap::values)
10212 .map(|profile| ManagedWorktreeProfileSummary {
10213 id: profile.profile_id().clone(),
10214 revision: profile.revision().clone(),
10215 retention: profile.retention(),
10216 })
10217 .collect(),
10218 }),
10219 },
10220 )
10221 })
10222 .collect::<BTreeMap<_, _>>();
10223 let mut agent_progress = Vec::new();
10224 for session in &control.sessions {
10225 let Some(binding) = bindings.get(&session.instance_id) else {
10226 continue;
10227 };
10228 if binding.generation != session.generation {
10229 continue;
10230 }
10231 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10232 let address = SessionAddress {
10233 workspace_id: binding.workspace_id.clone(),
10234 session: SessionKey {
10235 instance_id: session.instance_id,
10236 generation: session.generation,
10237 },
10238 };
10239 if let Some(progress) =
10240 agent_progress_from_provider_snapshot(address, &session.provider)
10241 {
10242 agent_progress.push(progress);
10243 }
10244 workspace.sessions.push(session.clone());
10245 }
10246 }
10247 NodeSnapshot {
10248 node_id: self.node_id.clone(),
10249 enabled_providers: self.enabled_providers.clone(),
10250 provider_runtime_statuses: {
10251 let updates = self
10252 .provider_runtime_status_updates
10253 .read()
10254 .unwrap_or_else(|poisoned| poisoned.into_inner());
10255 ProviderRuntimeStatuses::new(
10256 self.provider_runtime_statuses
10257 .iter()
10258 .filter(|status| !updates.contains_key(status.provider()))
10259 .cloned()
10260 .chain(updates.values().cloned()),
10261 )
10262 .expect("refreshed provider runtime inventory remains bounded and unique")
10263 },
10264 workspaces: workspaces.into_values().collect(),
10265 session_records: self
10266 .session_records
10267 .lock()
10268 .unwrap_or_else(|poisoned| poisoned.into_inner())
10269 .records
10270 .values()
10271 .cloned()
10272 .collect(),
10273 managed_worktrees: self.managed_worktrees
10274 .lock()
10275 .unwrap_or_else(|poisoned| poisoned.into_inner())
10276 .snapshots(),
10277 launch_inventory: Some(LaunchInventory {
10278 spawn_profiles: Some(
10279 self.spawn_profiles
10280 .iter()
10281 .filter_map(|profile| {
10282 resolved_spawn_profile_summary(profile, &environment_profiles)
10283 })
10284 .collect(),
10285 ),
10286 bundles: Some(
10287 self.bundle_catalog
10288 .read()
10289 .unwrap_or_else(|poisoned| poisoned.into_inner())
10290 .iter()
10291 .map(NodeBundle::receipt)
10292 .collect(),
10293 ),
10294 network_allowlists: {
10297 let catalog = self
10298 .network_allowlist_catalog
10299 .read()
10300 .unwrap_or_else(|poisoned| poisoned.into_inner());
10301 if catalog.is_empty() {
10302 None
10303 } else {
10304 Some(catalog.ids().cloned().collect())
10305 }
10306 },
10307 }),
10308 agent_progress,
10309 }
10310 }
10311
10312 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
10313 assert!(
10314 !matches!(
10315 &event,
10316 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
10317 ),
10318 "terminal frame and agent stream events must use their own dedicated live channel",
10319 );
10320 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10321 let sequence = history
10322 .last_sequence
10323 .checked_add(1)
10324 .expect("node event sequence exhausted");
10325 if history.events.len() == NODE_EVENT_HISTORY_MAX {
10326 if let Some(evicted) = history.events.pop_front() {
10327 history.replay_floor_sequence = evicted
10328 .sequence
10329 .checked_add(1)
10330 .expect("node replay floor sequence exhausted");
10331 history.removed_record_providers.remove(&evicted.sequence);
10332 }
10333 }
10334 match &event {
10335 NodeEvent::HarnessMcpReadCall { .. } => {
10336 panic!("transient harness MCP calls must not enter durable event history")
10337 }
10338 NodeEvent::SessionRecordUpserted { record } => {
10339 history
10340 .record_providers
10341 .insert(record.record_id.clone(), record.provider.clone());
10342 }
10343 NodeEvent::SessionRecordRemoved { record_id } => {
10344 if let Some(provider) = history.record_providers.remove(record_id) {
10345 history.removed_record_providers.insert(sequence, provider);
10346 }
10347 }
10348 NodeEvent::ManagedWorktreeUpserted { .. }
10349 | NodeEvent::ManagedWorktreeRemoved { .. }
10350 | NodeEvent::Control { .. }
10351 | NodeEvent::ControllerChanged { .. }
10352 | NodeEvent::SessionRecordHistorySummarized { .. }
10353 | NodeEvent::WorkspaceAdded { .. }
10354 | NodeEvent::WorkspaceRemoved { .. }
10355 | NodeEvent::ResyncRequired { .. }
10356 | NodeEvent::AgentStream { .. }
10357 | NodeEvent::TerminalFrame { .. } => {}
10358 }
10359 let envelope = NodeEventEnvelope { sequence, event };
10360 history.events.push_back(envelope.clone());
10361 history.last_sequence = sequence;
10362 let _ = self.event_tx.send(envelope.clone());
10363 drop(history);
10364 envelope
10365 }
10366
10367 fn publish_transient(&self, event: NodeEvent) -> bool {
10378 assert!(event.requires_harness_mcp_proxy_capability());
10379 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
10380 let envelope = NodeEventEnvelope { sequence: 0, event };
10381 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
10382 Ok(_) => true,
10383 Err(broadcast::error::SendError(undelivered)) => {
10384 if let Some(NodeEventEnvelope {
10385 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
10386 ..
10387 }) = undelivered.first() {
10388 tracing::warn!(
10394 call_id = call_id.as_str(),
10395 content_type = ?request.content_type,
10396 cause = "no-subscribers",
10397 "harness MCP proxy event could not be published",
10398 );
10399 }
10400 false
10401 }
10402 }
10403 }
10404
10405 fn publish_terminal_frames(&self) {
10406 let snapshot = self.handle.snapshot();
10407 let bindings = self
10408 .session_bindings
10409 .lock()
10410 .unwrap_or_else(|poisoned| poisoned.into_inner());
10411 let candidates = snapshot
10412 .sessions
10413 .iter()
10414 .filter_map(|session| {
10415 let binding = bindings.get(&session.instance_id)?;
10416 if binding.generation != session.generation {
10417 return None;
10418 }
10419 let frame = session.terminal_frame.clone()?;
10420 Some((
10421 SessionAddress {
10422 workspace_id: binding.workspace_id.clone(),
10423 session: SessionKey {
10424 instance_id: session.instance_id,
10425 generation: session.generation,
10426 },
10427 },
10428 frame,
10429 ))
10430 })
10431 .collect();
10432 drop(bindings);
10433 self.publish_terminal_frame_candidates(candidates);
10434 }
10435
10436 fn publish_terminal_frame_candidates(
10437 &self,
10438 candidates: Vec<(SessionAddress, TerminalFrame)>,
10439 ) {
10440 let mut watermarks = self
10441 .terminal_frame_watermarks
10442 .lock()
10443 .unwrap_or_else(|poisoned| poisoned.into_inner());
10444 let mut events = Vec::with_capacity(candidates.len());
10445 for (address, frame) in candidates {
10446 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
10447 let advanced = watermarks
10448 .get(&address.session.instance_id)
10449 .map_or(true, |(current_address, sequence)| {
10450 current_address != &address || frame.sequence > *sequence
10451 });
10452 if !advanced {
10453 continue;
10454 }
10455 if is_first_frame {
10456 if self.controller_state().is_none() {
10464 tracing::warn!(
10465 node_id = %self.node_id,
10466 workspace_id = %address.workspace_id,
10467 session = ?address.session,
10468 terminal_size = ?frame.size,
10469 "session producing output with no controller attached",
10470 );
10471 } else {
10472 tracing::info!(
10473 node_id = %self.node_id,
10474 workspace_id = %address.workspace_id,
10475 session = ?address.session,
10476 terminal_size = ?frame.size,
10477 "session produced its first output",
10478 );
10479 }
10480 }
10481 watermarks.insert(
10482 address.session.instance_id,
10483 (address.clone(), frame.sequence),
10484 );
10485 events.push(NodeEvent::TerminalFrame { address, frame });
10486 }
10487 drop(watermarks);
10488 if events.is_empty() {
10489 return;
10490 }
10491 let mut history = self
10492 .history
10493 .lock()
10494 .unwrap_or_else(|poisoned| poisoned.into_inner());
10495 let mut envelopes = Vec::with_capacity(events.len());
10496 for event in events {
10497 let sequence = history
10498 .last_sequence
10499 .checked_add(1)
10500 .expect("node event sequence exhausted");
10501 history.last_sequence = sequence;
10502 envelopes.push(NodeEventEnvelope { sequence, event });
10503 }
10504 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
10505 drop(history);
10506 }
10507
10508 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
10517 let mut history = self
10518 .history
10519 .lock()
10520 .unwrap_or_else(|poisoned| poisoned.into_inner());
10521 let sequence = history
10522 .last_sequence
10523 .checked_add(1)
10524 .expect("node event sequence exhausted");
10525 history.last_sequence = sequence;
10526 drop(history);
10527 tracing::debug!(
10535 source_sequence = chunk.source_sequence,
10536 kind = agent_stream_chunk_kind_label(&chunk.kind),
10537 envelope_sequence = sequence,
10538 "publishing agent stream chunk",
10539 );
10540 let envelope = NodeEventEnvelope {
10541 sequence,
10542 event: NodeEvent::AgentStream { address, chunk },
10543 };
10544 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
10545 }
10546
10547 fn current_sequence(&self) -> u64 {
10548 self.history
10549 .lock()
10550 .unwrap_or_else(|poisoned| poisoned.into_inner())
10551 .last_sequence
10552 }
10553
10554 fn controller_state(&self) -> Option<ControllerState> {
10555 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10556 let lease = controller.as_ref().copied()?;
10557 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
10558 if remaining.is_zero() {
10559 *controller = None;
10560 return None;
10561 }
10562 Some(ControllerState {
10563 connection_id: lease.connection_id,
10564 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
10565 })
10566 }
10567
10568 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
10569 if role != ClientRole::Operator {
10570 tracing::warn!(
10571 node_id = %self.node_id,
10572 connection_id,
10573 "controller lease rejected: observer connections cannot acquire control",
10574 );
10575 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
10576 }
10577 let lease_ms = if lease_ms == 0 {
10578 DEFAULT_CONTROLLER_LEASE_MS
10579 } else {
10580 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
10581 };
10582 let now = Instant::now();
10583 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10584 if let Some(active) = controller.as_ref() {
10585 if active.connection_id != connection_id && active.expires_at > now {
10586 tracing::warn!(
10587 node_id = %self.node_id,
10588 connection_id,
10589 holder_connection_id = active.connection_id,
10590 "controller lease rejected: another operator holds it",
10591 );
10592 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
10593 }
10594 }
10595 let is_new_attach = match controller.as_ref() {
10602 Some(active) => active.connection_id != connection_id,
10603 None => true,
10604 };
10605 *controller = Some(ControllerLease {
10606 connection_id,
10607 expires_at: now + Duration::from_millis(lease_ms),
10608 });
10609 drop(controller);
10610 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
10611 if is_new_attach {
10612 tracing::info!(
10613 node_id = %self.node_id,
10614 connection_id,
10615 lease_ms,
10616 "controller lease acquired",
10617 );
10618 } else {
10619 tracing::debug!(
10620 node_id = %self.node_id,
10621 connection_id,
10622 lease_ms,
10623 "controller lease renewed",
10624 );
10625 }
10626 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
10627 Ok(state)
10628 }
10629
10630 fn release_controller(&self, connection_id: u64) -> bool {
10631 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10632 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
10633 if released {
10634 *controller = None;
10635 }
10636 drop(controller);
10637 if released {
10638 tracing::info!(
10639 node_id = %self.node_id,
10640 connection_id,
10641 "controller lease released",
10642 );
10643 self.publish(NodeEvent::ControllerChanged { controller: None });
10644 }
10645 released
10646 }
10647
10648 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
10649 if role != ClientRole::Operator {
10650 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
10651 }
10652 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10653 let now = Instant::now();
10654 match controller.as_ref().copied() {
10655 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
10656 Some(active) if active.expires_at <= now => {
10657 *controller = None;
10658 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
10659 }
10660 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
10661 }
10662 }
10663
10664 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
10665 if !self
10666 .workspaces
10667 .read()
10668 .unwrap_or_else(|poisoned| poisoned.into_inner())
10669 .contains_key(&address.workspace_id)
10670 {
10671 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10672 }
10673 let binding = self
10674 .session_bindings
10675 .lock()
10676 .unwrap_or_else(|poisoned| poisoned.into_inner())
10677 .get(&address.session.instance_id)
10678 .cloned()
10679 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10680 if binding.workspace_id != address.workspace_id {
10681 return Err(failure(
10682 NodeFailureCode::SessionWorkspaceMismatch,
10683 "session belongs to another workspace",
10684 ));
10685 }
10686 if binding.generation != address.session.generation {
10687 return Err(failure(
10688 NodeFailureCode::StaleGeneration,
10689 &format!(
10690 "stale session generation: expected {}, received {}",
10691 binding.generation.0,
10692 address.session.generation.0,
10693 ),
10694 ));
10695 }
10696 let snapshot = self.handle.snapshot();
10697 let Some(current) = snapshot
10698 .sessions
10699 .iter()
10700 .find(|item| item.instance_id == address.session.instance_id)
10701 else {
10702 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
10703 };
10704 if current.generation != address.session.generation {
10705 return Err(failure(
10706 NodeFailureCode::StaleGeneration,
10707 &format!(
10708 "stale session generation: expected {}, received {}",
10709 current.generation.0,
10710 address.session.generation.0,
10711 ),
10712 ));
10713 }
10714 Ok(current.agent_id.clone())
10715 }
10716
10717 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
10718 self.dispatch_envelope(self.prepare_command(command))
10719 }
10720
10721 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
10722 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
10723 CommandEnvelope {
10724 id: CommandId(id),
10725 command,
10726 }
10727 }
10728
10729 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
10730 let command_id = envelope.id;
10731 self.handle
10732 .dispatch(envelope)
10733 .map_err(|error| match error {
10734 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
10735 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
10736 })?;
10737 Ok(command_id)
10738 }
10739
10740 fn record_rejected_command(
10749 &self,
10750 command_id: CommandId,
10751 message: String,
10752 unsupported_transport: bool,
10753 ) {
10754 let now = Instant::now();
10755 let mut reasons = self.rejected_command_reasons.lock()
10756 .unwrap_or_else(|poisoned| poisoned.into_inner());
10757 reasons.retain(|_, reason| reason.expires_at > now);
10758 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
10759 if let Some(&oldest) = reasons.keys().next() {
10760 reasons.remove(&oldest);
10761 }
10762 }
10763 reasons.insert(
10764 command_id,
10765 RejectedCommandReason {
10766 message,
10767 unsupported_transport,
10768 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
10769 },
10770 );
10771 }
10772
10773 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
10780 let now = Instant::now();
10781 let mut reasons = self.rejected_command_reasons.lock()
10782 .unwrap_or_else(|poisoned| poisoned.into_inner());
10783 match reasons.remove(&command_id) {
10784 Some(reason) if reason.expires_at > now => Some(reason),
10785 _ => None,
10786 }
10787 }
10788
10789 async fn dispatch_bounded(
10790 &self,
10791 command: ControlCommand,
10792 timeout_duration: Duration,
10793 ) -> Result<CommandId, NodeFailure> {
10794 let deadline = Instant::now() + timeout_duration;
10795 loop {
10796 match self.dispatch(command.clone()) {
10797 Ok(command_id) => return Ok(command_id),
10798 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
10799 sleep(Duration::from_millis(2)).await;
10800 }
10801 Err(error) => return Err(error),
10802 }
10803 }
10804 }
10805
10806 fn internal_session_snapshot(
10807 &self,
10808 address: &SessionAddress,
10809 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
10810 self.validate_address(address)?;
10811 self.handle
10812 .snapshot()
10813 .sessions
10814 .clone()
10815 .into_iter()
10816 .find(|session| {
10817 session.instance_id == address.session.instance_id
10818 && session.generation == address.session.generation
10819 })
10820 .ok_or_else(|| {
10821 failure(
10822 NodeFailureCode::UnknownSession,
10823 "session snapshot is unavailable",
10824 )
10825 })
10826 }
10827
10828 fn require_acp_session(
10836 &self,
10837 address: &SessionAddress,
10838 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
10839 let snapshot = self.internal_session_snapshot(address)?;
10840 if snapshot.transport != TransportKind::Acp {
10841 return Err(failure(
10842 NodeFailureCode::UnsupportedTransport,
10843 "this verb requires a session spawned over the ACP transport",
10844 ));
10845 }
10846 Ok(snapshot)
10847 }
10848
10849 async fn dispatch_history_bounded(
10850 &self,
10851 address: &SessionAddress,
10852 command: ControlCommand,
10853 operation: HistoryOperation,
10854 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
10855 self.validate_address(address)?;
10856 let started_after = self.current_sequence();
10857 let command_id = self
10858 .dispatch_bounded(
10859 command,
10860 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
10861 )
10862 .await?;
10863 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
10864 let mut scan_after = started_after;
10865 let mut accepted_after = None;
10866 loop {
10867 let events = self
10868 .history
10869 .lock()
10870 .unwrap_or_else(|poisoned| poisoned.into_inner())
10871 .events
10872 .iter()
10873 .filter(|envelope| envelope.sequence > scan_after)
10874 .cloned()
10875 .collect::<Vec<_>>();
10876 for envelope in events {
10877 scan_after = scan_after.max(envelope.sequence);
10878 let NodeEvent::Control {
10879 address: event_address,
10880 event,
10881 } = envelope.event
10882 else {
10883 continue;
10884 };
10885 if event_address != *address {
10886 continue;
10887 }
10888 if event.command_id == Some(command_id) {
10889 match &event.event {
10890 ControlEventKind::CommandRejected { .. } => {
10891 return Err(failure(
10892 NodeFailureCode::InvalidRequest,
10893 "history request was rejected",
10894 ));
10895 }
10896 ControlEventKind::HistoryRequested {
10897 operation: requested,
10898 ..
10899 } if requested == &operation => {
10900 accepted_after = Some(envelope.sequence);
10901 continue;
10902 }
10903 _ => {}
10904 }
10905 }
10906 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
10907 match (&operation, &event.event) {
10908 (
10909 HistoryOperation::Discover { .. },
10910 ControlEventKind::HistoryDiscovered { .. },
10911 )
10912 | (
10913 HistoryOperation::Load { .. },
10914 ControlEventKind::HistoryLoaded { .. },
10915 ) => return self.internal_session_snapshot(address),
10916 (_, ControlEventKind::HistoryFailed { .. }) => {
10917 return Err(failure(
10918 NodeFailureCode::BackendOperationFailed,
10919 "history operation failed",
10920 ));
10921 }
10922 _ => {}
10923 }
10924 }
10925 }
10926 if Instant::now() >= deadline {
10927 return Err(failure(
10928 NodeFailureCode::BackendBusy,
10929 if accepted_after.is_some() {
10930 "history operation did not settle before the bounded deadline"
10931 } else {
10932 "history operation was not accepted before the bounded deadline"
10933 },
10934 ));
10935 }
10936 sleep(Duration::from_millis(2)).await;
10937 }
10938 }
10939
10940 async fn discover_history(
10941 &self,
10942 address: &SessionAddress,
10943 limit: u16,
10944 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
10945 let query = HistoryQuery {
10946 working_directory: Some(self.workspace_root(&address.workspace_id)?),
10947 limit,
10948 };
10949 query
10950 .validate()
10951 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
10952 let snapshot = self
10953 .dispatch_history_bounded(
10954 address,
10955 ControlCommand::DiscoverHistory {
10956 instance_id: address.session.instance_id,
10957 query: query.clone(),
10958 },
10959 HistoryOperation::Discover { query },
10960 )
10961 .await?;
10962 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
10963 return Err(failure(
10964 NodeFailureCode::BackendOperationFailed,
10965 "history discovery settled into an invalid state",
10966 ));
10967 }
10968 Ok(snapshot.history.candidates)
10969 }
10970
10971 async fn load_history(
10972 &self,
10973 address: &SessionAddress,
10974 candidate_id: String,
10975 ) -> Result<HistorySessionRecord, NodeFailure> {
10976 validate_candidate_id(&candidate_id)
10977 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
10978 let snapshot = self
10979 .dispatch_history_bounded(
10980 address,
10981 ControlCommand::LoadHistory {
10982 instance_id: address.session.instance_id,
10983 candidate_id: candidate_id.clone(),
10984 },
10985 HistoryOperation::Load {
10986 candidate_id: candidate_id.clone(),
10987 },
10988 )
10989 .await?;
10990 if snapshot.history.pending.is_some()
10991 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
10992 {
10993 return Err(failure(
10994 NodeFailureCode::BackendOperationFailed,
10995 "history load settled into an invalid state",
10996 ));
10997 }
10998 snapshot.history.loaded.ok_or_else(|| {
10999 failure(
11000 NodeFailureCode::BackendOperationFailed,
11001 "history load produced no bounded session",
11002 )
11003 })
11004 }
11005
11006 async fn export_context_pack_for_session_record(
11013 &self,
11014 record_id: &SessionRecordId,
11015 session: &SessionAddress,
11016 allow_clean_detachment: bool,
11017 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11018 let outcome = self
11024 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11025 .await;
11026 if let Err(failure) = &outcome {
11027 tracing::warn!(
11028 record = %record_id,
11029 code = ?failure.code,
11030 reason = %failure.message,
11031 "session-record context-pack export refused",
11032 );
11033 }
11034 outcome
11035 }
11036
11037 async fn export_context_pack_for_session_record_inner(
11038 &self,
11039 record_id: &SessionRecordId,
11040 session: &SessionAddress,
11041 allow_clean_detachment: bool,
11042 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11043 let step = |name: &'static str| move |failure: NodeFailure| {
11048 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11049 failure
11050 };
11051 let record = self.record(record_id).map_err(step("record-lookup"))?;
11052 let identity = match record.provider_session.clone() {
11068 Some(identity) => {
11069 if identity.key != ProviderSessionKey::SessionId {
11070 return Err(step("provider-identity-key")(failure(
11071 NodeFailureCode::BackendOperationFailed,
11072 "managed session provider identity cannot bind history exactly",
11073 )));
11074 }
11075 Some(identity)
11076 }
11077 None if record.state == ManagedSessionState::IdentityPending => {
11078 return Err(step("provider-session-identity")(failure(
11079 NodeFailureCode::BackendOperationFailed,
11080 "managed session has not yet observed its provider session identity",
11081 )));
11082 }
11083 None => None,
11084 };
11085 self.revalidate_session_record_context_export(
11086 &record,
11087 identity.as_ref(),
11088 session,
11089 allow_clean_detachment,
11090 ).map_err(step("revalidate-before-discovery"))?;
11091 let candidates = self
11092 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11093 .await
11094 .map_err(step("discover-history"))?;
11095 let candidate_id = select_history_candidate(
11100 &candidates,
11101 identity.as_ref().map(|identity| identity.id.as_str()),
11102 ).map_err(step("unique-candidate"))?;
11103 let loaded = self.load_history(session, candidate_id.clone()).await
11104 .map_err(step("load-history"))?;
11105 if let Some(identity) = identity.as_ref() {
11106 if loaded.session_id != identity.id {
11107 return Err(failure(
11108 NodeFailureCode::SessionRecordConflict,
11109 "loaded history identity does not match the managed session record",
11110 ));
11111 }
11112 }
11113 self.revalidate_session_record_context_export(
11114 &record,
11115 identity.as_ref(),
11116 session,
11117 allow_clean_detachment,
11118 )?;
11119 self.revalidate_loaded_history(session, &candidate_id, &loaded)
11120 .map_err(step("revalidate-loaded-history"))?;
11121 let pack = self.materialize_context_pack(session).await
11122 .map_err(step("materialize-context-pack"))?;
11123 self.commit_context_pack_for_session_record(
11124 &record,
11125 identity.as_ref(),
11126 session,
11127 &candidate_id,
11128 &loaded,
11129 pack,
11130 allow_clean_detachment,
11131 )
11132 }
11133
11134 async fn export_context_pack(
11135 &self,
11136 address: &SessionAddress,
11137 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11138 let pack = self.materialize_context_pack(address).await?;
11139 self.commit_context_pack(pack)
11140 }
11141
11142 async fn materialize_context_pack(
11143 &self,
11144 address: &SessionAddress,
11145 ) -> Result<NodeContextPack, NodeFailure> {
11146 let snapshot = self.internal_session_snapshot(address)?;
11147 if !context_pack_source_status_is_usable(&snapshot.status) {
11148 return Err(failure(
11149 NodeFailureCode::InvalidRequest,
11150 "context export requires a running or successfully exited source session",
11151 ));
11152 }
11153 if snapshot.history.pending.is_some()
11154 || snapshot.history.loaded_candidate_id.is_none()
11155 {
11156 return Err(failure(
11157 NodeFailureCode::InvalidRequest,
11158 "context export requires settled loaded history",
11159 ));
11160 }
11161 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11162 failure(
11163 NodeFailureCode::InvalidRequest,
11164 "context export requires settled loaded history",
11165 )
11166 })?;
11167 let source_provider = snapshot.agent_id.clone();
11168 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11169 let repository = self
11170 .context_pack_repository(&address.workspace_id)
11171 .await?;
11172 let current = self.internal_session_snapshot(address)?;
11173 if !context_pack_source_status_is_usable(¤t.status)
11174 || current.agent_id != source_provider
11175 || current.history.pending.is_some()
11176 || current.history.loaded_candidate_id != loaded_candidate_id
11177 || current.history.loaded.as_ref() != Some(&loaded)
11178 {
11179 return Err(failure(
11180 NodeFailureCode::ContextPackMaterializationFailed,
11181 "context source changed during bounded repository capture",
11182 ));
11183 }
11184 NodeContextPack::export_with_repository(
11185 ContextPackLineageReceipt {
11186 source_node_id: self.node_id.clone(),
11187 source_session: address.clone(),
11188 source_provider,
11189 },
11190 &loaded,
11191 Some(repository),
11192 )
11193 .map_err(|_| {
11194 failure(
11195 NodeFailureCode::ContextPackMaterializationFailed,
11196 "bounded context pack export failed",
11197 )
11198 })
11199 }
11200
11201 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
11207 let mut store = self
11208 .context_pack_store
11209 .lock()
11210 .unwrap_or_else(|poisoned| poisoned.into_inner());
11211 let Some(store) = store.as_mut() else {
11212 return Ok(());
11213 };
11214 store.commit(pack).map_err(|_| {
11215 failure(
11216 NodeFailureCode::ContextPackMaterializationFailed,
11217 "durable context pack commit failed",
11218 )
11219 })
11220 }
11221
11222 fn commit_context_pack(
11223 &self,
11224 pack: NodeContextPack,
11225 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11226 self.commit_context_pack_durably(&pack)?;
11227 self.context_catalog
11228 .write()
11229 .unwrap_or_else(|poisoned| poisoned.into_inner())
11230 .insert(pack)
11231 .map_err(|_| {
11232 failure(
11233 NodeFailureCode::ContextPackMaterializationFailed,
11234 "context pack catalog is full",
11235 )
11236 })
11237 }
11238
11239 fn commit_context_pack_for_session_record(
11240 &self,
11241 expected: &ManagedSessionRecord,
11242 identity: Option<&ProviderSessionIdentity>,
11243 session: &SessionAddress,
11244 candidate_id: &str,
11245 loaded: &HistorySessionRecord,
11246 pack: NodeContextPack,
11247 allow_clean_detachment: bool,
11248 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11249 let current_root = self.workspace_root(&expected.workspace_id)?;
11250 let _transaction = self
11251 .state_transaction
11252 .lock()
11253 .unwrap_or_else(|poisoned| poisoned.into_inner());
11254 let records = self
11255 .session_records
11256 .lock()
11257 .unwrap_or_else(|poisoned| poisoned.into_inner());
11258 let current = records.records.get(&expected.record_id).ok_or_else(|| {
11259 failure(
11260 NodeFailureCode::SessionRecordConflict,
11261 "managed session record disappeared during context export",
11262 )
11263 })?;
11264 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
11265 return Err(failure(
11266 NodeFailureCode::SessionRecordConflict,
11267 "managed session record changed during context export",
11268 ));
11269 }
11270 let bindings = self
11271 .session_bindings
11272 .lock()
11273 .unwrap_or_else(|poisoned| poisoned.into_inner());
11274 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
11275 failure(
11276 NodeFailureCode::SessionRecordConflict,
11277 "managed session runtime binding disappeared during context export",
11278 )
11279 })?;
11280 let runtime = self
11281 .handle
11282 .snapshot()
11283 .sessions
11284 .iter()
11285 .find(|snapshot| {
11286 snapshot.instance_id == session.session.instance_id
11287 && snapshot.generation == session.session.generation
11288 })
11289 .cloned()
11290 .ok_or_else(|| {
11291 failure(
11292 NodeFailureCode::SessionRecordConflict,
11293 "managed session runtime disappeared during context export",
11294 )
11295 })?;
11296 let receipt = pack.receipt();
11297 if !session_record_context_export_binding_is_exact(
11298 expected,
11299 current,
11300 identity,
11301 session,
11302 binding,
11303 &runtime.agent_id,
11304 ¤t_root,
11305 allow_clean_detachment,
11306 ) || !session_record_context_export_source_is_usable(
11307 current,
11308 &runtime.status,
11309 allow_clean_detachment,
11310 ) || runtime.history.pending.is_some()
11311 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
11312 || runtime.history.loaded.as_ref() != Some(loaded)
11313 || receipt.lineage.source_node_id != self.node_id
11314 || receipt.lineage.source_session != *session
11315 || receipt.lineage.source_provider != current.provider
11316 || receipt.source_message_count != loaded.message_count
11317 {
11318 return Err(failure(
11319 NodeFailureCode::SessionRecordConflict,
11320 "managed session source changed before context catalog commit",
11321 ));
11322 }
11323 self.commit_context_pack_durably(&pack)?;
11324 self.context_catalog
11325 .write()
11326 .unwrap_or_else(|poisoned| poisoned.into_inner())
11327 .insert(pack)
11328 .map_err(|_| {
11329 failure(
11330 NodeFailureCode::ContextPackMaterializationFailed,
11331 "context pack catalog is full",
11332 )
11333 })
11334 }
11335
11336 async fn context_pack_repository(
11337 &self,
11338 workspace_id: &WorkspaceId,
11339 ) -> Result<ContextPackRepository, NodeFailure> {
11340 let first = self
11341 .context_pack_repository_observation(workspace_id)
11342 .await?;
11343 let second = self
11344 .context_pack_repository_observation(workspace_id)
11345 .await?;
11346 stable_context_pack_repository(first, second)
11347 }
11348
11349 async fn context_pack_repository_observation(
11350 &self,
11351 workspace_id: &WorkspaceId,
11352 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
11353 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
11354 let head = if git.is_repository {
11355 let root = self.workspace_root(workspace_id)?;
11356 match resolve_base_commit_with_timeout(
11357 &root,
11358 "HEAD",
11359 GIT_COMMAND_TIMEOUT_MS,
11360 )
11361 .await
11362 {
11363 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
11364 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
11365 ContextPackRepositoryHead::Unborn
11366 }
11367 Err(_) => {
11368 return Err(failure(
11369 NodeFailureCode::ContextPackMaterializationFailed,
11370 "context pack HEAD observation failed",
11371 ));
11372 }
11373 }
11374 } else {
11375 ContextPackRepositoryHead::NotRepository
11376 };
11377 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
11378 for selected_path in CONTEXT_PACK_SELECTED_FILES {
11379 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
11380 failure(
11381 NodeFailureCode::InvalidRepositoryPath,
11382 "context pack selected file path is invalid",
11383 )
11384 })?;
11385 match self.read_workspace_file(workspace_id.clone(), path).await {
11386 Ok(WorkspaceFileRead {
11387 content: WorkspaceFileContent::Utf8 { text, byte_len },
11388 ..
11389 }) => files.push(ContextPackRepositoryFileSource::utf8(
11390 selected_path,
11391 text,
11392 byte_len,
11393 )),
11394 Ok(WorkspaceFileRead {
11395 content: WorkspaceFileContent::NonUtf8 { byte_len },
11396 ..
11397 }) => files.push(ContextPackRepositoryFileSource::skipped(
11398 selected_path,
11399 ContextPackSelectedFileSkipReason::NonUtf8,
11400 Some(byte_len),
11401 )),
11402 Ok(WorkspaceFileRead {
11403 content: WorkspaceFileContent::TooLarge { .. },
11404 ..
11405 }) => files.push(ContextPackRepositoryFileSource::skipped(
11406 selected_path,
11407 ContextPackSelectedFileSkipReason::TooLarge,
11408 None,
11409 )),
11410 Err(error) => {
11411 let reason = if matches!(
11412 error.code,
11413 NodeFailureCode::InvalidRepositoryPath
11414 | NodeFailureCode::RepositoryFileNotRegular
11415 | NodeFailureCode::RepositoryPathUnsafe
11416 ) {
11417 ContextPackSelectedFileSkipReason::Unsafe
11418 } else {
11419 ContextPackSelectedFileSkipReason::Unavailable
11420 };
11421 files.push(ContextPackRepositoryFileSource::skipped(
11422 selected_path,
11423 reason,
11424 None,
11425 ));
11426 }
11427 }
11428 }
11429 Ok(ContextPackRepositoryObservation { head, git, files })
11430 }
11431
11432 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
11433 let referenced_by_binding = self
11434 .session_bindings
11435 .lock()
11436 .unwrap_or_else(|poisoned| poisoned.into_inner())
11437 .values()
11438 .any(|binding| {
11439 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
11440 });
11441 let referenced_by_record = self
11442 .session_records
11443 .lock()
11444 .unwrap_or_else(|poisoned| poisoned.into_inner())
11445 .records
11446 .values()
11447 .any(|record| record.context_id.as_ref() == Some(context_id));
11448 let referenced_by_materialization = self
11449 .materializations
11450 .lock()
11451 .unwrap_or_else(|poisoned| poisoned.into_inner())
11452 .values()
11453 .any(|ownership| {
11454 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
11455 });
11456 if referenced_by_binding
11457 || referenced_by_record
11458 || referenced_by_materialization
11459 {
11460 return Err(failure(
11461 NodeFailureCode::ContextPackBusy,
11462 "context pack is still referenced",
11463 ));
11464 }
11465 let mut catalog = self.context_catalog
11466 .write()
11467 .unwrap_or_else(|poisoned| poisoned.into_inner());
11468 if catalog.get(context_id).is_none() {
11469 return Err(failure(
11470 NodeFailureCode::UnknownContextPack,
11471 "context pack is unavailable",
11472 ));
11473 }
11474 self.spawn_idempotency
11475 .lock()
11476 .unwrap_or_else(|poisoned| poisoned.into_inner())
11477 .entries
11478 .retain(|_, entry| !entry.value.references_context(context_id));
11479 let removed = catalog.remove(context_id);
11480 debug_assert!(removed.is_some(), "validated context pack remains present");
11481 Ok(())
11482 }
11483
11484 async fn dispatch_input_bounded(
11485 &self,
11486 address: &SessionAddress,
11487 action: InputAction,
11488 ) -> Result<(), NodeFailure> {
11489 let started_after = self.current_sequence();
11490 let command_id = self
11491 .dispatch_bounded(
11492 ControlCommand::SendInput {
11493 instance_id: address.session.instance_id,
11494 action,
11495 },
11496 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11497 )
11498 .await?;
11499 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
11503 let mut scan_after = started_after;
11504 let mut accepted_after = None;
11505 loop {
11506 let events = self
11507 .history
11508 .lock()
11509 .unwrap_or_else(|poisoned| poisoned.into_inner())
11510 .events
11511 .iter()
11512 .filter(|envelope| envelope.sequence > scan_after)
11513 .cloned()
11514 .collect::<Vec<_>>();
11515 for envelope in events {
11516 scan_after = scan_after.max(envelope.sequence);
11517 let NodeEvent::Control {
11518 address: event_address,
11519 event,
11520 } = envelope.event
11521 else {
11522 continue;
11523 };
11524 if event_address != *address {
11525 continue;
11526 }
11527 if event.command_id == Some(command_id) {
11528 match &event.event {
11529 ControlEventKind::CommandRejected { message } => {
11530 return Err(failure(NodeFailureCode::InvalidRequest, message));
11531 }
11532 ControlEventKind::InputRequested { .. } => {
11533 accepted_after = Some(envelope.sequence);
11534 continue;
11535 }
11536 _ => {}
11537 }
11538 }
11539 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11540 match &event.event {
11541 ControlEventKind::InputCompleted { .. } => return Ok(()),
11542 ControlEventKind::InputFailed { message, .. } => {
11543 return Err(failure(
11544 NodeFailureCode::BackendOperationFailed,
11545 message,
11546 ));
11547 }
11548 _ => {}
11549 }
11550 }
11551 }
11552 if Instant::now() >= deadline {
11553 return Err(failure(
11554 NodeFailureCode::BackendBusy,
11555 if accepted_after.is_some() {
11556 "input was accepted but did not settle before the bounded deadline"
11557 } else {
11558 "input was not accepted before the bounded deadline"
11559 },
11560 ));
11561 }
11562 sleep(Duration::from_millis(2)).await;
11563 }
11564 }
11565
11566 async fn dispatch_resize_bounded(
11567 &self,
11568 address: &SessionAddress,
11569 size: gate4agent_types::TerminalSize,
11570 ) -> Result<(), NodeFailure> {
11571 let started_after = self.current_sequence();
11572 let command_id = self
11573 .dispatch_bounded(
11574 ControlCommand::Resize {
11575 instance_id: address.session.instance_id,
11576 size,
11577 },
11578 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11579 )
11580 .await?;
11581 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11582 let mut scan_after = started_after;
11583 let mut accepted_after = None;
11584 loop {
11585 let events = self
11586 .history
11587 .lock()
11588 .unwrap_or_else(|poisoned| poisoned.into_inner())
11589 .events
11590 .iter()
11591 .filter(|envelope| envelope.sequence > scan_after)
11592 .cloned()
11593 .collect::<Vec<_>>();
11594 for envelope in events {
11595 scan_after = scan_after.max(envelope.sequence);
11596 let NodeEvent::Control {
11597 address: event_address,
11598 event,
11599 } = envelope.event
11600 else {
11601 continue;
11602 };
11603 if event_address != *address {
11604 continue;
11605 }
11606 if event.command_id == Some(command_id) {
11607 match &event.event {
11608 ControlEventKind::CommandRejected { message } => {
11609 return Err(failure(NodeFailureCode::InvalidRequest, message));
11610 }
11611 ControlEventKind::ResizeRequested {
11612 size: requested_size,
11613 ..
11614 } if *requested_size == size => {
11615 accepted_after = Some(envelope.sequence);
11616 continue;
11617 }
11618 _ => {}
11619 }
11620 }
11621 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11622 match &event.event {
11623 ControlEventKind::Resized { size: settled_size }
11624 if *settled_size == size =>
11625 {
11626 return Ok(());
11627 }
11628 ControlEventKind::ResizeFailed { message } => {
11629 return Err(failure(
11630 NodeFailureCode::BackendOperationFailed,
11631 message,
11632 ));
11633 }
11634 _ => {}
11635 }
11636 }
11637 }
11638 if Instant::now() >= deadline {
11639 return Err(failure(
11640 NodeFailureCode::BackendBusy,
11641 if accepted_after.is_some() {
11642 "resize was accepted but did not settle before the bounded deadline"
11643 } else {
11644 "resize was not accepted before the bounded deadline"
11645 },
11646 ));
11647 }
11648 sleep(Duration::from_millis(2)).await;
11649 }
11650 }
11651
11652 async fn spawn_session(
11653 &self,
11654 workspace_id: WorkspaceId,
11655 provider: AgentId,
11656 mode: SessionMode,
11657 terminal_size: gate4agent_types::TerminalSize,
11658 initial_prompt: Option<String>,
11659 ) -> Result<SessionAddress, NodeFailure> {
11660 if self.managed_worktrees.lock()
11661 .unwrap_or_else(|poisoned| poisoned.into_inner())
11662 .lease_for_workspace(&workspace_id)
11663 .is_some()
11664 {
11665 return Err(failure(
11666 NodeFailureCode::ManagedWorktreeOwnershipConflict,
11667 "managed worktree targets may only be spawned through their lease request",
11668 ));
11669 }
11670 self
11671 .spawn_session_with_deadline(
11672 workspace_id,
11673 provider,
11674 mode,
11675 terminal_size,
11676 initial_prompt,
11677 None,
11678 None,
11679 None,
11680 None,
11681 None,
11682 SpawnRecordPolicy::ProviderIdentityOnly,
11683 None,
11684 &[],
11685 None,
11686 ApprovalLevel::default(),
11692 None,
11693 None,
11694 )
11695 .await
11696 .map(|(session, _runtime_policy)| session)
11697 }
11698
11699 async fn spawn_session_with_deadline(
11700 &self,
11701 workspace_id: WorkspaceId,
11702 provider: AgentId,
11703 mode: SessionMode,
11704 terminal_size: gate4agent_types::TerminalSize,
11705 initial_prompt: Option<String>,
11706 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
11707 bundle: Option<ResolvedBundleReceipt>,
11708 context: Option<ResolvedContextPackReceipt>,
11709 managed_authority: Option<ManagedWorktreeLeaseId>,
11710 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
11711 record_policy: SpawnRecordPolicy,
11712 deadline: Option<Instant>,
11713 required_capabilities: &[ProviderRuntimeCapability],
11714 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
11715 approval_level: ApprovalLevel,
11716 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
11717 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
11723 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
11724 let reserved_lease = self.managed_worktrees.lock()
11725 .unwrap_or_else(|poisoned| poisoned.into_inner())
11726 .lease_for_workspace(&workspace_id);
11727 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
11728 (Some(expected), Some(provided)) if expected == provided => {}
11729 (None, None) => {}
11730 _ => {
11731 return Err(failure(
11732 NodeFailureCode::ManagedWorktreeOwnershipConflict,
11733 "spawn lacks exact managed worktree lease authority",
11734 ));
11735 }
11736 }
11737 self.ensure_workspace_environment_profile_compatible(
11741 &workspace_id,
11742 environment_profile.as_ref(),
11743 )?;
11744 let runtime_requirement = match (mode, initial_prompt.is_some()) {
11745 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
11746 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
11747 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
11748 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
11749 };
11750 let transport = match mode {
11756 SessionMode::Pty => TransportKind::Pty,
11757 SessionMode::Inline => TransportKind::Pipe,
11758 SessionMode::Acp => TransportKind::Acp,
11759 };
11760 let pipe_transport = self.provider_supports_pipe_transport(&provider);
11761 let acp_transport = self.provider_supports_acp_transport(&provider);
11762 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
11763 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
11764 NodeFailureCode::UnsupportedSpawnCapability,
11765 "pre-admitted provider runtime no longer satisfies spawn requirements",
11766 ))?;
11767 runtime_policy
11768 } else if let Some(deadline) = deadline {
11769 let remaining = spawn_deadline_remaining(deadline)?;
11770 timeout(
11771 remaining,
11772 self.admit_provider_runtime(&provider, runtime_requirement),
11773 )
11774 .await
11775 .map_err(|_| {
11776 failure(
11777 NodeFailureCode::SpawnDeadlineExceeded,
11778 "provider admission exceeded the spawn deadline",
11779 )
11780 })??
11781 } else {
11782 self.admit_provider_runtime(&provider, runtime_requirement).await?
11783 };
11784 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
11785 let runtime_policy =
11797 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
11798 if required_capabilities
11799 .iter()
11800 .any(|capability| !runtime_policy.admits(*capability))
11801 {
11802 return Err(failure(
11803 NodeFailureCode::UnsupportedSpawnCapability,
11804 "provider runtime does not admit a required spawn capability",
11805 ));
11806 }
11807 if let Some(deadline) = deadline {
11808 spawn_deadline_remaining(deadline)?;
11809 }
11810 self.ensure_binding_capacity()?;
11811 let working_directory = self.workspace_root(&workspace_id)?;
11812 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
11813 let session = SessionKey {
11814 instance_id,
11815 generation: SessionGeneration(1),
11816 };
11817 let address = SessionAddress {
11818 workspace_id: workspace_id.clone(),
11819 session,
11820 };
11821 tracing::info!(
11829 node_id = %self.node_id,
11830 workspace_id = %workspace_id,
11831 provider = %provider,
11832 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
11833 session = ?address.session,
11834 "session spawn request accepted",
11835 );
11836 #[cfg(feature = "dig2-station-probe")]
11841 let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
11842 use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
11843 match acquire_lease(
11844 &self.browser_station_leases,
11845 profile_id,
11846 instance_id,
11847 unix_time_ms(),
11848 ) {
11849 Ok(guard) => Some(guard),
11850 Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
11851 return Err(failure(
11852 NodeFailureCode::BrowserStationProfileBusy,
11853 "dig2browser station profile is already leased by another session",
11854 ));
11855 }
11856 Err(BrowserStationLeaseError::CapacityExceeded) => {
11857 return Err(failure(
11858 NodeFailureCode::BrowserStationProfileBusy,
11859 "dig2browser station profile lease capacity exhausted",
11860 ));
11861 }
11862 Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
11863 return Err(failure(
11864 NodeFailureCode::BrowserStationProfileBusy,
11865 "session already holds a dig2browser station profile lease",
11866 ));
11867 }
11868 }
11869 } else {
11870 None
11871 };
11872 #[cfg(not(feature = "dig2-station-probe"))]
11873 let _ = browser_profile_id;
11874 let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
11875 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != provider
11884 {
11885 return Err(failure(
11886 NodeFailureCode::BindingMismatch,
11887 "harness MCP reservation does not match the exact PTY/ACP provider",
11888 ));
11889 }
11890 prepared.verify_helper_program().map_err(harness_mcp_failure)?;
11891 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
11892 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
11893 })?;
11894 let launch = prepared.launch();
11899 let mut env_entries = vec![
11900 (
11901 OsString::from(launch.endpoint_env.as_str()),
11902 prepared.endpoint().as_os_str().to_os_string(),
11903 ),
11904 (
11905 OsString::from(launch.token_env.as_str()),
11906 prepared.token().expose().into(),
11907 ),
11908 (
11909 OsString::from(launch.program_env.as_str()),
11910 prepared.helper_program().as_os_str().to_os_string(),
11911 ),
11912 ];
11913 if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
11914 env_entries.push(trace_entry);
11915 }
11916 let overlay = NativeMcpServerLaunchOverlay::new(
11917 provider.clone(),
11918 launch.server_name.as_str(),
11919 prepared.helper_program().as_os_str().to_os_string(),
11920 launch.args.iter().map(OsString::from).collect(),
11921 env_entries,
11922 launch.scrub_env.iter().map(OsString::from).collect(),
11923 ).map_err(|_| failure(
11924 NodeFailureCode::HarnessMcpUnavailable,
11925 "harness MCP launch overlay is unavailable",
11926 ))?;
11927 control
11928 .install_native_mcp_server_launch_overlay(instance_id, overlay)
11929 .map_err(|_| failure(
11930 NodeFailureCode::HarnessMcpUnavailable,
11931 "harness MCP launch overlay could not be installed",
11932 ))?;
11933 tracing::info!(
11939 node_id = %self.node_id,
11940 instance_id = ?instance_id,
11941 provider = %provider,
11942 mode = ?mode,
11943 "harness MCP overlay installed for spawn",
11944 );
11945 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
11946 } else {
11947 None
11948 };
11949 let prepared_materialization = self.prepare_session_materialization(
11950 &address,
11951 &provider,
11952 mode,
11953 environment_profile.as_ref(),
11954 bundle.as_ref(),
11955 context.as_ref(),
11956 managed_authority.clone(),
11957 )?;
11958 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
11959 Some((overlay, guard)) => (Some(overlay), Some(guard)),
11960 None => (None, None),
11961 };
11962 let materialization_id = materialization_guard
11963 .as_ref()
11964 .and_then(SessionMaterializationGuard::id)
11965 .cloned();
11966 let mut environment_selection = self.select_environment_profile(
11967 instance_id,
11968 &provider,
11969 mode,
11970 environment_profile.as_ref(),
11971 )?;
11972 let provider_home = materialization_guard
11975 .as_ref()
11976 .and_then(SessionMaterializationGuard::provider_home);
11977 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
11978 &provider,
11979 mode,
11980 approval_level,
11981 network_allowlist.as_ref(),
11982 provider_home,
11983 )?;
11984 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
11985 (prepared, args) if args.is_empty() => prepared,
11986 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
11987 overlay.append_extra_args(args).map_err(|_| {
11988 failure(
11989 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
11990 "network allowlist overlay could not merge with instance launch args",
11991 )
11992 })?;
11993 Some(PreparedNativeLaunchOverlay::Instance(overlay))
11994 }
11995 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
11996 Some(PreparedNativeLaunchOverlay::Instance(
11997 overlay
11998 .into_instance_with_extra_args(args)
11999 .map_err(|_| {
12000 failure(
12001 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
12002 "network allowlist overlay could not merge with environment overlay",
12003 )
12004 })?,
12005 ))
12006 }
12007 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
12008 NativeInstanceLaunchOverlay::new(
12009 provider.clone(),
12010 match mode {
12011 SessionMode::Pty => TransportKind::Pty,
12012 SessionMode::Acp => TransportKind::Acp,
12013 SessionMode::Inline => TransportKind::Pipe,
12014 },
12015 Vec::new(),
12016 args,
12017 )
12018 .map_err(|_| {
12019 failure(
12020 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
12021 "network allowlist overlay is invalid for this transport",
12022 )
12023 })?,
12024 )),
12025 };
12026 let mut instance_overlay = environment_overlay
12027 .map(|overlay| self.install_prepared_launch_overlay(instance_id, overlay))
12028 .transpose()?
12029 .flatten();
12030 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12031 let record_id = self.bind_spawn_session_with_materialization(
12032 &address,
12033 provider.clone(),
12034 mode,
12035 runtime_policy,
12036 record_policy,
12037 environment_profile,
12038 bundle,
12039 context,
12040 materialization_id,
12041 )?;
12042 if let Some(selection) = environment_selection.take() {
12043 selection.retain();
12044 }
12045 if let Some(overlay) = instance_overlay.take() {
12046 overlay.retain();
12047 }
12048 if let Some(guard) = materialization_guard.take() {
12049 guard.retain();
12050 }
12051 let agent_id = provider;
12055 let register_command_id = match self.dispatch_bounded(
12056 ControlCommand::Register {
12057 instance_id,
12058 agent_id,
12059 transport,
12060 },
12061 dispatch_timeout,
12062 )
12063 .await {
12064 Ok(command_id) => command_id,
12065 Err(error) => {
12066 if let Some(binding) = self.remove_binding(&address) {
12067 self.cleanup_session_owned_materialization(&address, &binding)?;
12068 }
12069 if let Some(record_id) = record_id.as_ref() {
12070 self.discard_record(record_id)?;
12071 }
12072 return Err(spawn_dispatch_error(error, deadline));
12073 }
12074 };
12075 let start_timeout = match spawn_dispatch_timeout(deadline) {
12076 Ok(timeout) => timeout,
12077 Err(error) => {
12078 let recovery = self
12079 .rollback_spawn(
12080 &address,
12081 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12082 )
12083 .await;
12084 return match recovery {
12085 Ok(()) => {
12086 if let Some(record_id) = record_id.as_ref() {
12087 self.discard_record(record_id)?;
12088 }
12089 Err(error)
12090 }
12091 Err(recovery_error) => {
12092 if let Some(record_id) = record_id.as_ref() {
12093 self.mark_record_error(
12094 record_id,
12095 "spawn deadline elapsed and registration recovery failed",
12096 )?;
12097 }
12098 Err(recovery_error)
12099 }
12100 };
12101 }
12102 };
12103 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12104 if let Err(recovery_error) = self
12105 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12106 .await
12107 {
12108 return Err(recovery_error);
12109 }
12110 if let Some(record_id) = record_id.as_ref() {
12111 self.discard_record(record_id)?;
12112 }
12113 return Err(start_error);
12114 }
12115 let start_result = self
12116 .dispatch_bounded(
12117 ControlCommand::Start {
12118 instance_id,
12119 runtime_policy,
12120 request: StartRequest {
12121 working_directory,
12122 terminal_size,
12123 initial_prompt,
12124 session_options: None,
12125 approval_level,
12126 },
12127 },
12128 start_timeout,
12129 )
12130 .await;
12131 let start_command_id = match start_result {
12132 Ok(command_id) => command_id,
12133 Err(start_error) => {
12134 let start_error = spawn_dispatch_error(start_error, deadline);
12135 let recovery = self
12136 .rollback_spawn(
12137 &address,
12138 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12139 )
12140 .await;
12141 return match recovery {
12142 Ok(()) => {
12143 if let Some(record_id) = record_id.as_ref() {
12144 self.discard_record(record_id)?;
12145 }
12146 Err(start_error)
12147 }
12148 Err(recovery_error) => {
12149 let diagnostic = format!(
12150 "start failed and registration recovery failed: {}",
12151 recovery_error.message,
12152 );
12153 if let Some(record_id) = record_id.as_ref() {
12154 self.mark_record_error(record_id, &diagnostic)?;
12155 }
12156 Err(failure(recovery_error.code, &diagnostic))
12157 }
12158 };
12159 }
12160 };
12161
12162 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12163 loop {
12164 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12173 .or_else(|| self.take_rejected_command_reason(start_command_id))
12174 {
12175 self.rollback_spawn(
12176 &address,
12177 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12178 ).await.map_err(|error| {
12179 failure(
12180 error.code,
12181 &format!(
12182 "spawn was rejected ({}) and registration recovery failed: {}",
12183 reason.message, error.message
12184 ),
12185 )
12186 })?;
12187 if let Some(record_id) = record_id.as_ref() {
12188 self.discard_record(record_id)?;
12189 }
12190 let code = if reason.unsupported_transport {
12191 NodeFailureCode::UnsupportedTransport
12192 } else {
12193 NodeFailureCode::BackendOperationFailed
12194 };
12195 return Err(failure(code, &reason.message));
12196 }
12197 if let Some(current) = self
12198 .handle
12199 .snapshot()
12200 .sessions
12201 .iter()
12202 .find(|current| current.instance_id == instance_id)
12203 {
12204 if current.generation != session.generation {
12205 let divergence = failure(
12206 NodeFailureCode::StaleGeneration,
12207 "spawn generation diverged",
12208 );
12209 return match self
12210 .rollback_spawn(
12211 &address,
12212 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12213 )
12214 .await
12215 {
12216 Ok(()) => {
12217 if let Some(record_id) = record_id.as_ref() {
12218 self.discard_record(record_id)?;
12219 }
12220 Err(divergence)
12221 }
12222 Err(recovery_error) => {
12223 if let Some(record_id) = record_id.as_ref() {
12224 self.mark_record_error(
12225 record_id,
12226 "spawn generation diverged and recovery failed",
12227 )?;
12228 }
12229 Err(recovery_error)
12230 }
12231 };
12232 }
12233 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12234 if let Some(overlay) = harness_mcp_overlay.take() {
12235 overlay.retain();
12236 }
12237 #[cfg(feature = "dig2-station-probe")]
12238 if let Some(guard) = browser_station_lease_guard.take() {
12239 guard.retain();
12240 }
12241 return Ok((address, runtime_policy));
12242 }
12243 }
12244 if Instant::now() >= commit_deadline {
12245 self.rollback_spawn(
12246 &address,
12247 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12248 ).await.map_err(|error| {
12249 failure(
12250 error.code,
12251 &format!(
12252 "start did not commit and registration recovery failed: {}",
12253 error.message
12254 ),
12255 )
12256 })?;
12257 if let Some(record_id) = record_id.as_ref() {
12258 self.discard_record(record_id)?;
12259 }
12260 return Err(failure(
12261 if deadline.is_some() {
12262 NodeFailureCode::SpawnDeadlineExceeded
12263 } else {
12264 NodeFailureCode::BackendBusy
12265 },
12266 "start did not commit before the bounded deadline; registration was removed",
12267 ));
12268 }
12269 sleep(Duration::from_millis(2)).await;
12270 }
12271 }
12272
12273 fn effective_spawn_runtime_policy(
12274 &self,
12275 runtime_policy: ProviderRuntimePolicy,
12276 ) -> ProviderRuntimePolicy {
12277 #[cfg(feature = "fixture")]
12278 let runtime_policy = if self.fixture_semantic_hook_policy {
12279 ProviderRuntimePolicy::new(true, true, true, true, false, true)
12280 .expect("monitoring Hook fixture policy is internally valid")
12281 } else {
12282 runtime_policy
12283 };
12284 runtime_policy
12285 }
12286
12287 fn verify_harness_mcp_before_start(
12288 &self,
12289 prepared: Option<&PreparedHarnessMcpSpawn>,
12290 ) -> Result<(), NodeFailure> {
12291 prepared
12292 .map(PreparedHarnessMcpSpawn::verify_helper_program)
12293 .transpose()
12294 .map(|_| ())
12295 .map_err(harness_mcp_failure)
12296 }
12297
12298 async fn rollback_spawn(
12299 &self,
12300 address: &SessionAddress,
12301 commit_timeout: Duration,
12302 ) -> Result<(), NodeFailure> {
12303 self.dispatch_bounded(
12304 ControlCommand::Remove {
12305 instance_id: address.session.instance_id,
12306 },
12307 commit_timeout,
12308 )
12309 .await?;
12310 self.wait_until_removed(address, commit_timeout).await?;
12311 if let Some(binding) = self.remove_binding(address) {
12312 self.cleanup_session_owned_materialization(address, &binding)?;
12313 }
12314 Ok(())
12315 }
12316
12317 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12318 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12319 self.dispatch_bounded(
12320 ControlCommand::Remove {
12321 instance_id: address.session.instance_id,
12322 },
12323 commit_timeout,
12324 )
12325 .await?;
12326 self.wait_until_removed(address, commit_timeout).await?;
12327 let removed = self.remove_binding(address);
12328 if let Some(binding) = removed.as_ref() {
12329 self.cleanup_session_owned_materialization(address, binding)?;
12330 }
12331 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12332 let snapshot = self.managed_worktrees.lock()
12333 .unwrap_or_else(|poisoned| poisoned.into_inner())
12334 .release_session(
12335 &lease_id,
12336 self.incarnation_id,
12337 address.session.instance_id,
12338 address.session.generation,
12339 unix_time_ms(),
12340 );
12341 if self.persist_state().is_err() {
12342 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12343 }
12344 if let Some(snapshot) = snapshot {
12345 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12346 }
12347 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12348 }
12349 Ok(())
12350 }
12351
12352 async fn wait_until_removed(
12398 &self,
12399 address: &SessionAddress,
12400 commit_timeout: Duration,
12401 ) -> Result<(), NodeFailure> {
12402 let deadline = Instant::now() + commit_timeout;
12403 let mut next_dispatch = Instant::now();
12404 loop {
12405 let snapshot = self.handle.snapshot();
12406 let status = snapshot
12407 .sessions
12408 .iter()
12409 .find(|session| session.instance_id == address.session.instance_id)
12410 .map(|session| session.status.clone());
12411 let Some(status) = status else {
12412 return Ok(());
12413 };
12414 let now = Instant::now();
12415 if now >= deadline {
12416 return Err(failure(
12417 NodeFailureCode::BackendBusy,
12418 "remove did not commit before the bounded deadline; session binding was retained",
12419 ));
12420 }
12421 if status.allows_remove() && now >= next_dispatch {
12422 let _ = self.dispatch(ControlCommand::Remove {
12423 instance_id: address.session.instance_id,
12424 });
12425 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
12426 }
12427 sleep(Duration::from_millis(2)).await;
12428 }
12429 }
12430
12431 fn resync(&self, after_sequence: u64) -> NodeResponse {
12432 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12433 let event_sequence = history.last_sequence;
12434 let oldest_available_sequence = history.replay_floor_sequence;
12435 let events = history
12436 .events
12437 .iter()
12438 .filter(|event| event.sequence > after_sequence)
12439 .cloned()
12440 .collect();
12441 drop(history);
12442 NodeResponse::Resync {
12443 event_sequence,
12444 oldest_available_sequence,
12445 snapshot: self.snapshot(),
12446 events,
12447 }
12448 }
12449}
12450
12451fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
12456 match kind {
12457 AgentStreamChunkKindV1::Text { .. } => "text",
12458 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
12459 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
12460 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
12461 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
12462 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
12463 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
12464 }
12465}
12466
12467async fn accept_connections(
12481 endpoint: &str,
12482 call_home: Option<std::net::SocketAddr>,
12483 shared: Arc<NodeShared>,
12484) -> Result<(), NodeServerError> {
12485 let mut connections = JoinSet::new();
12486 let result = match call_home {
12487 None => accept_connections_inner(endpoint, shared, &mut connections).await,
12488 Some(relay) => {
12489 let listener =
12490 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
12491 let dialler = call_home_loop(relay, Arc::clone(&shared));
12492 tokio::pin!(listener);
12493 tokio::pin!(dialler);
12494 tokio::select! {
12495 result = &mut listener => result,
12496 result = &mut dialler => result,
12497 }
12498 }
12499 };
12500 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
12501 while !connections.is_empty() {
12502 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
12503 if remaining.is_zero()
12504 || timeout(remaining, connections.join_next()).await.is_err()
12505 {
12506 break;
12507 }
12508 }
12509 connections.abort_all();
12510 while connections.join_next().await.is_some() {}
12511 result
12512}
12513
12514const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
12518
12519async fn call_home_loop(
12533 relay: std::net::SocketAddr,
12534 shared: Arc<NodeShared>,
12535) -> Result<(), NodeServerError> {
12536 let mut failures = 0_usize;
12537 loop {
12538 if shared.shutdown.load(Ordering::Acquire) {
12539 return Ok(());
12540 }
12541 let permit = tokio::select! {
12542 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12543 permit.map_err(|_| NodeServerError::PreauthClosed)?
12544 }
12545 _ = shared.shutdown_notify.notified() => return Ok(()),
12546 };
12547 match dial_relay_and_serve(relay, Arc::clone(&shared), permit).await {
12548 Ok(()) => {
12549 failures = 0;
12553 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed");
12554 }
12555 Err(error) => {
12556 failures = failures.saturating_add(1);
12557 tracing::warn!(
12558 node_id = %shared.node_id,
12559 %relay,
12560 cause = %error,
12561 attempt = failures,
12562 "call-home connection failed",
12563 );
12564 let backoff = CALL_HOME_BACKOFFS_MS
12565 [failures.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)];
12566 tokio::select! {
12567 _ = sleep(Duration::from_millis(backoff)) => {}
12568 _ = shared.shutdown_notify.notified() => return Ok(()),
12569 }
12570 }
12571 }
12572 }
12573}
12574
12575async fn dial_relay_and_serve(
12577 relay: std::net::SocketAddr,
12578 shared: Arc<NodeShared>,
12579 preauth_permit: OwnedSemaphorePermit,
12580) -> Result<(), NodeServerError> {
12581 let mut stream = tokio::net::TcpStream::connect(relay)
12582 .await
12583 .map_err(NodeServerError::CallHomeTransport)?;
12584 let _ = stream.set_nodelay(true);
12588 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
12589 .await
12590 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12591 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
12592 serve_connection(stream, shared, preauth_permit).await
12593}
12594
12595async fn accept_connections_inner(
12596 endpoint: &str,
12597 shared: Arc<NodeShared>,
12598 connections: &mut JoinSet<Result<(), NodeServerError>>,
12599) -> Result<(), NodeServerError> {
12600 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
12601 loop {
12602 while connections.try_join_next().is_some() {}
12603 if shared.shutdown.load(Ordering::Acquire) {
12604 return Ok(());
12605 }
12606 let preauth_permit = tokio::select! {
12607 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12608 permit.map_err(|_| NodeServerError::PreauthClosed)?
12609 }
12610 _ = shared.shutdown_notify.notified() => return Ok(()),
12611 };
12612 let server = tokio::select! {
12613 result = listener.accept() => result?,
12614 _ = shared.shutdown_notify.notified() => return Ok(()),
12615 };
12616 let connection_shared = Arc::clone(&shared);
12617 connections.spawn(async move {
12618 serve_connection(server, connection_shared, preauth_permit).await
12619 });
12620 if shared.shutdown.load(Ordering::Acquire) {
12621 return Ok(());
12622 }
12623 }
12624}
12625
12626async fn serve_connection<S>(
12634 mut pipe: S,
12635 shared: Arc<NodeShared>,
12636 preauth_permit: OwnedSemaphorePermit,
12637) -> Result<(), NodeServerError>
12638where
12639 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
12640{
12641 let hello_frame = timeout(
12642 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
12643 read_json_frame_limited_body_timeout(
12644 &mut pipe,
12645 MAX_NODE_HELLO_FRAME_BYTES,
12646 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12647 ),
12648 )
12649 .await
12650 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
12651 let ClientFrame::Hello(hello) = hello_frame else {
12652 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
12653 };
12654 if hello.build_stamp != BUILD_STAMP {
12655 return Err(NodeServerError::Handshake(format!(
12656 "build stamp mismatch: local={BUILD_STAMP} remote={}",
12657 hello.build_stamp,
12658 )));
12659 }
12660 let compatibility = match hello.compatibility.as_ref() {
12661 Some(offer) => {
12662 let mut selected = node_compatibility_support(&shared)?
12663 .negotiate(offer)
12664 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12665 project_negotiated_provider_ids(&mut selected);
12666 Some(selected)
12667 }
12668 None => None,
12669 };
12670 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
12671 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
12672 (Some(offer), Some(selected)) => negotiated_auth_proof(
12673 shared.access_token.as_bytes(),
12674 AuthDirection::Server,
12675 hello.role,
12676 &hello.client_nonce,
12677 &server_nonce,
12678 offer,
12679 selected,
12680 ),
12681 (None, None) => auth_proof(
12682 shared.access_token.as_bytes(),
12683 AuthDirection::Server,
12684 hello.role,
12685 &hello.client_nonce,
12686 &server_nonce,
12687 ),
12688 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
12689 }
12690 .map_err(NodeServerError::Authentication)?;
12691 write_json_frame_limited(
12692 &mut pipe,
12693 &ServerFrame::Challenge(ServerChallenge {
12694 build_stamp: BUILD_STAMP.to_owned(),
12695 server_nonce,
12696 server_proof,
12697 compatibility: compatibility.clone(),
12698 }),
12699 MAX_NODE_HELLO_FRAME_BYTES,
12700 )
12701 .await?;
12702 let authentication = timeout(
12703 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
12704 read_json_frame_limited_body_timeout(
12705 &mut pipe,
12706 MAX_NODE_HELLO_FRAME_BYTES,
12707 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12708 ),
12709 )
12710 .await
12711 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
12712 let ClientFrame::Authenticate(authentication) = authentication else {
12713 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
12714 };
12715 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
12716 (Some(offer), Some(selected)) => negotiated_auth_proof(
12717 shared.access_token.as_bytes(),
12718 AuthDirection::Client,
12719 hello.role,
12720 &hello.client_nonce,
12721 &server_nonce,
12722 offer,
12723 selected,
12724 ),
12725 (None, None) => auth_proof(
12726 shared.access_token.as_bytes(),
12727 AuthDirection::Client,
12728 hello.role,
12729 &hello.client_nonce,
12730 &server_nonce,
12731 ),
12732 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
12733 }
12734 .map_err(NodeServerError::Authentication)?;
12735 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
12736 return Err(NodeServerError::Handshake("access denied".to_owned()));
12737 }
12738 let selected_capabilities = compatibility
12739 .as_ref()
12740 .map(|selected| selected.capabilities.clone())
12741 .unwrap_or_default();
12742 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
12743 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
12744 });
12745 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
12746 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
12747 });
12748 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
12749 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
12750 });
12751 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
12752 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
12753 });
12754 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
12755 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
12756 });
12757 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
12758 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
12759 }) && selected_capabilities.iter().any(|capability| {
12760 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
12761 });
12762 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
12763 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
12764 });
12765 let include_session_bundles = selected_capabilities.iter().any(|capability| {
12766 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
12767 });
12768 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
12769 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
12770 });
12771 let include_agent_progress = selected_capabilities.iter().any(|capability| {
12772 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
12773 });
12774 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
12775 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
12776 });
12777 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
12778 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
12779 });
12780 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
12781 .try_acquire_owned()
12782 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
12783 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
12784 let _release_guard = ControllerReleaseGuard {
12785 shared: Arc::clone(&shared),
12786 connection_id,
12787 };
12788 drop(preauth_permit);
12789 let _authenticated_permit = authenticated_permit;
12790 let mut event_rx = shared.event_tx.subscribe();
12791 let mut terminal_event_rx = terminal_event_subscription(
12792 &shared,
12793 include_terminal_frame_events,
12794 );
12795 let mut agent_stream_event_rx = agent_stream_event_subscription(
12796 &shared,
12797 include_agent_stream_events,
12798 );
12799 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
12800 &shared,
12801 include_harness_mcp_proxy,
12802 );
12803 write_json_frame(
12804 &mut pipe,
12805 &ServerFrame::Hello(NodeHello {
12806 build_stamp: BUILD_STAMP.to_owned(),
12807 incarnation_id: shared.incarnation_id,
12808 connection_id,
12809 role: hello.role,
12810 event_sequence: shared.current_sequence(),
12811 controller: shared.controller_state(),
12812 snapshot: snapshot_for_wire(
12813 &shared,
12814 include_provider_runtime_status,
12815 include_open_provider_ids,
12816 include_spawn_profiles,
12817 include_managed_worktrees,
12818 include_child_environment_profiles,
12819 include_session_bundles,
12820 include_history_context_packs,
12821 include_agent_progress,
12822 include_session_task_correlation,
12823 ),
12824 compatibility,
12825 }),
12826 )
12827 .await?;
12828 let (mut reader, mut writer) = tokio::io::split(pipe);
12829 let (frame_tx, mut frame_rx) = mpsc::channel(16);
12830 let reader_task = tokio::spawn(async move {
12831 loop {
12832 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
12833 &mut reader,
12834 MAX_NODE_CLIENT_FRAME_BYTES,
12835 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12836 )
12837 .await;
12838 let terminal = frame.is_err();
12839 if frame_tx.send(frame).await.is_err() || terminal {
12840 break;
12841 }
12842 }
12843 });
12844 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
12845 let mut pending_events = Vec::new();
12846 let mut resync_required = false;
12847 let mut discard_events_through = 0_u64;
12848 loop {
12849 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
12854 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
12855 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
12856 for _ in 0..durable_drain_budget {
12857 match event_rx.try_recv() {
12858 Ok(event) => queue_connection_event(
12859 &mut pending_events,
12860 event,
12861 discard_events_through,
12862 ),
12863 Err(broadcast::error::TryRecvError::Empty) => break,
12864 Err(broadcast::error::TryRecvError::Lagged(_)) => {
12865 resync_required = true;
12866 break;
12867 }
12868 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
12869 }
12870 }
12871 if let Some(receiver) = terminal_event_rx.as_mut() {
12872 match receiver.try_recv() {
12873 Ok(events) => queue_connection_event_batch(
12874 &mut pending_events,
12875 &events,
12876 discard_events_through,
12877 ),
12878 Err(broadcast::error::TryRecvError::Empty) => {}
12879 Err(broadcast::error::TryRecvError::Lagged(_)) => {
12880 resync_required = true;
12881 }
12882 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
12883 }
12884 }
12885 if let Some(receiver) = agent_stream_event_rx.as_mut() {
12886 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
12896 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
12897 for _ in 0..agent_stream_drain_budget {
12898 match receiver.try_recv() {
12899 Ok(events) => queue_connection_event_batch(
12900 &mut pending_events,
12901 &events,
12902 discard_events_through,
12903 ),
12904 Err(broadcast::error::TryRecvError::Empty) => break,
12905 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
12906 tracing::warn!(
12907 connection_id,
12908 dropped,
12909 "agent stream event subscription lagged; forcing a resync",
12910 );
12911 resync_required = true;
12912 break;
12913 }
12914 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
12915 }
12916 }
12917 }
12918 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
12919 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
12925 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
12926 for _ in 0..harness_mcp_proxy_drain_budget {
12927 match receiver.try_recv() {
12928 Ok(events) => queue_connection_event_batch(
12929 &mut pending_events,
12930 &events,
12931 discard_events_through,
12932 ),
12933 Err(broadcast::error::TryRecvError::Empty) => break,
12934 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
12935 tracing::warn!(
12936 connection_id,
12937 dropped,
12938 "harness MCP proxy event subscription lagged; forcing a resync",
12939 );
12940 resync_required = true;
12941 break;
12942 }
12943 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
12944 }
12945 }
12946 }
12947 if resync_required {
12948 let event = resync_required_event(&shared);
12949 apply_connection_resync_watermark(
12950 &mut pending_events,
12951 &mut discard_events_through,
12952 event.sequence,
12953 );
12954 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
12955 resync_required = false;
12956 continue;
12957 }
12958 if !pending_events.is_empty() {
12959 pending_events.sort_unstable_by_key(|event| event.sequence);
12960 let burst_len = connection_event_burst_len(pending_events.len());
12961 shared
12962 .connection_events_sent
12963 .fetch_add(burst_len as u64, Ordering::Relaxed);
12964 for event in pending_events.drain(..burst_len) {
12965 let event = if include_managed_worktrees {
12966 Some(event)
12967 } else {
12968 project_event_without_managed_worktrees(event)
12969 };
12970 let event = event.and_then(|event| if include_open_provider_ids {
12971 Some(event)
12972 } else {
12973 project_event_legacy_provider_ids(&shared, event)
12974 });
12975 let event = event.map(|event| if include_child_environment_profiles {
12976 event
12977 } else {
12978 project_event_without_child_environment_profile(event)
12979 });
12980 let event = event.map(|event| if include_session_bundles {
12981 event
12982 } else {
12983 project_event_without_session_bundle(event)
12984 });
12985 let event = event.map(|event| {
12986 project_event_history_for_wire(event, include_history_context_packs)
12987 });
12988 let event = event.map(|event| if include_history_context_packs {
12989 event
12990 } else {
12991 project_event_without_context_pack(event)
12992 });
12993 let event = event.map(|event| if include_session_task_correlation {
12994 event
12995 } else {
12996 project_event_without_session_task_binding(event)
12997 });
12998 let exact_harness_controller = hello.role == ClientRole::Operator
12999 && shared.controller_state().is_some_and(|controller| {
13000 controller.connection_id == connection_id
13001 });
13002 let event = event.and_then(|event| {
13003 if include_harness_mcp_proxy && exact_harness_controller {
13004 Some(event)
13005 } else {
13006 project_event_without_harness_mcp_proxy(event)
13007 }
13008 });
13009 if let Some(event) = event {
13010 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13011 }
13012 }
13013 }
13014 tokio::select! {
13015 biased;
13016 frame = frame_rx.recv() => {
13017 let frame = match frame {
13018 Some(frame) => frame,
13019 None => break,
13020 };
13021 let frame = match frame {
13022 Ok(frame) => frame,
13023 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13024 Err(error) => return Err(error.into()),
13025 };
13026 let ClientFrame::Request(request) = frame else {
13027 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13028 };
13029 #[cfg(feature = "fixture")]
13030 let probe_spawn_managed_worktree_v2 = matches!(
13031 &request.request,
13032 NodeRequest::SpawnManagedWorktreeV2 { .. }
13033 );
13034 let requires_open_provider_ids =
13035 request_requires_open_provider_ids(&shared, &request.request);
13036 let requires_child_environment_profile =
13037 request_requires_child_environment_profile(&shared, &request.request);
13038 let requires_session_bundle =
13039 request_requires_session_bundle(&shared, &request.request);
13040 let requires_history_context_pack =
13041 request_requires_history_context_pack(&shared, &request.request);
13042 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13043 ResponseEnvelope {
13044 request_id: request.request_id,
13045 result: Err(failure(
13046 NodeFailureCode::UnsupportedCapability,
13047 "open provider IDs were not negotiated",
13048 )),
13049 }
13050 } else if requires_child_environment_profile
13051 && !include_child_environment_profiles
13052 {
13053 ResponseEnvelope {
13054 request_id: request.request_id,
13055 result: Err(failure(
13056 NodeFailureCode::UnsupportedCapability,
13057 "child environment profiles were not negotiated",
13058 )),
13059 }
13060 } else if requires_session_bundle && !include_session_bundles {
13061 ResponseEnvelope {
13062 request_id: request.request_id,
13063 result: Err(failure(
13064 NodeFailureCode::UnsupportedCapability,
13065 "session bundles were not negotiated",
13066 )),
13067 }
13068 } else if requires_history_context_pack && !include_history_context_packs {
13069 ResponseEnvelope {
13070 request_id: request.request_id,
13071 result: Err(failure(
13072 NodeFailureCode::UnsupportedCapability,
13073 "history context packs were not negotiated",
13074 )),
13075 }
13076 } else if request_uses_unnegotiated_capability(
13077 &request.request,
13078 &selected_capabilities,
13079 )
13080 {
13081 ResponseEnvelope {
13082 request_id: request.request_id,
13083 result: Err(failure(
13084 NodeFailureCode::UnsupportedCapability,
13085 "request capability was not negotiated",
13086 )),
13087 }
13088 } else {
13089 process_request(&shared, connection_id, hello.role, request).await
13090 };
13091 #[cfg(feature = "fixture")]
13092 if probe_spawn_managed_worktree_v2 {
13093 shared
13094 .fixture_spawn_managed_worktree_v2_failure_probe
13095 .record(reply.result.as_ref().err().map(|failure| failure.code));
13096 }
13097 if !include_provider_runtime_status {
13098 clear_response_provider_runtime_status(&mut reply);
13099 }
13100 if !include_managed_worktrees {
13101 project_response_without_managed_worktrees(&mut reply);
13102 }
13103 if !include_open_provider_ids {
13104 project_response_legacy_provider_ids(&shared, &mut reply);
13105 }
13106 if !include_child_environment_profiles {
13107 project_response_without_child_environment_profile(&mut reply);
13108 }
13109 if !include_session_bundles {
13110 project_response_without_session_bundle(&mut reply);
13111 }
13112 project_response_history_for_wire(
13113 &mut reply,
13114 include_history_context_packs,
13115 );
13116 if !include_history_context_packs {
13117 project_response_without_context_pack(&mut reply);
13118 }
13119 if !include_agent_progress {
13120 project_response_without_agent_progress(&mut reply);
13121 }
13122 if !include_session_task_correlation {
13123 project_response_without_session_task_binding(&mut reply);
13124 }
13125 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13126 }
13127 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13128 event = event_rx.recv() => {
13129 match event {
13130 Ok(event) => queue_connection_event(
13131 &mut pending_events,
13132 event,
13133 discard_events_through,
13134 ),
13135 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13136 Err(broadcast::error::RecvError::Closed) => break,
13137 }
13138 }
13139 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13140 match events {
13141 Ok(events) => queue_connection_event_batch(
13142 &mut pending_events,
13143 &events,
13144 discard_events_through,
13145 ),
13146 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13147 Err(broadcast::error::RecvError::Closed) => break,
13148 }
13149 }
13150 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13151 match events {
13152 Ok(events) => queue_connection_event_batch(
13153 &mut pending_events,
13154 &events,
13155 discard_events_through,
13156 ),
13157 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13158 Err(broadcast::error::RecvError::Closed) => break,
13159 }
13160 }
13161 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13162 match events {
13163 Ok(events) => queue_connection_event_batch(
13164 &mut pending_events,
13165 &events,
13166 discard_events_through,
13167 ),
13168 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13169 Err(broadcast::error::RecvError::Closed) => break,
13170 }
13171 }
13172 _ = shared.shutdown_notify.notified() => break,
13173 }
13174 }
13175 Ok(())
13176}
13177
13178async fn receive_terminal_event_batch(
13179 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13180) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13181 match receiver {
13182 Some(receiver) => receiver.recv().await,
13183 None => std::future::pending().await,
13184 }
13185}
13186
13187fn terminal_event_subscription(
13188 shared: &NodeShared,
13189 enabled: bool,
13190) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13191 enabled.then(|| shared.terminal_event_tx.subscribe())
13192}
13193
13194fn agent_stream_event_subscription(
13198 shared: &NodeShared,
13199 enabled: bool,
13200) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13201 enabled.then(|| shared.agent_stream_event_tx.subscribe())
13202}
13203
13204fn harness_mcp_proxy_event_subscription(
13209 shared: &NodeShared,
13210 enabled: bool,
13211) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13212 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13213}
13214
13215fn queue_connection_event(
13216 pending: &mut Vec<NodeEventEnvelope>,
13217 event: NodeEventEnvelope,
13218 discard_through: u64,
13219) {
13220 if event.sequence <= discard_through
13221 && !event.event.requires_harness_mcp_proxy_capability()
13222 {
13223 return;
13224 }
13225 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13226 if let Some(index) = pending.iter().position(|current| {
13227 matches!(
13228 ¤t.event,
13229 NodeEvent::TerminalFrame {
13230 address: current_address,
13231 ..
13232 } if current_address == address
13233 )
13234 }) {
13235 pending.remove(index);
13236 }
13237 }
13238 pending.push(event);
13239}
13240
13241fn project_event_without_harness_mcp_proxy(
13242 envelope: NodeEventEnvelope,
13243) -> Option<NodeEventEnvelope> {
13244 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13245}
13246
13247fn queue_connection_event_batch(
13248 pending: &mut Vec<NodeEventEnvelope>,
13249 events: &[NodeEventEnvelope],
13250 discard_through: u64,
13251) {
13252 for event in events {
13253 queue_connection_event(pending, event.clone(), discard_through);
13254 }
13255}
13256
13257fn apply_connection_resync_watermark(
13258 pending: &mut Vec<NodeEventEnvelope>,
13259 discard_through: &mut u64,
13260 marker_sequence: u64,
13261) {
13262 *discard_through = (*discard_through).max(marker_sequence);
13263 pending.retain(|event| event.sequence > *discard_through);
13264}
13265
13266fn connection_event_burst_len(pending_len: usize) -> usize {
13267 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13268}
13269
13270fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13271 let history = shared
13272 .history
13273 .lock()
13274 .unwrap_or_else(|poisoned| poisoned.into_inner());
13275 let sequence = history.last_sequence;
13276 let oldest_available_sequence = history.replay_floor_sequence;
13277 NodeEventEnvelope {
13278 sequence,
13279 event: NodeEvent::ResyncRequired {
13280 oldest_available_sequence,
13281 },
13282 }
13283}
13284
13285fn node_compatibility_support(
13286 shared: &NodeShared,
13287) -> Result<NodeCompatibilitySupport, NodeServerError> {
13288 let mut support = node_compatibility_support_for_manifest(
13289 &shared.provider_contracts,
13290 &shared.provider_adapter_contracts,
13291 )?;
13292 if shared
13293 .delivery_store
13294 .lock()
13295 .unwrap_or_else(|poisoned| poisoned.into_inner())
13296 .is_some()
13297 {
13298 support.capabilities.push(
13299 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13300 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13301 );
13302 }
13303 if shared.harness_mcp_registry.is_some() {
13304 support.capabilities.push(
13305 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
13306 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13307 );
13308 }
13309 Ok(support)
13310}
13311
13312fn node_compatibility_support_for_manifest(
13313 provider_contracts: &[ProviderContractSupport],
13314 provider_adapter_contracts: &[ProviderAdapterContractSupport],
13315) -> Result<NodeCompatibilitySupport, NodeServerError> {
13316 Ok(NodeCompatibilitySupport {
13317 build_stamp: BUILD_STAMP.to_owned(),
13318 capabilities: baseline_capabilities()?,
13319 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
13320 path_semantics: platform::path_semantics(),
13321 local_transport: platform::local_transport(),
13322 state_schema: StateSchemaSupport {
13323 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
13324 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13325 },
13326 provider_contracts: provider_contracts.to_vec(),
13327 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
13328 })
13329}
13330
13331fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
13332 let capabilities = [
13333 NODE_COMPATIBILITY_METADATA_CAPABILITY,
13334 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
13335 NODE_REPOSITORY_PATH_CAPABILITY,
13336 NODE_WORKSPACE_FILE_READ_CAPABILITY,
13337 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
13338 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
13339 NODE_GIT_READ_CAPABILITY,
13340 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
13341 NODE_PROVIDER_ID_OPEN_CAPABILITY,
13342 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
13343 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
13344 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
13345 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
13346 NODE_ACP_CONTROL_CAPABILITY,
13347 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
13348 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
13349 NODE_WORKTREE_SELECTION_CAPABILITY,
13350 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
13351 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
13352 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
13353 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
13354 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
13355 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
13356 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
13357 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
13358 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
13359 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
13360 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
13361 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
13362 ]
13363 .into_iter()
13364 .map(|capability| {
13365 CapabilityId::new(capability)
13366 .map_err(|error| NodeServerError::Handshake(error.to_string()))
13367 })
13368 .collect::<Result<Vec<_>, _>>()?;
13369 #[cfg(windows)]
13370 let capabilities = {
13371 let mut capabilities = capabilities;
13372 capabilities.push(
13373 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
13374 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13375 );
13376 capabilities
13377 };
13378 Ok(capabilities)
13379}
13380
13381fn request_uses_unnegotiated_capability(
13382 request: &NodeRequest,
13383 selected_capabilities: &[CapabilityId],
13384) -> bool {
13385 let selected = |required: &str| {
13386 selected_capabilities
13387 .iter()
13388 .any(|capability| capability.as_str() == required)
13389 };
13390 request
13391 .required_capability()
13392 .is_some_and(|required| !selected(required))
13393 || (request.requires_worktree_selection_capability()
13394 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
13395 || (request.requires_spawn_spec_defaults_overrides_capability()
13396 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
13397 || (request.requires_spawn_profile_revision_capability()
13398 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
13399 || (request.requires_child_environment_profile_capability()
13400 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
13401 || (request.requires_session_bundle_materialization_capability()
13402 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
13403 || (request.requires_history_context_pack_capability()
13404 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
13405}
13406
13407fn snapshot_for_wire(
13408 shared: &NodeShared,
13409 include_provider_runtime_status: bool,
13410 include_open_provider_ids: bool,
13411 include_spawn_profiles: bool,
13412 include_managed_worktrees: bool,
13413 include_child_environment_profiles: bool,
13414 include_session_bundles: bool,
13415 include_history_context_packs: bool,
13416 include_agent_progress: bool,
13417 include_session_task_correlation: bool,
13418) -> NodeSnapshot {
13419 let mut snapshot = shared.snapshot();
13420 if !include_provider_runtime_status {
13421 snapshot.provider_runtime_statuses.clear();
13422 }
13423 if !include_open_provider_ids {
13424 project_snapshot_legacy_provider_ids(&mut snapshot);
13425 }
13426 if !include_managed_worktrees {
13427 snapshot.managed_worktrees.clear();
13428 for workspace in &mut snapshot.workspaces {
13429 workspace.managed_worktree_profiles = None;
13430 }
13431 }
13432 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
13433 if !include_spawn_profiles {
13434 inventory.spawn_profiles = None;
13435 }
13436 if !include_session_bundles {
13437 inventory.bundles = None;
13438 }
13439 if inventory.spawn_profiles.is_none()
13440 && inventory.bundles.is_none()
13441 && inventory.network_allowlists.is_none()
13442 {
13443 snapshot.launch_inventory = None;
13444 }
13445 }
13446 if !include_child_environment_profiles {
13447 clear_snapshot_child_environment_profiles(&mut snapshot);
13448 }
13449 if !include_session_bundles {
13450 clear_snapshot_session_bundles(&mut snapshot);
13451 }
13452 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
13453 if !include_history_context_packs {
13454 clear_snapshot_context_packs(&mut snapshot);
13455 }
13456 if !include_agent_progress {
13457 snapshot.agent_progress.clear();
13458 }
13459 if !include_session_task_correlation {
13460 clear_snapshot_session_task_bindings(&mut snapshot);
13461 }
13462 snapshot
13463}
13464
13465fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
13466 for record in &mut snapshot.session_records {
13467 record.task_binding = None;
13468 }
13469}
13470
13471fn project_event_without_session_task_binding(
13472 mut envelope: NodeEventEnvelope,
13473) -> NodeEventEnvelope {
13474 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
13475 record.task_binding = None;
13476 }
13477 envelope
13478}
13479
13480fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
13481 let Ok(response) = reply.result.as_mut() else { return };
13482 match response {
13483 NodeResponse::Snapshot { snapshot, .. } => {
13484 clear_snapshot_session_task_bindings(snapshot);
13485 }
13486 NodeResponse::Resync { snapshot, events, .. } => {
13487 clear_snapshot_session_task_bindings(snapshot);
13488 for event in events {
13489 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
13490 record.task_binding = None;
13491 }
13492 }
13493 }
13494 NodeResponse::SessionRecordUpdated { record }
13495 | NodeResponse::ProviderSessionIndexed { record }
13496 | NodeResponse::NativeSessionIndexed { record, .. }
13497 | NodeResponse::SessionRecordResumed { record, .. } => {
13498 record.task_binding = None;
13499 }
13500 _ => {}
13501 }
13502}
13503
13504fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
13505 let Ok(response) = reply.result.as_mut() else { return };
13506 match response {
13507 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
13508 snapshot.agent_progress.clear();
13509 }
13510 _ => {}
13511 }
13512}
13513
13514fn project_event_without_managed_worktrees(
13515 mut envelope: NodeEventEnvelope,
13516) -> Option<NodeEventEnvelope> {
13517 if matches!(
13518 envelope.event,
13519 NodeEvent::ManagedWorktreeUpserted { .. }
13520 | NodeEvent::ManagedWorktreeRemoved { .. }
13521 ) {
13522 return None;
13523 }
13524 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
13525 workspace.managed_worktree_profiles = None;
13526 }
13527 Some(envelope)
13528}
13529
13530fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
13531 if matches!(
13532 reply.result,
13533 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
13534 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
13535 ) {
13536 reply.result = Err(failure(
13537 NodeFailureCode::UnsupportedCapability,
13538 "managed worktree capability was not negotiated",
13539 ));
13540 return;
13541 }
13542 let Ok(response) = reply.result.as_mut() else { return };
13543 match response {
13544 NodeResponse::Snapshot { snapshot, .. } => {
13545 snapshot.managed_worktrees.clear();
13546 for workspace in &mut snapshot.workspaces {
13547 workspace.managed_worktree_profiles = None;
13548 }
13549 }
13550 NodeResponse::Resync { snapshot, events, .. } => {
13551 snapshot.managed_worktrees.clear();
13552 for workspace in &mut snapshot.workspaces {
13553 workspace.managed_worktree_profiles = None;
13554 }
13555 *events = std::mem::take(events)
13556 .into_iter()
13557 .filter_map(project_event_without_managed_worktrees)
13558 .collect();
13559 }
13560 NodeResponse::WorkspaceRegistered { workspace }
13561 | NodeResponse::StandaloneWorkspaceCreated { workspace }
13562 | NodeResponse::WorktreeCreated { workspace, .. } => {
13563 workspace.managed_worktree_profiles = None;
13564 }
13565 NodeResponse::WorkspaceInspected { inspection } => {
13566 inspection.git.managed_worktree = None;
13567 }
13568 _ => {}
13569 }
13570}
13571
13572fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
13573 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
13574 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13575 });
13576 if includes_open_provider_ids {
13577 return;
13578 }
13579 compatibility
13580 .provider_contracts
13581 .retain(|contract| provider_id_is_legacy(&contract.provider));
13582 compatibility
13583 .provider_adapter_contracts
13584 .retain(|contract| provider_id_is_legacy(&contract.provider));
13585}
13586
13587fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
13588 if let Some(spec) = match request {
13589 NodeRequest::SpawnSpec { spec }
13590 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13591 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13592 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13593 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13594 _ => None,
13595 } {
13596 return shared
13597 .resolve_spawn_spec(spec)
13598 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
13599 }
13600 request_requires_open_provider_ids_with(
13601 request,
13602 |session| shared.validate_address(session).ok(),
13603 |record_id| shared.record(record_id).ok().map(|record| record.provider),
13604 )
13605}
13606
13607fn request_requires_child_environment_profile(
13608 shared: &NodeShared,
13609 request: &NodeRequest,
13610) -> bool {
13611 if let Some(spec) = match request {
13612 NodeRequest::SpawnSpec { spec }
13613 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13614 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13615 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13616 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13617 _ => None,
13618 } {
13619 return shared
13620 .resolve_spawn_spec(spec)
13621 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
13622 }
13623 match request {
13624 NodeRequest::Resume { session, .. }
13625 | NodeRequest::Prompt { session, .. }
13626 | NodeRequest::Paste { session, .. }
13627 | NodeRequest::Input { session, .. }
13628 | NodeRequest::TerminalBytes { session, .. }
13629 | NodeRequest::TerminalControl { session, .. }
13630 | NodeRequest::Resize { session, .. }
13631 | NodeRequest::Interrupt { session }
13632 | NodeRequest::Stop { session, .. }
13633 | NodeRequest::Remove { session }
13634 | NodeRequest::ResolveInteraction { session, .. }
13635 | NodeRequest::SetSessionMode { session, .. }
13636 | NodeRequest::SetSessionConfigOption { session, .. }
13637 | NodeRequest::SetSessionModel { session, .. }
13638 | NodeRequest::DiscoverHistory { session, .. }
13639 | NodeRequest::LoadHistory { session, .. }
13640 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13641 | NodeRequest::ExportContextPack { session } => shared
13642 .session_bindings
13643 .lock()
13644 .unwrap_or_else(|poisoned| poisoned.into_inner())
13645 .get(&session.session.instance_id)
13646 .filter(|binding| {
13647 binding.workspace_id == session.workspace_id
13648 && binding.generation == session.session.generation
13649 })
13650 .map_or(true, |binding| binding.environment_profile.is_some()),
13651 NodeRequest::RenameSessionRecord { record_id, .. }
13652 | NodeRequest::SetSessionTask { record_id, .. }
13653 | NodeRequest::ResumeSessionRecord { record_id, .. }
13654 | NodeRequest::ForgetSessionRecord { record_id } => shared
13655 .record(record_id)
13656 .map_or(true, |record| record.environment_profile.is_some()),
13657 NodeRequest::ArmHarnessMcpReservation { .. }
13658 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13659 | NodeRequest::ActivateHarnessMcpReservation { .. }
13660 | NodeRequest::AbortHarnessMcpReservation { .. }
13661 | NodeRequest::PutHarnessMcpReplyChunk { .. }
13662 | NodeRequest::RejectHarnessMcpCall { .. }
13663 | NodeRequest::Snapshot
13664 | NodeRequest::Resync { .. }
13665 | NodeRequest::BeginDeliveryStage { .. }
13666 | NodeRequest::PutDeliveryBlobChunk { .. }
13667 | NodeRequest::CommitDeliveryStage { .. }
13668 | NodeRequest::AbortDeliveryStage { .. }
13669 | NodeRequest::BrowseHostDirectories { .. }
13670 | NodeRequest::InspectWorkspace { .. }
13671 | NodeRequest::ReadWorkspaceFile { .. }
13672 | NodeRequest::WriteWorkspaceFile { .. }
13673 | NodeRequest::CreateWorkspaceFile { .. }
13674 | NodeRequest::CreateWorkspaceDirectory { .. }
13675 | NodeRequest::ReadGitHistory { .. }
13676 | NodeRequest::ReadGitDiff { .. }
13677 | NodeRequest::AcquireController { .. }
13678 | NodeRequest::ReleaseController
13679 | NodeRequest::Spawn { .. }
13680 | NodeRequest::SpawnSpec { .. }
13681 | NodeRequest::RegisterWorkspace { .. }
13682 | NodeRequest::CreateStandaloneWorkspace { .. }
13683 | NodeRequest::UnregisterWorkspace { .. }
13684 | NodeRequest::CreateWorktree { .. }
13685 | NodeRequest::RemoveWorktree { .. }
13686 | NodeRequest::SpawnManagedWorktree { .. }
13687 | NodeRequest::SpawnManagedWorktreeV2 { .. }
13688 | NodeRequest::CleanupManagedWorktree { .. }
13689 | NodeRequest::IndexProviderSession { .. }
13690 | NodeRequest::IndexNativeSession { .. }
13691 | NodeRequest::CatalogNativeSessions { .. }
13692 | NodeRequest::PageNativeSessions { .. }
13693 | NodeRequest::PreviewNativeSession { .. }
13694 | NodeRequest::PreviewSessionRecord { .. }
13695 | NodeRequest::ForgetContextPack { .. }
13696 | NodeRequest::ResolveDurableContextPack { .. }
13697 | NodeRequest::ReadContextPack { .. }
13698 | NodeRequest::Shutdown => false,
13699 }
13700}
13701
13702fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
13703 if let Some(spec) = match request {
13704 NodeRequest::SpawnSpec { spec }
13705 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13706 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13707 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13708 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13709 _ => None,
13710 } {
13711 return shared
13712 .resolve_spawn_spec(spec)
13713 .is_ok_and(|resolved| resolved.bundle_id.is_some());
13714 }
13715 match request {
13716 NodeRequest::Resume { session, .. }
13717 | NodeRequest::Prompt { session, .. }
13718 | NodeRequest::Paste { session, .. }
13719 | NodeRequest::Input { session, .. }
13720 | NodeRequest::TerminalBytes { session, .. }
13721 | NodeRequest::TerminalControl { session, .. }
13722 | NodeRequest::Resize { session, .. }
13723 | NodeRequest::Interrupt { session }
13724 | NodeRequest::Stop { session, .. }
13725 | NodeRequest::Remove { session }
13726 | NodeRequest::ResolveInteraction { session, .. }
13727 | NodeRequest::SetSessionMode { session, .. }
13728 | NodeRequest::SetSessionConfigOption { session, .. }
13729 | NodeRequest::SetSessionModel { session, .. }
13730 | NodeRequest::DiscoverHistory { session, .. }
13731 | NodeRequest::LoadHistory { session, .. }
13732 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13733 | NodeRequest::ExportContextPack { session } => shared
13734 .session_bindings
13735 .lock()
13736 .unwrap_or_else(|poisoned| poisoned.into_inner())
13737 .get(&session.session.instance_id)
13738 .filter(|binding| {
13739 binding.workspace_id == session.workspace_id
13740 && binding.generation == session.session.generation
13741 })
13742 .map_or(true, |binding| binding.bundle.is_some()),
13743 NodeRequest::RenameSessionRecord { record_id, .. }
13744 | NodeRequest::SetSessionTask { record_id, .. }
13745 | NodeRequest::ResumeSessionRecord { record_id, .. }
13746 | NodeRequest::ForgetSessionRecord { record_id } => shared
13747 .record(record_id)
13748 .map_or(true, |record| record.bundle.is_some()),
13749 NodeRequest::ArmHarnessMcpReservation { .. }
13750 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13751 | NodeRequest::ActivateHarnessMcpReservation { .. }
13752 | NodeRequest::AbortHarnessMcpReservation { .. }
13753 | NodeRequest::PutHarnessMcpReplyChunk { .. }
13754 | NodeRequest::RejectHarnessMcpCall { .. }
13755 | NodeRequest::Snapshot
13756 | NodeRequest::Resync { .. }
13757 | NodeRequest::BeginDeliveryStage { .. }
13758 | NodeRequest::PutDeliveryBlobChunk { .. }
13759 | NodeRequest::CommitDeliveryStage { .. }
13760 | NodeRequest::AbortDeliveryStage { .. }
13761 | NodeRequest::BrowseHostDirectories { .. }
13762 | NodeRequest::InspectWorkspace { .. }
13763 | NodeRequest::ReadWorkspaceFile { .. }
13764 | NodeRequest::WriteWorkspaceFile { .. }
13765 | NodeRequest::CreateWorkspaceFile { .. }
13766 | NodeRequest::CreateWorkspaceDirectory { .. }
13767 | NodeRequest::ReadGitHistory { .. }
13768 | NodeRequest::ReadGitDiff { .. }
13769 | NodeRequest::AcquireController { .. }
13770 | NodeRequest::ReleaseController
13771 | NodeRequest::Spawn { .. }
13772 | NodeRequest::SpawnSpec { .. }
13773 | NodeRequest::RegisterWorkspace { .. }
13774 | NodeRequest::CreateStandaloneWorkspace { .. }
13775 | NodeRequest::UnregisterWorkspace { .. }
13776 | NodeRequest::CreateWorktree { .. }
13777 | NodeRequest::RemoveWorktree { .. }
13778 | NodeRequest::SpawnManagedWorktree { .. }
13779 | NodeRequest::SpawnManagedWorktreeV2 { .. }
13780 | NodeRequest::CleanupManagedWorktree { .. }
13781 | NodeRequest::IndexProviderSession { .. }
13782 | NodeRequest::IndexNativeSession { .. }
13783 | NodeRequest::CatalogNativeSessions { .. }
13784 | NodeRequest::PageNativeSessions { .. }
13785 | NodeRequest::PreviewNativeSession { .. }
13786 | NodeRequest::PreviewSessionRecord { .. }
13787 | NodeRequest::ForgetContextPack { .. }
13788 | NodeRequest::ResolveDurableContextPack { .. }
13789 | NodeRequest::ReadContextPack { .. }
13790 | NodeRequest::Shutdown => false,
13791 }
13792}
13793
13794fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
13795 if request.requires_history_context_pack_capability() {
13796 return true;
13797 }
13798 if let Some(spec) = match request {
13799 NodeRequest::SpawnSpec { spec }
13800 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13801 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13802 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13803 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13804 _ => None,
13805 } {
13806 return shared
13807 .resolve_spawn_spec(spec)
13808 .map_or(true, |resolved| resolved.context_id.is_some());
13809 }
13810 match request {
13811 NodeRequest::Resume { session, .. }
13812 | NodeRequest::Prompt { session, .. }
13813 | NodeRequest::Paste { session, .. }
13814 | NodeRequest::Input { session, .. }
13815 | NodeRequest::TerminalBytes { session, .. }
13816 | NodeRequest::TerminalControl { session, .. }
13817 | NodeRequest::Resize { session, .. }
13818 | NodeRequest::Interrupt { session }
13819 | NodeRequest::Stop { session, .. }
13820 | NodeRequest::Remove { session }
13821 | NodeRequest::ResolveInteraction { session, .. }
13822 | NodeRequest::SetSessionMode { session, .. }
13823 | NodeRequest::SetSessionConfigOption { session, .. }
13824 | NodeRequest::SetSessionModel { session, .. } => shared
13825 .session_bindings
13826 .lock()
13827 .unwrap_or_else(|poisoned| poisoned.into_inner())
13828 .get(&session.session.instance_id)
13829 .filter(|binding| {
13830 binding.workspace_id == session.workspace_id
13831 && binding.generation == session.session.generation
13832 })
13833 .map_or(true, |binding| binding.context.is_some()),
13834 NodeRequest::RenameSessionRecord { record_id, .. }
13835 | NodeRequest::SetSessionTask { record_id, .. }
13836 | NodeRequest::ResumeSessionRecord { record_id, .. }
13837 | NodeRequest::ForgetSessionRecord { record_id } => shared
13838 .record(record_id)
13839 .map_or(true, |record| record.context.is_some()),
13840 NodeRequest::ArmHarnessMcpReservation { .. }
13841 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13842 | NodeRequest::ActivateHarnessMcpReservation { .. }
13843 | NodeRequest::AbortHarnessMcpReservation { .. }
13844 | NodeRequest::PutHarnessMcpReplyChunk { .. }
13845 | NodeRequest::RejectHarnessMcpCall { .. }
13846 | NodeRequest::Snapshot
13847 | NodeRequest::Resync { .. }
13848 | NodeRequest::BeginDeliveryStage { .. }
13849 | NodeRequest::PutDeliveryBlobChunk { .. }
13850 | NodeRequest::CommitDeliveryStage { .. }
13851 | NodeRequest::AbortDeliveryStage { .. }
13852 | NodeRequest::BrowseHostDirectories { .. }
13853 | NodeRequest::InspectWorkspace { .. }
13854 | NodeRequest::ReadWorkspaceFile { .. }
13855 | NodeRequest::WriteWorkspaceFile { .. }
13856 | NodeRequest::CreateWorkspaceFile { .. }
13857 | NodeRequest::CreateWorkspaceDirectory { .. }
13858 | NodeRequest::ReadGitHistory { .. }
13859 | NodeRequest::ReadGitDiff { .. }
13860 | NodeRequest::AcquireController { .. }
13861 | NodeRequest::ReleaseController
13862 | NodeRequest::Spawn { .. }
13863 | NodeRequest::SpawnSpec { .. }
13864 | NodeRequest::RegisterWorkspace { .. }
13865 | NodeRequest::CreateStandaloneWorkspace { .. }
13866 | NodeRequest::UnregisterWorkspace { .. }
13867 | NodeRequest::CreateWorktree { .. }
13868 | NodeRequest::RemoveWorktree { .. }
13869 | NodeRequest::SpawnManagedWorktree { .. }
13870 | NodeRequest::SpawnManagedWorktreeV2 { .. }
13871 | NodeRequest::CleanupManagedWorktree { .. }
13872 | NodeRequest::IndexProviderSession { .. }
13873 | NodeRequest::IndexNativeSession { .. }
13874 | NodeRequest::CatalogNativeSessions { .. }
13875 | NodeRequest::PageNativeSessions { .. }
13876 | NodeRequest::PreviewNativeSession { .. }
13877 | NodeRequest::PreviewSessionRecord { .. }
13878 | NodeRequest::DiscoverHistory { .. }
13879 | NodeRequest::LoadHistory { .. }
13880 | NodeRequest::ExportContextPackForSessionRecord { .. }
13881 | NodeRequest::ExportContextPack { .. }
13882 | NodeRequest::ForgetContextPack { .. }
13883 | NodeRequest::ResolveDurableContextPack { .. }
13884 | NodeRequest::ReadContextPack { .. }
13885 | NodeRequest::Shutdown => false,
13886 }
13887}
13888
13889fn request_requires_open_provider_ids_with(
13890 request: &NodeRequest,
13891 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
13892 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
13893) -> bool {
13894 match request {
13895 NodeRequest::Spawn { provider, .. } => !provider_id_is_legacy(provider),
13896 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
13897 NodeRequest::CatalogNativeSessions { route, .. }
13898 | NodeRequest::PageNativeSessions { route, .. } => {
13899 !provider_id_is_legacy(&route.provider)
13900 }
13901 NodeRequest::PreviewNativeSession { selection, .. }
13902 | NodeRequest::IndexNativeSession { selection, .. } => {
13903 !provider_id_is_legacy(&selection.route.provider)
13904 }
13905 NodeRequest::SpawnSpec { .. }
13906 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
13907 NodeRequest::SpawnManagedWorktree { .. }
13908 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
13909 NodeRequest::Resume { session, .. }
13910 | NodeRequest::Prompt { session, .. }
13911 | NodeRequest::Paste { session, .. }
13912 | NodeRequest::Input { session, .. }
13913 | NodeRequest::TerminalBytes { session, .. }
13914 | NodeRequest::TerminalControl { session, .. }
13915 | NodeRequest::Resize { session, .. }
13916 | NodeRequest::Interrupt { session }
13917 | NodeRequest::Stop { session, .. }
13918 | NodeRequest::Remove { session }
13919 | NodeRequest::ResolveInteraction { session, .. }
13920 | NodeRequest::SetSessionMode { session, .. }
13921 | NodeRequest::SetSessionConfigOption { session, .. }
13922 | NodeRequest::SetSessionModel { session, .. }
13923 | NodeRequest::DiscoverHistory { session, .. }
13924 | NodeRequest::LoadHistory { session, .. }
13925 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13926 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
13927 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
13928 NodeRequest::RenameSessionRecord { record_id, .. }
13929 | NodeRequest::SetSessionTask { record_id, .. }
13930 | NodeRequest::ResumeSessionRecord { record_id, .. }
13931 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
13932 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
13933 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
13934 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
13935 NodeRequest::ArmHarnessMcpReservation { .. }
13936 | NodeRequest::ActivateHarnessMcpReservation { .. }
13937 | NodeRequest::AbortHarnessMcpReservation { .. }
13938 | NodeRequest::PutHarnessMcpReplyChunk { .. }
13939 | NodeRequest::RejectHarnessMcpCall { .. }
13940 | NodeRequest::Snapshot
13941 | NodeRequest::Resync { .. }
13942 | NodeRequest::BeginDeliveryStage { .. }
13943 | NodeRequest::PutDeliveryBlobChunk { .. }
13944 | NodeRequest::CommitDeliveryStage { .. }
13945 | NodeRequest::AbortDeliveryStage { .. }
13946 | NodeRequest::BrowseHostDirectories { .. }
13947 | NodeRequest::InspectWorkspace { .. }
13948 | NodeRequest::ReadWorkspaceFile { .. }
13949 | NodeRequest::WriteWorkspaceFile { .. }
13950 | NodeRequest::CreateWorkspaceFile { .. }
13951 | NodeRequest::CreateWorkspaceDirectory { .. }
13952 | NodeRequest::ReadGitHistory { .. }
13953 | NodeRequest::ReadGitDiff { .. }
13954 | NodeRequest::AcquireController { .. }
13955 | NodeRequest::ReleaseController
13956 | NodeRequest::RegisterWorkspace { .. }
13957 | NodeRequest::CreateStandaloneWorkspace { .. }
13958 | NodeRequest::UnregisterWorkspace { .. }
13959 | NodeRequest::CreateWorktree { .. }
13960 | NodeRequest::RemoveWorktree { .. }
13961 | NodeRequest::CleanupManagedWorktree { .. }
13962 | NodeRequest::Shutdown => false,
13963 NodeRequest::ForgetContextPack { .. } => true,
13964 NodeRequest::ResolveDurableContextPack { .. }
13970 | NodeRequest::ReadContextPack { .. } => true,
13971 }
13972}
13973
13974fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
13975 snapshot.enabled_providers.retain(provider_id_is_legacy);
13976 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
13977 snapshot
13978 .provider_runtime_statuses
13979 .as_slice()
13980 .iter()
13981 .filter(|status| provider_id_is_legacy(status.provider()))
13982 .cloned(),
13983 )
13984 .expect("a filtered provider runtime projection remains valid");
13985 snapshot
13986 .session_records
13987 .retain(|record| provider_id_is_legacy(&record.provider));
13988 for workspace in &mut snapshot.workspaces {
13989 workspace
13990 .sessions
13991 .retain(|session| provider_id_is_legacy(&session.agent_id));
13992 }
13993}
13994
13995fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
13996 for record in &mut snapshot.session_records {
13997 record.environment_profile = None;
13998 }
13999 if let Some(profiles) = snapshot
14000 .launch_inventory
14001 .as_mut()
14002 .and_then(|inventory| inventory.spawn_profiles.as_mut())
14003 {
14004 for profile in profiles {
14005 profile.environment_profile = None;
14006 }
14007 }
14008}
14009
14010fn project_event_without_child_environment_profile(
14011 mut envelope: NodeEventEnvelope,
14012) -> NodeEventEnvelope {
14013 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14014 record.environment_profile = None;
14015 }
14016 envelope
14017}
14018
14019fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14020 let contains_environment_profile = match reply.result.as_ref() {
14021 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14022 receipt.environment_profile.is_some()
14023 }
14024 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14025 receipt.spawn.environment_profile.is_some()
14026 }
14027 _ => false,
14028 };
14029 if contains_environment_profile {
14030 reply.result = Err(failure(
14031 NodeFailureCode::UnsupportedCapability,
14032 "child environment profiles were not negotiated",
14033 ));
14034 return;
14035 }
14036 let Ok(response) = reply.result.as_mut() else {
14037 return;
14038 };
14039 match response {
14040 NodeResponse::Snapshot { snapshot, .. } => {
14041 clear_snapshot_child_environment_profiles(snapshot);
14042 }
14043 NodeResponse::Resync { snapshot, events, .. } => {
14044 clear_snapshot_child_environment_profiles(snapshot);
14045 for event in events {
14046 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14047 record.environment_profile = None;
14048 }
14049 }
14050 }
14051 NodeResponse::SessionRecordUpdated { record }
14052 | NodeResponse::ProviderSessionIndexed { record }
14053 | NodeResponse::NativeSessionIndexed { record, .. }
14054 | NodeResponse::SessionRecordResumed { record, .. } => {
14055 record.environment_profile = None;
14056 }
14057 NodeResponse::Armed { .. }
14058 | NodeResponse::Spawned { .. }
14059 | NodeResponse::Activated { .. }
14060 | NodeResponse::Aborted { .. }
14061 | NodeResponse::ReplyChunkAccepted { .. }
14062 | NodeResponse::CallRejected { .. }
14063 | NodeResponse::DeliveryStageBegun { .. }
14064 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14065 | NodeResponse::DeliveryCommitted { .. }
14066 | NodeResponse::DeliveryStageAborted { .. }
14067 | NodeResponse::WorkspaceInspected { .. }
14068 | NodeResponse::HostDirectoriesBrowsed { .. }
14069 | NodeResponse::WorkspaceFileRead { .. }
14070 | NodeResponse::WorkspaceFileWritten { .. }
14071 | NodeResponse::WorkspaceFileCreated { .. }
14072 | NodeResponse::WorkspaceDirectoryCreated { .. }
14073 | NodeResponse::GitHistoryRead { .. }
14074 | NodeResponse::GitDiffRead { .. }
14075 | NodeResponse::Controller { .. }
14076 | NodeResponse::SpawnAccepted { .. }
14077 | NodeResponse::SpawnSpecAccepted { .. }
14078 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14079 | NodeResponse::ManagedWorktreeCleanup { .. }
14080 | NodeResponse::NativeSessionsCataloged { .. }
14081 | NodeResponse::NativeSessionsPaged { .. }
14082 | NodeResponse::NativeSessionPreviewed { .. }
14083 | NodeResponse::SessionRecordPreviewed { .. }
14084 | NodeResponse::HistoryDiscovered { .. }
14085 | NodeResponse::HistoryLoaded { .. }
14086 | NodeResponse::ContextPackForSessionRecordExported { .. }
14087 | NodeResponse::ContextPackExported { .. }
14088 | NodeResponse::ContextPackForgotten { .. }
14089 | NodeResponse::DurableContextPackResolved { .. }
14090 | NodeResponse::ContextPackBytesRead { .. }
14091 | NodeResponse::SessionRecordForgotten { .. }
14092 | NodeResponse::WorkspaceRegistered { .. }
14093 | NodeResponse::StandaloneWorkspaceCreated { .. }
14094 | NodeResponse::WorkspaceUnregistered { .. }
14095 | NodeResponse::WorktreeCreated { .. }
14096 | NodeResponse::WorktreeRemoved { .. }
14097 | NodeResponse::Accepted
14098 | NodeResponse::ShuttingDown => {}
14099 }
14100}
14101
14102fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14103 for record in &mut snapshot.session_records {
14104 record.bundle = None;
14105 }
14106}
14107
14108fn project_event_without_session_bundle(
14109 mut envelope: NodeEventEnvelope,
14110) -> NodeEventEnvelope {
14111 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14112 record.bundle = None;
14113 }
14114 envelope
14115}
14116
14117fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14118 let contains_bundle = match reply.result.as_ref() {
14119 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14120 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14121 receipt.spawn.bundle.is_some()
14122 }
14123 _ => false,
14124 };
14125 if contains_bundle {
14126 reply.result = Err(failure(
14127 NodeFailureCode::UnsupportedCapability,
14128 "session bundles were not negotiated",
14129 ));
14130 return;
14131 }
14132 let Ok(response) = reply.result.as_mut() else {
14133 return;
14134 };
14135 match response {
14136 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14137 NodeResponse::Resync { snapshot, events, .. } => {
14138 clear_snapshot_session_bundles(snapshot);
14139 for event in events {
14140 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14141 record.bundle = None;
14142 }
14143 }
14144 }
14145 NodeResponse::SessionRecordUpdated { record }
14146 | NodeResponse::ProviderSessionIndexed { record }
14147 | NodeResponse::NativeSessionIndexed { record, .. }
14148 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14149 NodeResponse::Armed { .. }
14150 | NodeResponse::Spawned { .. }
14151 | NodeResponse::Activated { .. }
14152 | NodeResponse::Aborted { .. }
14153 | NodeResponse::ReplyChunkAccepted { .. }
14154 | NodeResponse::CallRejected { .. }
14155 | NodeResponse::DeliveryStageBegun { .. }
14156 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14157 | NodeResponse::DeliveryCommitted { .. }
14158 | NodeResponse::DeliveryStageAborted { .. }
14159 | NodeResponse::WorkspaceInspected { .. }
14160 | NodeResponse::HostDirectoriesBrowsed { .. }
14161 | NodeResponse::WorkspaceFileRead { .. }
14162 | NodeResponse::WorkspaceFileWritten { .. }
14163 | NodeResponse::WorkspaceFileCreated { .. }
14164 | NodeResponse::WorkspaceDirectoryCreated { .. }
14165 | NodeResponse::GitHistoryRead { .. }
14166 | NodeResponse::GitDiffRead { .. }
14167 | NodeResponse::Controller { .. }
14168 | NodeResponse::SpawnAccepted { .. }
14169 | NodeResponse::SpawnSpecAccepted { .. }
14170 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14171 | NodeResponse::ManagedWorktreeCleanup { .. }
14172 | NodeResponse::NativeSessionsCataloged { .. }
14173 | NodeResponse::NativeSessionsPaged { .. }
14174 | NodeResponse::NativeSessionPreviewed { .. }
14175 | NodeResponse::SessionRecordPreviewed { .. }
14176 | NodeResponse::HistoryDiscovered { .. }
14177 | NodeResponse::HistoryLoaded { .. }
14178 | NodeResponse::ContextPackForSessionRecordExported { .. }
14179 | NodeResponse::ContextPackExported { .. }
14180 | NodeResponse::ContextPackForgotten { .. }
14181 | NodeResponse::DurableContextPackResolved { .. }
14182 | NodeResponse::ContextPackBytesRead { .. }
14183 | NodeResponse::SessionRecordForgotten { .. }
14184 | NodeResponse::WorkspaceRegistered { .. }
14185 | NodeResponse::StandaloneWorkspaceCreated { .. }
14186 | NodeResponse::WorkspaceUnregistered { .. }
14187 | NodeResponse::WorktreeCreated { .. }
14188 | NodeResponse::WorktreeRemoved { .. }
14189 | NodeResponse::Accepted
14190 | NodeResponse::ShuttingDown => {}
14191 }
14192}
14193
14194fn project_snapshot_history_for_wire(
14195 snapshot: &mut NodeSnapshot,
14196 include_history_metadata: bool,
14197) {
14198 for workspace in &mut snapshot.workspaces {
14199 for session in &mut workspace.sessions {
14200 if include_history_metadata {
14201 session.history.loaded = None;
14202 } else {
14203 session.history = Default::default();
14204 }
14205 }
14206 }
14207}
14208
14209fn project_event_history_for_wire(
14210 mut envelope: NodeEventEnvelope,
14211 include_history_metadata: bool,
14212) -> NodeEventEnvelope {
14213 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14214 for session in &mut workspace.sessions {
14215 if include_history_metadata {
14216 session.history.loaded = None;
14217 } else {
14218 session.history = Default::default();
14219 }
14220 }
14221 }
14222 envelope
14223}
14224
14225fn project_response_history_for_wire(
14226 reply: &mut ResponseEnvelope,
14227 include_history_metadata: bool,
14228) {
14229 let Ok(response) = reply.result.as_mut() else {
14230 return;
14231 };
14232 match response {
14233 NodeResponse::Snapshot { snapshot, .. } => {
14234 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14235 }
14236 NodeResponse::Resync {
14237 snapshot, events, ..
14238 } => {
14239 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14240 for event in events {
14241 *event = project_event_history_for_wire(
14242 event.clone(),
14243 include_history_metadata,
14244 );
14245 }
14246 }
14247 NodeResponse::WorkspaceRegistered { workspace }
14248 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14249 | NodeResponse::WorktreeCreated { workspace, .. } => {
14250 for session in &mut workspace.sessions {
14251 if include_history_metadata {
14252 session.history.loaded = None;
14253 } else {
14254 session.history = Default::default();
14255 }
14256 }
14257 }
14258 _ => {}
14259 }
14260}
14261
14262fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14263 for record in &mut snapshot.session_records {
14264 record.context_id = None;
14265 record.context = None;
14266 record.exported_context = None;
14267 }
14268}
14269
14270fn project_event_without_context_pack(
14271 mut envelope: NodeEventEnvelope,
14272) -> NodeEventEnvelope {
14273 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14274 record.context_id = None;
14275 record.context = None;
14276 record.exported_context = None;
14277 }
14278 envelope
14279}
14280
14281fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14282 let contains_context = match reply.result.as_ref() {
14283 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14284 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14285 receipt.spawn.context.is_some()
14286 }
14287 Ok(NodeResponse::HistoryDiscovered { .. })
14288 | Ok(NodeResponse::HistoryLoaded { .. })
14289 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14290 | Ok(NodeResponse::ContextPackExported { .. })
14291 | Ok(NodeResponse::ContextPackForgotten { .. })
14292 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14293 _ => false,
14294 };
14295 if contains_context {
14296 reply.result = Err(failure(
14297 NodeFailureCode::UnsupportedCapability,
14298 "history context packs were not negotiated",
14299 ));
14300 return;
14301 }
14302 let Ok(response) = reply.result.as_mut() else {
14303 return;
14304 };
14305 match response {
14306 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
14307 NodeResponse::Resync {
14308 snapshot, events, ..
14309 } => {
14310 clear_snapshot_context_packs(snapshot);
14311 for event in events {
14312 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14313 record.context_id = None;
14314 record.context = None;
14315 record.exported_context = None;
14316 }
14317 }
14318 }
14319 NodeResponse::SessionRecordUpdated { record }
14320 | NodeResponse::ProviderSessionIndexed { record }
14321 | NodeResponse::NativeSessionIndexed { record, .. }
14322 | NodeResponse::SessionRecordResumed { record, .. } => {
14323 record.context_id = None;
14324 record.context = None;
14325 record.exported_context = None;
14326 }
14327 _ => {}
14328 }
14329}
14330
14331fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
14332 let contains_open_record = match reply.result.as_ref() {
14333 Ok(NodeResponse::SessionRecordUpdated { record })
14334 | Ok(NodeResponse::ProviderSessionIndexed { record })
14335 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
14336 !provider_id_is_legacy(&record.provider)
14337 }
14338 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
14339 !provider_id_is_legacy(&selection.route.provider)
14340 || !provider_id_is_legacy(&record.provider)
14341 }
14342 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14343 !provider_id_is_legacy(&receipt.provider)
14344 }
14345 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14346 !provider_id_is_legacy(&receipt.spawn.provider)
14347 }
14348 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
14349 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
14350 !provider_id_is_legacy(&route.provider)
14351 }
14352 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
14353 !provider_id_is_legacy(&selection.route.provider)
14354 }
14355 _ => false,
14356 };
14357 if contains_open_record {
14358 reply.result = Err(failure(
14359 NodeFailureCode::UnsupportedCapability,
14360 "open provider IDs were not negotiated",
14361 ));
14362 return;
14363 }
14364 let Ok(response) = reply.result.as_mut() else {
14365 return;
14366 };
14367 match response {
14368 NodeResponse::Snapshot { snapshot, .. } => {
14369 project_snapshot_legacy_provider_ids(snapshot);
14370 }
14371 NodeResponse::Resync { snapshot, events, .. } => {
14372 project_snapshot_legacy_provider_ids(snapshot);
14373 *events = std::mem::take(events)
14374 .into_iter()
14375 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
14376 .collect();
14377 }
14378 NodeResponse::WorkspaceRegistered { workspace }
14379 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14380 | NodeResponse::WorktreeCreated { workspace, .. } => {
14381 workspace
14382 .sessions
14383 .retain(|session| provider_id_is_legacy(&session.agent_id));
14384 }
14385 NodeResponse::Armed { .. }
14386 | NodeResponse::Spawned { .. }
14387 | NodeResponse::Activated { .. }
14388 | NodeResponse::Aborted { .. }
14389 | NodeResponse::ReplyChunkAccepted { .. }
14390 | NodeResponse::CallRejected { .. }
14391 | NodeResponse::DeliveryStageBegun { .. }
14392 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14393 | NodeResponse::DeliveryCommitted { .. }
14394 | NodeResponse::DeliveryStageAborted { .. }
14395 | NodeResponse::WorkspaceInspected { .. }
14396 | NodeResponse::HostDirectoriesBrowsed { .. }
14397 | NodeResponse::WorkspaceFileRead { .. }
14398 | NodeResponse::WorkspaceFileWritten { .. }
14399 | NodeResponse::WorkspaceFileCreated { .. }
14400 | NodeResponse::WorkspaceDirectoryCreated { .. }
14401 | NodeResponse::GitHistoryRead { .. }
14402 | NodeResponse::GitDiffRead { .. }
14403 | NodeResponse::Controller { .. }
14404 | NodeResponse::SpawnAccepted { .. }
14405 | NodeResponse::SpawnSpecAccepted { .. }
14406 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14407 | NodeResponse::ManagedWorktreeCleanup { .. }
14408 | NodeResponse::NativeSessionsCataloged { .. }
14409 | NodeResponse::NativeSessionsPaged { .. }
14410 | NodeResponse::NativeSessionPreviewed { .. }
14411 | NodeResponse::SessionRecordPreviewed { .. }
14412 | NodeResponse::HistoryDiscovered { .. }
14413 | NodeResponse::HistoryLoaded { .. }
14414 | NodeResponse::ContextPackForSessionRecordExported { .. }
14415 | NodeResponse::ContextPackExported { .. }
14416 | NodeResponse::ContextPackForgotten { .. }
14417 | NodeResponse::DurableContextPackResolved { .. }
14418 | NodeResponse::ContextPackBytesRead { .. }
14419 | NodeResponse::SessionRecordUpdated { .. }
14420 | NodeResponse::ProviderSessionIndexed { .. }
14421 | NodeResponse::NativeSessionIndexed { .. }
14422 | NodeResponse::SessionRecordResumed { .. }
14423 | NodeResponse::SessionRecordForgotten { .. }
14424 | NodeResponse::WorkspaceUnregistered { .. }
14425 | NodeResponse::WorktreeRemoved { .. }
14426 | NodeResponse::Accepted
14427 | NodeResponse::ShuttingDown => {}
14428 }
14429}
14430
14431fn project_event_legacy_provider_ids(
14432 shared: &NodeShared,
14433 mut envelope: NodeEventEnvelope,
14434) -> Option<NodeEventEnvelope> {
14435 let include = match &mut envelope.event {
14436 NodeEvent::HarnessMcpReadCall { .. } => true,
14437 NodeEvent::Control { address, .. }
14438 | NodeEvent::AgentStream { address, .. }
14439 | NodeEvent::TerminalFrame { address, .. } => shared
14440 .validate_address(address)
14441 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
14442 NodeEvent::WorkspaceAdded { workspace } => {
14443 workspace
14444 .sessions
14445 .retain(|session| provider_id_is_legacy(&session.agent_id));
14446 true
14447 }
14448 NodeEvent::SessionRecordUpserted { record } => {
14449 provider_id_is_legacy(&record.provider)
14450 }
14451 NodeEvent::SessionRecordHistorySummarized { .. } => false,
14452 NodeEvent::SessionRecordRemoved { .. } => shared
14453 .history
14454 .lock()
14455 .unwrap_or_else(|poisoned| poisoned.into_inner())
14456 .removed_record_providers
14457 .get(&envelope.sequence)
14458 .is_some_and(provider_id_is_legacy),
14459 NodeEvent::ControllerChanged { .. }
14460 | NodeEvent::ManagedWorktreeUpserted { .. }
14461 | NodeEvent::ManagedWorktreeRemoved { .. }
14462 | NodeEvent::WorkspaceRemoved { .. }
14463 | NodeEvent::ResyncRequired { .. } => true,
14464 };
14465 include.then_some(envelope)
14466}
14467
14468fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
14469 let Ok(response) = reply.result.as_mut() else {
14470 return;
14471 };
14472 match response {
14473 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14474 snapshot.provider_runtime_statuses.clear();
14475 }
14476 NodeResponse::Armed { .. }
14477 | NodeResponse::Spawned { .. }
14478 | NodeResponse::Activated { .. }
14479 | NodeResponse::Aborted { .. }
14480 | NodeResponse::ReplyChunkAccepted { .. }
14481 | NodeResponse::CallRejected { .. }
14482 | NodeResponse::DeliveryStageBegun { .. }
14483 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14484 | NodeResponse::DeliveryCommitted { .. }
14485 | NodeResponse::DeliveryStageAborted { .. }
14486 | NodeResponse::WorkspaceInspected { .. }
14487 | NodeResponse::HostDirectoriesBrowsed { .. }
14488 | NodeResponse::WorkspaceFileRead { .. }
14489 | NodeResponse::WorkspaceFileWritten { .. }
14490 | NodeResponse::WorkspaceFileCreated { .. }
14491 | NodeResponse::WorkspaceDirectoryCreated { .. }
14492 | NodeResponse::GitHistoryRead { .. }
14493 | NodeResponse::GitDiffRead { .. }
14494 | NodeResponse::Controller { .. }
14495 | NodeResponse::SpawnAccepted { .. }
14496 | NodeResponse::SpawnSpecAccepted { .. }
14497 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14498 | NodeResponse::ManagedWorktreeCleanup { .. }
14499 | NodeResponse::NativeSessionsCataloged { .. }
14500 | NodeResponse::NativeSessionsPaged { .. }
14501 | NodeResponse::NativeSessionPreviewed { .. }
14502 | NodeResponse::SessionRecordPreviewed { .. }
14503 | NodeResponse::HistoryDiscovered { .. }
14504 | NodeResponse::HistoryLoaded { .. }
14505 | NodeResponse::ContextPackForSessionRecordExported { .. }
14506 | NodeResponse::ContextPackExported { .. }
14507 | NodeResponse::ContextPackForgotten { .. }
14508 | NodeResponse::DurableContextPackResolved { .. }
14509 | NodeResponse::ContextPackBytesRead { .. }
14510 | NodeResponse::SessionRecordUpdated { .. }
14511 | NodeResponse::ProviderSessionIndexed { .. }
14512 | NodeResponse::NativeSessionIndexed { .. }
14513 | NodeResponse::SessionRecordResumed { .. }
14514 | NodeResponse::SessionRecordForgotten { .. }
14515 | NodeResponse::WorkspaceRegistered { .. }
14516 | NodeResponse::StandaloneWorkspaceCreated { .. }
14517 | NodeResponse::WorkspaceUnregistered { .. }
14518 | NodeResponse::WorktreeCreated { .. }
14519 | NodeResponse::WorktreeRemoved { .. }
14520 | NodeResponse::Accepted
14521 | NodeResponse::ShuttingDown => {}
14522 }
14523}
14524
14525struct ControllerReleaseGuard {
14526 shared: Arc<NodeShared>,
14527 connection_id: u64,
14528}
14529
14530impl Drop for ControllerReleaseGuard {
14531 fn drop(&mut self) {
14532 self.shared.release_controller(self.connection_id);
14533 }
14534}
14535
14536struct AbortTaskOnDrop(AbortHandle);
14537
14538impl Drop for AbortTaskOnDrop {
14539 fn drop(&mut self) {
14540 self.0.abort();
14541 }
14542}
14543
14544fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
14549 std::env::var(name)
14550 .ok()
14551 .and_then(|value| value.parse::<u64>().ok())
14552 .filter(|value| *value > 0)
14553 .unwrap_or(default)
14554 .min(max)
14555}
14556
14557fn workspace_inspection_time_budget_ms() -> u64 {
14558 env_config_bounded(
14559 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
14560 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
14561 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
14562 )
14563}
14564
14565fn workspace_inspection_entry_cap() -> usize {
14566 env_config_bounded(
14567 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
14568 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
14569 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
14570 ) as usize
14571}
14572
14573struct WorkspaceWalkBudget {
14579 deadline: Instant,
14580 entry_cap: usize,
14581 entries_visited: u64,
14582 time_budget_exceeded: bool,
14583 entry_cap_exceeded: bool,
14584}
14585
14586impl WorkspaceWalkBudget {
14587 fn new(deadline: Instant, entry_cap: usize) -> Self {
14588 Self {
14589 deadline,
14590 entry_cap,
14591 entries_visited: 0,
14592 time_budget_exceeded: false,
14593 entry_cap_exceeded: false,
14594 }
14595 }
14596
14597 fn is_exhausted(&self) -> bool {
14598 self.time_budget_exceeded || self.entry_cap_exceeded
14599 }
14600
14601 fn record_visit(&mut self) -> bool {
14604 self.entries_visited += 1;
14605 if self.entries_visited as usize >= self.entry_cap {
14606 self.entry_cap_exceeded = true;
14607 }
14608 if Instant::now() >= self.deadline {
14609 self.time_budget_exceeded = true;
14610 }
14611 self.is_exhausted()
14612 }
14613}
14614
14615fn collect_workspace_entries(
14616 root: &Path,
14617 deadline: Instant,
14618 entry_cap: usize,
14619) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
14620 let mut entries = Vec::new();
14621 let mut truncated = false;
14622 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
14623 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
14624 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
14628 (entries, truncated, budget)
14629}
14630
14631fn workspace_inspection_truncation(
14635 walk_budget: &WorkspaceWalkBudget,
14636 git_time_budget_exceeded: bool,
14637 elapsed: Duration,
14638) -> Option<WorkspaceInspectionTruncationV1> {
14639 if !walk_budget.time_budget_exceeded
14640 && !walk_budget.entry_cap_exceeded
14641 && !git_time_budget_exceeded
14642 {
14643 return None;
14644 }
14645 Some(WorkspaceInspectionTruncationV1 {
14646 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
14647 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
14648 git_time_budget_exceeded,
14649 entries_visited: walk_budget.entries_visited,
14650 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
14651 })
14652}
14653
14654fn walk_workspace_directory(
14655 directory: &Path,
14656 relative_directory: Option<&RepositoryPath>,
14657 depth: usize,
14658 entries: &mut Vec<WorkspaceEntry>,
14659 truncated: &mut bool,
14660 budget: &mut WorkspaceWalkBudget,
14661) {
14662 if budget.is_exhausted() {
14663 *truncated = true;
14664 return;
14665 }
14666 let read_dir = match std::fs::read_dir(directory) {
14667 Ok(read_dir) => read_dir,
14668 Err(_) => {
14669 *truncated = true;
14670 return;
14671 }
14672 };
14673 let mut children = Vec::new();
14674 for child in read_dir {
14675 match child {
14676 Ok(child) => children.push(child),
14677 Err(_) => *truncated = true,
14678 }
14679 }
14680 children.sort_by_key(|child| child.file_name());
14681 for child in children {
14682 if budget.record_visit() {
14683 *truncated = true;
14684 return;
14685 }
14686 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
14687 *truncated = true;
14688 return;
14689 }
14690 let file_type = match child.file_type() {
14691 Ok(file_type) => file_type,
14692 Err(_) => {
14693 *truncated = true;
14694 continue;
14695 }
14696 };
14697 if file_type.is_symlink() {
14698 continue;
14699 }
14700 let name = match child.file_name().into_string() {
14701 Ok(name) => name,
14702 Err(_) => {
14703 *truncated = true;
14704 continue;
14705 }
14706 };
14707 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
14708 continue;
14709 }
14710 let relative_path = match relative_directory {
14711 None => windows_repository_path(name),
14712 Some(parent) => parent
14713 .as_utf8()
14714 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
14715 };
14716 let Some(relative_path) = relative_path else {
14717 *truncated = true;
14718 continue;
14719 };
14720 let kind = if file_type.is_dir() {
14721 WorkspaceEntryKind::Directory
14722 } else if file_type.is_file() {
14723 WorkspaceEntryKind::File
14724 } else {
14725 continue;
14726 };
14727 entries.push(WorkspaceEntry {
14728 relative_path: relative_path.clone(),
14729 kind,
14730 });
14731 if file_type.is_dir() {
14732 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
14733 walk_workspace_directory(
14734 &child.path(),
14735 Some(&relative_path),
14736 depth + 1,
14737 entries,
14738 truncated,
14739 budget,
14740 );
14741 } else {
14742 *truncated = true;
14743 }
14744 }
14745 }
14746}
14747
14748fn windows_repository_path(value: String) -> Option<RepositoryPath> {
14749 if value.contains('\\') {
14750 return None;
14751 }
14752 RepositoryPath::utf8(value).ok()
14753}
14754
14755fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
14756 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
14757}
14758
14759fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
14768 if name.eq_ignore_ascii_case(".git")
14769 || name.eq_ignore_ascii_case("target")
14770 || name.eq_ignore_ascii_case("node_modules")
14771 {
14772 return true;
14773 }
14774 path.join("CACHEDIR.TAG").is_file()
14775}
14776
14777fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
14778 matches!(
14779 status,
14780 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
14781 )
14782}
14783
14784#[derive(Clone, Debug, Eq, PartialEq)]
14785enum ContextPackRepositoryHead {
14786 NotRepository,
14787 Unborn,
14788 Commit(String),
14789}
14790
14791#[derive(Clone, Debug, Eq, PartialEq)]
14792struct ContextPackRepositoryObservation {
14793 head: ContextPackRepositoryHead,
14794 git: GitSnapshot,
14795 files: Vec<ContextPackRepositoryFileSource>,
14796}
14797
14798fn stable_context_pack_repository(
14799 first: ContextPackRepositoryObservation,
14800 second: ContextPackRepositoryObservation,
14801) -> Result<ContextPackRepository, NodeFailure> {
14802 if first != second {
14803 return Err(failure(
14804 NodeFailureCode::ContextPackMaterializationFailed,
14805 "context repository changed during bounded capture",
14806 ));
14807 }
14808 Ok(ContextPackRepository::from_sources(
14809 &second.git,
14810 second.files,
14811 ))
14812}
14813
14814fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
14821 if Instant::now() < deadline {
14822 return false;
14823 }
14824 append_git_diagnostic(
14825 snapshot,
14826 &format!("{stage} skipped: workspace inspection time budget exhausted"),
14827 );
14828 true
14829}
14830
14831async fn run_git_bounded_with_deadline(
14840 root: &str,
14841 arguments: &[&str],
14842 output_limit: usize,
14843 deadline: Instant,
14844) -> io::Result<GitCommandOutput> {
14845 let remaining = deadline.checked_duration_since(Instant::now());
14846 let timeout_ms = remaining
14847 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
14848 .unwrap_or(0)
14849 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
14850 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
14851}
14852
14853async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
14860 let mut snapshot = GitSnapshot {
14861 is_repository: false,
14862 branch: None,
14863 status: Vec::new(),
14864 recent_commits: Vec::new(),
14865 worktrees: Vec::new(),
14866 managed_worktree: None,
14867 truncated: false,
14868 diagnostic: None,
14869 };
14870 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
14871 return (snapshot, true);
14872 }
14873 let repository = match run_git_bounded_with_deadline(
14874 root,
14875 &["rev-parse", "--is-inside-work-tree"],
14876 4 * 1_024,
14877 deadline,
14878 )
14879 .await
14880 {
14881 Ok(output) => output,
14882 Err(error) => {
14883 append_git_diagnostic(
14884 &mut snapshot,
14885 &format!("git inspection unavailable: {error}"),
14886 );
14887 return (snapshot, false);
14888 }
14889 };
14890 snapshot.truncated |= repository.truncated;
14891 if repository.timed_out {
14892 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
14893 return (snapshot, false);
14894 }
14895 if !repository.success
14896 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
14897 {
14898 let stderr = String::from_utf8_lossy(&repository.stderr);
14899 if !stderr.to_ascii_lowercase().contains("not a git repository") {
14900 append_git_diagnostic(&mut snapshot, stderr.trim());
14901 }
14902 return (snapshot, false);
14903 }
14904 snapshot.is_repository = true;
14905
14906 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
14907 return (snapshot, true);
14908 }
14909 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
14910 Ok(output) => {
14911 snapshot.truncated |= output.truncated;
14912 if output.timed_out {
14913 append_git_diagnostic(&mut snapshot, "git branch query timed out");
14914 } else if output.success {
14915 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
14916 if !branch.is_empty() {
14917 snapshot.branch = Some(truncate_git_field(
14918 &branch,
14919 MAX_REPOSITORY_PATH_BYTES,
14920 &mut snapshot.truncated,
14921 ));
14922 }
14923 }
14924 }
14925 Err(error) => append_git_diagnostic(
14926 &mut snapshot,
14927 &format!("git branch query failed: {error}"),
14928 ),
14929 }
14930 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
14931 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
14932 snapshot.truncated |= output.truncated;
14933 if output.success && !output.timed_out {
14934 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
14935 if !head.is_empty() {
14936 snapshot.branch = Some(format!("detached:{head}"));
14937 }
14938 }
14939 }
14940 }
14941
14942 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
14943 return (snapshot, true);
14944 }
14945 match run_git_bounded_with_deadline(
14946 root,
14947 &[
14948 "status",
14949 "--porcelain=v1",
14950 "-z",
14951 "--renames",
14952 "--untracked-files=normal",
14953 "--",
14954 ".",
14955 ],
14956 GIT_OUTPUT_MAX_BYTES,
14957 deadline,
14958 )
14959 .await
14960 {
14961 Ok(output) => {
14962 snapshot.truncated |= output.truncated;
14963 if output.timed_out {
14964 append_git_diagnostic(&mut snapshot, "git status timed out");
14965 } else if output.success {
14966 parse_git_status(&output.stdout, &mut snapshot);
14967 } else {
14968 append_git_diagnostic(
14969 &mut snapshot,
14970 String::from_utf8_lossy(&output.stderr).trim(),
14971 );
14972 }
14973 }
14974 Err(error) => append_git_diagnostic(
14975 &mut snapshot,
14976 &format!("git status failed: {error}"),
14977 ),
14978 }
14979
14980 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
14981 return (snapshot, true);
14982 }
14983 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
14984 match run_git_bounded_with_deadline(
14985 root,
14986 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
14987 16 * 1_024,
14988 deadline,
14989 )
14990 .await
14991 {
14992 Ok(output) => {
14993 snapshot.truncated |= output.truncated;
14994 if output.timed_out {
14995 append_git_diagnostic(&mut snapshot, "git log timed out");
14996 } else if output.success {
14997 parse_git_commits(&output.stdout, &mut snapshot);
14998 } else {
14999 let stderr = String::from_utf8_lossy(&output.stderr);
15000 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15001 append_git_diagnostic(&mut snapshot, stderr.trim());
15002 }
15003 }
15004 }
15005 Err(error) => append_git_diagnostic(
15006 &mut snapshot,
15007 &format!("git log failed: {error}"),
15008 ),
15009 }
15010
15011 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15012 return (snapshot, true);
15013 }
15014 match list_git_worktrees_with_deadline(root, deadline).await {
15015 Ok(worktrees) => {
15016 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15017 }
15018 Err(error) => append_git_diagnostic(
15019 &mut snapshot,
15020 &format!("git worktree list failed: {}", error.message),
15021 ),
15022 }
15023 (snapshot, false)
15024}
15025
15026fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15027 let mut remaining = output;
15028 while !remaining.is_empty() {
15029 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15030 snapshot.truncated = true;
15031 break;
15032 }
15033 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15034 snapshot.truncated = true;
15035 break;
15036 };
15037 let record = &remaining[..record_end];
15038 remaining = &remaining[record_end + 1..];
15039 let has_previous_path = record
15040 .get(..2)
15041 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15042 let previous_path_bytes = if has_previous_path {
15043 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15044 snapshot.truncated = true;
15045 break;
15046 };
15047 let previous = &remaining[..previous_end];
15048 remaining = &remaining[previous_end + 1..];
15049 Some(previous)
15050 } else {
15051 None
15052 };
15053 if record.len() < 4
15054 || record[2] != b' '
15055 || !is_git_status_code(record[0])
15056 || !is_git_status_code(record[1])
15057 {
15058 snapshot.truncated = true;
15059 continue;
15060 }
15061 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15062 snapshot.truncated = true;
15063 continue;
15064 };
15065 let previous_path = match previous_path_bytes {
15066 Some(previous) => {
15067 let Some(previous) = windows_repository_path_from_bytes(previous) else {
15068 snapshot.truncated = true;
15069 continue;
15070 };
15071 Some(previous)
15072 }
15073 None => None,
15074 };
15075 snapshot.status.push(GitStatusEntry {
15076 index_status: (record[0] as char).to_string(),
15077 worktree_status: (record[1] as char).to_string(),
15078 path,
15079 previous_path,
15080 });
15081 }
15082}
15083
15084fn is_git_status_code(byte: u8) -> bool {
15085 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15086}
15087
15088fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15089 for line in String::from_utf8_lossy(output).lines() {
15090 let Some((id, summary)) = line.split_once('\u{1f}') else {
15091 continue;
15092 };
15093 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15094 snapshot.truncated = true;
15095 break;
15096 }
15097 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15098 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15099 snapshot.recent_commits.push(GitCommitSummary { id, summary });
15100 }
15101}
15102
15103fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15104 if value.len() <= max_bytes {
15105 return value.to_owned();
15106 }
15107 *truncated = true;
15108 let mut boundary = max_bytes;
15109 while boundary > 0 && !value.is_char_boundary(boundary) {
15110 boundary -= 1;
15111 }
15112 value[..boundary].to_owned()
15113}
15114
15115fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15116 if message.is_empty() {
15117 return;
15118 }
15119 let normalized = message
15120 .chars()
15121 .map(|character| if character.is_control() { ' ' } else { character })
15122 .collect::<String>();
15123 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15124 if !diagnostic.is_empty() {
15125 diagnostic.push_str("; ");
15126 }
15127 diagnostic.push_str(normalized.trim());
15128 snapshot.diagnostic = Some(truncate_git_field(
15129 &diagnostic,
15130 GIT_DIAGNOSTIC_MAX_BYTES,
15131 &mut snapshot.truncated,
15132 ));
15133}
15134
15135async fn run_git_bounded(
15136 root: &str,
15137 arguments: &[&str],
15138 output_limit: usize,
15139) -> io::Result<GitCommandOutput> {
15140 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15141}
15142
15143async fn read_git_history_bounded(
15144 root: &str,
15145 path: Option<&RepositoryPath>,
15146 before: Option<&GitObjectId>,
15147 limit: u16,
15148) -> Result<GitHistoryPage, NodeFailure> {
15149 let path = path
15150 .map(|path| {
15151 path.as_utf8().ok_or_else(|| {
15152 failure(
15153 NodeFailureCode::InvalidRepositoryPath,
15154 "git history path must use UTF-8",
15155 )
15156 })
15157 })
15158 .transpose()?
15159 .unwrap_or(".");
15160 let count = (usize::from(limit) + 1).to_string();
15161 let start = before
15162 .map(|revision| format!("{}^", revision.as_str()))
15163 .unwrap_or_else(|| "HEAD".to_owned());
15164 let arguments = [
15165 "--no-pager",
15166 "log",
15167 "--no-decorate",
15168 "--date=iso-strict",
15169 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15170 "-n",
15171 count.as_str(),
15172 start.as_str(),
15173 "--",
15174 path,
15175 ];
15176 let output = run_git_bounded(root, &arguments, 256 * 1_024)
15177 .await
15178 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15179 if output.timed_out {
15180 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15181 }
15182 if !output.success {
15183 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15184 }
15185 let mut commits = parse_git_history(&output.stdout);
15186 let has_more = commits.len() > usize::from(limit) || output.truncated;
15187 commits.truncate(usize::from(limit));
15188 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15189 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15190}
15191
15192fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15193 String::from_utf8_lossy(output)
15194 .split('\u{1e}')
15195 .filter_map(|record| {
15196 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15197 if fields.len() != 9 {
15198 return None;
15199 }
15200 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15201 let parents = if fields[1].is_empty() {
15202 Vec::new()
15203 } else {
15204 fields[1]
15205 .split(' ')
15206 .map(|value| GitObjectId::new(value.to_owned()))
15207 .collect::<Result<Vec<_>, _>>()
15208 .ok()?
15209 };
15210 Some(GitCommitDetails {
15211 id,
15212 parents,
15213 subject: bounded_git_text(fields[2], 512),
15214 author_name: bounded_git_text(fields[3], 256),
15215 author_email: bounded_git_text(fields[4], 320),
15216 authored_at: bounded_git_text(fields[5], 64),
15217 committer_name: bounded_git_text(fields[6], 256),
15218 committer_email: bounded_git_text(fields[7], 320),
15219 committed_at: bounded_git_text(fields[8], 64),
15220 signature_status: GitSignatureStatus::NoSignature,
15221 signer: None,
15222 })
15223 })
15224 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15225 .collect()
15226}
15227
15228fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15229 let normalized = value
15230 .chars()
15231 .map(|character| if character.is_control() { ' ' } else { character })
15232 .collect::<String>();
15233 if normalized.len() <= max_bytes {
15234 return normalized;
15235 }
15236 let mut boundary = max_bytes;
15237 while boundary > 0 && !normalized.is_char_boundary(boundary) {
15238 boundary -= 1;
15239 }
15240 normalized[..boundary].to_owned()
15241}
15242
15243async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15244 let path = request.path.as_ref().map(|path| {
15245 path.as_utf8()
15246 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15247 }).transpose()?;
15248 let mut arguments = match &request.mode {
15249 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15250 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15251 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15252 };
15253 if let GitDiffMode::Commit { revision } = &request.mode {
15254 arguments.push(revision.as_str());
15255 }
15256 arguments.push("--");
15257 if let Some(path) = path {
15258 arguments.push(path);
15259 }
15260 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15261 .await
15262 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15263 if output.timed_out {
15264 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15265 }
15266 if !output.success {
15267 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15268 }
15269 Ok(GitDiff {
15270 mode: request.mode,
15271 path: request.path,
15272 text: String::from_utf8_lossy(&output.stdout).into_owned(),
15273 truncated: output.truncated,
15274 })
15275}
15276
15277async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15278 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15279 ResponseEnvelope { request_id: envelope.request_id, result }
15280}
15281
15282async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15283 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15284 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15285 }
15286 let read_only = matches!(
15287 &request,
15288 NodeRequest::Snapshot
15289 | NodeRequest::Resync { .. }
15290 | NodeRequest::BrowseHostDirectories { .. }
15291 | NodeRequest::InspectWorkspace { .. }
15292 | NodeRequest::ReadWorkspaceFile { .. }
15293 | NodeRequest::ReadGitHistory { .. }
15294 | NodeRequest::ReadGitDiff { .. }
15295 | NodeRequest::CatalogNativeSessions { .. }
15296 | NodeRequest::PageNativeSessions { .. }
15297 | NodeRequest::PreviewNativeSession { .. }
15298 | NodeRequest::PreviewSessionRecord { .. }
15299 );
15300 let _mutation_guard = if read_only {
15301 None
15302 } else {
15303 let guard = shared.mutation_gate.lock().await;
15304 if shared.shutdown.load(Ordering::Acquire) {
15305 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
15306 }
15307 Some(guard)
15308 };
15309 match request {
15310 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
15311 event_sequence: shared.current_sequence(),
15312 controller: shared.controller_state(),
15313 snapshot: shared.snapshot(),
15314 }),
15315 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
15316 NodeRequest::ArmHarnessMcpReservation {
15317 reservation_id,
15318 activation_digest,
15319 spawn_spec,
15320 launch,
15321 expires_at_unix_ms,
15322 } => {
15323 shared.require_controller(connection_id, role)?;
15324 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15325 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15326 })?;
15327 let expires_at_unix_ms = registry
15328 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
15329 .await
15330 .map_err(harness_mcp_failure)?;
15331 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
15332 }
15333 NodeRequest::SpawnSpecWithHarnessMcp {
15334 reservation_id,
15335 activation_digest,
15336 spec,
15337 deadline_unix_ms,
15338 } => {
15339 shared.require_controller(connection_id, role)?;
15340 let receipt = shared
15341 .spawn_from_spec_with_harness_mcp(
15342 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
15343 )
15344 .await?;
15345 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
15346 }
15347 NodeRequest::ActivateHarnessMcpReservation {
15348 reservation_id,
15349 activation_digest,
15350 record_id,
15351 session,
15352 } => {
15353 shared.require_controller(connection_id, role)?;
15354 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
15355 current.instance_id == session.session.instance_id
15356 && current.generation == session.session.generation
15357 }).and_then(|current| current.process_id).ok_or_else(|| {
15358 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
15359 })?;
15360 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15361 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15362 })?.activate(
15363 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
15364 ).map_err(harness_mcp_failure)?;
15365 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
15366 }
15367 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
15368 shared.require_controller(connection_id, role)?;
15369 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15370 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15371 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
15372 if let (Some(control), Some(instance_id)) =
15373 (shared.native_launch_profile_control.as_ref(), instance_id)
15374 {
15375 control.clear_native_mcp_server_launch_overlay(instance_id);
15376 }
15377 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
15378 }
15379 NodeRequest::PutHarnessMcpReplyChunk {
15380 reservation_id, activation_digest, record_id, session, call_id,
15381 offset, final_chunk, chunk_hex,
15382 } => {
15383 shared.require_controller(connection_id, role)?;
15384 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15385 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15386 })?.put_reply_chunk(
15387 &reservation_id, &activation_digest, &record_id, &session, &call_id,
15388 offset, final_chunk, &chunk_hex,
15389 ).map_err(harness_mcp_failure)?;
15390 Ok(NodeResponse::ReplyChunkAccepted {
15391 reservation_id, activation_digest, record_id, session, call_id,
15392 next_offset, completed,
15393 })
15394 }
15395 NodeRequest::RejectHarnessMcpCall {
15396 reservation_id, activation_digest, record_id, session, call_id, reason,
15397 } => {
15398 shared.require_controller(connection_id, role)?;
15399 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15400 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15401 })?.reject_call(
15402 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
15403 ).map_err(harness_mcp_failure)?;
15404 Ok(NodeResponse::CallRejected {
15405 reservation_id, activation_digest, record_id, session, call_id,
15406 })
15407 }
15408 NodeRequest::BeginDeliveryStage { manifest } => {
15409 shared.require_controller(connection_id, role)?;
15410 shared.begin_delivery_stage(manifest)
15411 }
15412 NodeRequest::PutDeliveryBlobChunk {
15413 stage_id,
15414 blob_digest,
15415 offset,
15416 chunk_hex,
15417 } => {
15418 shared.require_controller(connection_id, role)?;
15419 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
15420 }
15421 NodeRequest::CommitDeliveryStage { stage_id } => {
15422 shared.require_controller(connection_id, role)?;
15423 let receipt = shared.commit_delivery_stage(stage_id)?;
15424 Ok(NodeResponse::DeliveryCommitted { receipt })
15425 }
15426 NodeRequest::AbortDeliveryStage { stage_id } => {
15427 shared.require_controller(connection_id, role)?;
15428 shared.abort_delivery_stage(&stage_id)?;
15429 Ok(NodeResponse::DeliveryStageAborted { stage_id })
15430 }
15431 NodeRequest::BrowseHostDirectories { directory, after } => {
15432 let listing = shared.browse_host_directories(directory, after).await?;
15433 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
15434 }
15435 NodeRequest::InspectWorkspace { workspace_id } => {
15436 let inspection = shared.inspect_workspace(workspace_id).await?;
15437 Ok(NodeResponse::WorkspaceInspected { inspection })
15438 }
15439 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
15440 let file = shared.read_workspace_file(workspace_id, path).await?;
15441 Ok(NodeResponse::WorkspaceFileRead { file })
15442 }
15443 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
15444 shared.require_controller(connection_id, role)?;
15445 let file = shared
15446 .write_workspace_file(workspace_id, path, expected_revision, text)
15447 .await?;
15448 Ok(NodeResponse::WorkspaceFileWritten { file })
15449 }
15450 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
15451 shared.require_controller(connection_id, role)?;
15452 let file = shared.create_workspace_file(workspace_id, path).await?;
15453 Ok(NodeResponse::WorkspaceFileCreated { file })
15454 }
15455 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
15456 shared.require_controller(connection_id, role)?;
15457 let entry = shared
15458 .create_workspace_directory(workspace_id.clone(), path)
15459 .await?;
15460 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
15461 }
15462 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
15463 if role != ClientRole::Operator {
15464 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
15465 }
15466 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
15467 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
15468 }
15469 let page = shared
15470 .read_git_history(workspace_id.clone(), path, before, limit)
15471 .await?;
15472 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
15473 }
15474 NodeRequest::ReadGitDiff { workspace_id, request } => {
15475 if role != ClientRole::Operator {
15476 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
15477 }
15478 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
15479 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
15480 }
15481 NodeRequest::CatalogNativeSessions { route, limit } => {
15482 if role != ClientRole::Operator {
15483 return Err(failure(
15484 NodeFailureCode::ObserverReadOnly,
15485 "native session catalog requires an operator connection",
15486 ));
15487 }
15488 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
15489 return Err(failure(
15490 NodeFailureCode::InvalidRequest,
15491 "native session catalog limit is invalid",
15492 ));
15493 }
15494 let (entries, summary) = shared
15495 .catalog_native_sessions(route.clone(), limit)
15496 .await?;
15497 Ok(NodeResponse::NativeSessionsCataloged {
15498 route,
15499 entries,
15500 summary: Some(summary),
15501 })
15502 }
15503 NodeRequest::PageNativeSessions {
15504 route,
15505 window,
15506 catalog_revision,
15507 recent_cutoff_unix_ms,
15508 after_selection_id,
15509 limit,
15510 } => {
15511 if role != ClientRole::Operator {
15512 return Err(failure(
15513 NodeFailureCode::ObserverReadOnly,
15514 "native session catalog paging requires an operator connection",
15515 ));
15516 }
15517 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
15518 || after_selection_id
15519 .as_deref()
15520 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
15521 {
15522 return Err(failure(
15523 NodeFailureCode::InvalidRequest,
15524 "native session catalog page request is invalid",
15525 ));
15526 }
15527 let page = shared
15528 .page_native_sessions(
15529 route.clone(),
15530 window,
15531 catalog_revision,
15532 recent_cutoff_unix_ms,
15533 after_selection_id,
15534 limit,
15535 )
15536 .await?;
15537 Ok(NodeResponse::NativeSessionsPaged {
15538 route,
15539 page,
15540 })
15541 }
15542 NodeRequest::PreviewNativeSession {
15543 selection,
15544 message_limit,
15545 } => {
15546 if role != ClientRole::Operator {
15547 return Err(failure(
15548 NodeFailureCode::ObserverReadOnly,
15549 "native session preview requires an operator connection",
15550 ));
15551 }
15552 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15553 .contains(&message_limit)
15554 || selection.validate().is_err()
15555 {
15556 return Err(failure(
15557 NodeFailureCode::InvalidRequest,
15558 "native session preview request is invalid",
15559 ));
15560 }
15561 let preview = shared
15562 .preview_native_session(selection.clone(), message_limit)
15563 .await?;
15564 Ok(NodeResponse::NativeSessionPreviewed {
15565 selection,
15566 preview,
15567 })
15568 }
15569 NodeRequest::IndexNativeSession {
15570 selection,
15571 display_name,
15572 } => {
15573 shared.require_controller(connection_id, role)?;
15574 let record = shared
15575 .index_native_session(selection.clone(), display_name)
15576 .await?;
15577 Ok(NodeResponse::NativeSessionIndexed { selection, record })
15578 }
15579 NodeRequest::PreviewSessionRecord {
15580 record_id,
15581 message_limit,
15582 } => {
15583 if role != ClientRole::Operator {
15584 return Err(failure(
15585 NodeFailureCode::ObserverReadOnly,
15586 "native session preview requires an operator connection",
15587 ));
15588 }
15589 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15590 .contains(&message_limit)
15591 {
15592 return Err(failure(
15593 NodeFailureCode::InvalidRequest,
15594 "native session preview request is invalid",
15595 ));
15596 }
15597 let record = shared.record(&record_id)?;
15598 let working_directory = shared.workspace_root(&record.workspace_id)?;
15599 if !platform::roots_equal(
15600 &working_directory,
15601 windows_path_text(&record.canonical_root),
15602 ) {
15603 return Err(failure(
15604 NodeFailureCode::SessionRecordConflict,
15605 "managed session workspace root changed; refusing to preview another directory",
15606 ));
15607 }
15608 let identity = record.provider_session.clone().ok_or_else(|| {
15609 failure(
15610 NodeFailureCode::BackendOperationFailed,
15611 "native session preview is unavailable for this session record",
15612 )
15613 })?;
15614 if identity.key != ProviderSessionKey::SessionId {
15615 return Err(failure(
15616 NodeFailureCode::BackendOperationFailed,
15617 "native session preview is unavailable for this session identity",
15618 ));
15619 }
15620 let preview = shared
15621 .preview_native_session_by_session_id(
15622 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
15623 )
15624 .await?;
15625 if preview.session_id != identity.id {
15626 return Err(failure(
15627 NodeFailureCode::SessionRecordConflict,
15628 "native session preview identity changed while loading",
15629 ));
15630 }
15631 shared.revalidate_session_record_preview(&record, &identity)?;
15632 let preview: gate4agent_types::SessionRecordPreview = preview.into();
15633 shared.publish(NodeEvent::SessionRecordHistorySummarized {
15634 record_id: record_id.clone(),
15635 summary: SessionHistorySummaryV1::from(&preview),
15636 });
15637 Ok(NodeResponse::SessionRecordPreviewed {
15638 record_id,
15639 preview,
15640 })
15641 }
15642 NodeRequest::AcquireController { lease_ms } => {
15643 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
15644 Ok(NodeResponse::Controller { controller: Some(controller) })
15645 }
15646 NodeRequest::ReleaseController => {
15647 shared.release_controller(connection_id);
15648 Ok(NodeResponse::Controller { controller: shared.controller_state() })
15649 }
15650 NodeRequest::RegisterWorkspace { workspace_id, root } => {
15651 shared.require_controller(connection_id, role)?;
15652 let root = require_windows_path(root)?;
15653 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
15654 let workspace = shared.register_workspace(workspace_id, root).await?;
15655 Ok(NodeResponse::WorkspaceRegistered { workspace })
15656 }
15657 NodeRequest::CreateStandaloneWorkspace {
15658 workspace_id,
15659 root,
15660 initial_branch,
15661 } => {
15662 shared.require_controller(connection_id, role)?;
15663 let root = require_windows_path(root)?;
15664 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
15665 let workspace = shared
15666 .create_standalone_workspace(workspace_id, root, initial_branch)
15667 .await?;
15668 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
15669 }
15670 NodeRequest::UnregisterWorkspace { workspace_id } => {
15671 shared.require_controller(connection_id, role)?;
15672 shared.reject_managed_reservation(Some(&workspace_id), None)?;
15673 shared.unregister_workspace(&workspace_id)?;
15674 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
15675 }
15676 NodeRequest::CreateWorktree {
15677 source_workspace_id,
15678 workspace_id,
15679 target_root,
15680 branch,
15681 base,
15682 } => {
15683 shared.require_controller(connection_id, role)?;
15684 let target_root = require_windows_path(target_root)?;
15685 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
15686 let (worktree, workspace) = shared
15687 .create_worktree(
15688 source_workspace_id,
15689 workspace_id,
15690 target_root,
15691 branch,
15692 base,
15693 )
15694 .await?;
15695 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
15696 }
15697 NodeRequest::RemoveWorktree {
15698 source_workspace_id,
15699 target_root,
15700 } => {
15701 shared.require_controller(connection_id, role)?;
15702 let native_target_root = require_windows_path(target_root.clone())?;
15703 shared.reject_managed_reservation(None, Some(&native_target_root))?;
15704 let workspace_id = shared
15705 .remove_worktree(source_workspace_id, native_target_root)
15706 .await?;
15707 Ok(NodeResponse::WorktreeRemoved {
15708 target_root,
15709 workspace_id,
15710 })
15711 }
15712 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
15713 shared.require_controller(connection_id, role)?;
15714 if !terminal_size.is_valid() {
15715 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
15716 }
15717 if let Some(prompt) = initial_prompt.as_deref() {
15718 validate_node_text("spawn initial prompt", prompt)?;
15719 }
15720 let session = shared
15721 .spawn_session(workspace_id, provider, mode, terminal_size, initial_prompt)
15722 .await?;
15723 Ok(NodeResponse::SpawnAccepted { session })
15724 }
15725 NodeRequest::SpawnSpec { spec } => {
15726 shared.require_controller(connection_id, role)?;
15727 let receipt = shared.spawn_from_spec(spec).await?;
15728 Ok(NodeResponse::SpawnSpecAccepted { receipt })
15729 }
15730 NodeRequest::SpawnManagedWorktree { request } => {
15731 shared.require_controller(connection_id, role)?;
15732 let receipt = shared.spawn_managed_worktree(request).await?;
15733 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
15734 }
15735 NodeRequest::SpawnManagedWorktreeV2 { request } => {
15736 shared.require_controller(connection_id, role)?;
15737 let receipt = shared.spawn_managed_worktree_v2(request).await?;
15738 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
15739 }
15740 NodeRequest::CleanupManagedWorktree { lease_id } => {
15741 shared.require_controller(connection_id, role)?;
15742 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
15743 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
15744 }
15745 NodeRequest::DiscoverHistory { session, limit } => {
15746 controlled_session(shared, connection_id, role, &session)?;
15747 let candidates = shared.discover_history(&session, limit).await?;
15748 Ok(NodeResponse::HistoryDiscovered {
15749 session,
15750 candidates,
15751 })
15752 }
15753 NodeRequest::LoadHistory {
15754 session,
15755 candidate_id,
15756 } => {
15757 controlled_session(shared, connection_id, role, &session)?;
15758 let loaded = shared
15759 .load_history(&session, candidate_id)
15760 .await?;
15761 Ok(NodeResponse::HistoryLoaded {
15762 session,
15763 session_id: loaded.session_id,
15764 message_count: loaded.message_count,
15765 completed_turn_count: loaded.completed_turn_count,
15766 })
15767 }
15768 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
15769 shared.require_controller(connection_id, role)?;
15770 let context = shared
15771 .export_context_pack_for_session_record(&record_id, &session, false)
15772 .await?;
15773 Ok(NodeResponse::ContextPackForSessionRecordExported {
15774 record_id,
15775 session,
15776 context,
15777 })
15778 }
15779 NodeRequest::ExportContextPack { session } => {
15780 controlled_session(shared, connection_id, role, &session)?;
15781 let context = shared.export_context_pack(&session).await?;
15782 Ok(NodeResponse::ContextPackExported { context })
15783 }
15784 NodeRequest::ForgetContextPack { context_id } => {
15785 shared.require_controller(connection_id, role)?;
15786 shared.forget_context_pack(&context_id)?;
15787 Ok(NodeResponse::ContextPackForgotten { context_id })
15788 }
15789 NodeRequest::ResolveDurableContextPack { context_id } => {
15790 let context = shared
15794 .context_catalog
15795 .read()
15796 .unwrap_or_else(|poisoned| poisoned.into_inner())
15797 .get(&context_id)
15798 .map(|pack| pack.receipt().clone())
15799 .ok_or_else(|| {
15800 failure(
15801 NodeFailureCode::UnknownContextPack,
15802 "context pack is not present",
15803 )
15804 })?;
15805 Ok(NodeResponse::DurableContextPackResolved { context })
15806 }
15807 NodeRequest::ReadContextPack { digest } => {
15808 let (id, byte_len, bytes) = shared
15813 .context_catalog
15814 .read()
15815 .unwrap_or_else(|poisoned| poisoned.into_inner())
15816 .get_by_digest(&digest)
15817 .map(|pack| {
15818 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
15819 })
15820 .ok_or_else(|| {
15821 failure(
15822 NodeFailureCode::UnknownContextPack,
15823 &format!("context pack {digest} not on this node"),
15824 )
15825 })?;
15826 tracing::debug!(
15827 digest = digest.as_str(),
15828 byte_len,
15829 "read context pack bytes",
15830 );
15831 Ok(NodeResponse::ContextPackBytesRead {
15832 pack: ContextPackBytesRead {
15833 digest,
15834 id,
15835 byte_len,
15836 bytes,
15837 },
15838 })
15839 }
15840 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
15841 let provider = controlled_session(shared, connection_id, role, &session)?;
15842 if let Some(prompt) = initial_prompt.as_deref() {
15843 validate_node_text("resume initial prompt", prompt)?;
15844 }
15845 let runtime_requirement = if initial_prompt.is_some() {
15846 ProviderRuntimeRequirement::ResumeWithPrompt
15847 } else {
15848 ProviderRuntimeRequirement::Resume
15849 };
15850 shared.require_session_runtime_policy(&session, runtime_requirement)?;
15851 let runtime_policy = shared
15852 .admit_provider_runtime(&provider, runtime_requirement)
15853 .await?;
15854 let working_directory = shared.bound_workspace_root(&session)?;
15855 let request = ResumeLaunchRequest {
15856 working_directory,
15857 terminal_size,
15858 initial_prompt,
15859 };
15860 request
15861 .validate()
15862 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
15863 let command = shared.prepare_command(ControlCommand::Resume {
15864 instance_id: session.session.instance_id,
15865 target: ResumeTarget::CurrentProvider,
15866 runtime_policy,
15867 request,
15868 });
15869 shared.arm_resume(&session, command.id, runtime_policy)?;
15870 let dispatch = shared.dispatch_envelope(command);
15871 if let Err(error) = dispatch {
15872 shared.clear_armed_resume(&session);
15873 return Err(error);
15874 }
15875 Ok(NodeResponse::Accepted)
15876 }
15877 NodeRequest::RenameSessionRecord {
15878 record_id,
15879 display_name,
15880 } => {
15881 shared.require_controller(connection_id, role)?;
15882 let record = shared.rename_session_record(&record_id, display_name)?;
15883 Ok(NodeResponse::SessionRecordUpdated { record })
15884 }
15885 NodeRequest::SetSessionTask {
15886 record_id,
15887 expected_revision,
15888 target,
15889 } => {
15890 shared.require_controller(connection_id, role)?;
15891 let record = shared.set_session_task(&record_id, expected_revision, target)?;
15892 Ok(NodeResponse::SessionRecordUpdated { record })
15893 }
15894 NodeRequest::IndexProviderSession {
15895 workspace_id,
15896 provider,
15897 identity,
15898 display_name,
15899 } => {
15900 shared.require_controller(connection_id, role)?;
15901 let record = shared.index_provider_session(
15902 workspace_id,
15903 provider,
15904 identity,
15905 display_name,
15906 )?;
15907 Ok(NodeResponse::ProviderSessionIndexed { record })
15908 }
15909 NodeRequest::ResumeSessionRecord {
15910 record_id,
15911 terminal_size,
15912 initial_prompt,
15913 } => {
15914 shared.require_controller(connection_id, role)?;
15915 if let Some(prompt) = initial_prompt.as_deref() {
15916 validate_node_text("managed resume initial prompt", prompt)?;
15917 }
15918 let (record, session) = shared
15919 .resume_session_record(
15920 &record_id,
15921 terminal_size,
15922 initial_prompt,
15923 )
15924 .await?;
15925 Ok(NodeResponse::SessionRecordResumed { record, session })
15926 }
15927 NodeRequest::ForgetSessionRecord { record_id } => {
15928 shared.require_controller(connection_id, role)?;
15929 shared.forget_session_record(&record_id).await?;
15930 Ok(NodeResponse::SessionRecordForgotten { record_id })
15931 }
15932 NodeRequest::Prompt { session, text } => {
15933 let agent_id = controlled_session(shared, connection_id, role, &session)?;
15934 validate_node_text("prompt", &text)?;
15935 shared.require_session_runtime_policy(
15936 &session,
15937 ProviderRuntimeRequirement::SemanticPrompt,
15938 )?;
15939 let framing = prompt_framing(&agent_id);
15940 shared
15941 .dispatch_input_bounded(
15942 &session,
15943 InputAction::SubmitPrompt(PromptPayload { text, framing }),
15944 )
15945 .await?;
15946 Ok(NodeResponse::Accepted)
15947 }
15948 NodeRequest::Paste { session, text } => {
15949 controlled_session(shared, connection_id, role, &session)?;
15950 validate_node_text("paste", &text)?;
15951 shared.require_session_runtime_policy(
15952 &session,
15953 ProviderRuntimeRequirement::SemanticPrompt,
15954 )?;
15955 shared
15956 .dispatch_input_bounded(
15957 &session,
15958 InputAction::InsertDraft(PromptPayload {
15959 text,
15960 framing: PromptFraming::BracketedPaste,
15961 }),
15962 )
15963 .await?;
15964 Ok(NodeResponse::Accepted)
15965 }
15966 NodeRequest::Input { session, text } => {
15967 controlled_session(shared, connection_id, role, &session)?;
15968 validate_node_text("terminal input", &text)?;
15969 shared
15970 .dispatch_input_bounded(
15971 &session,
15972 InputAction::TerminalText(TerminalText { text }),
15973 )
15974 .await?;
15975 Ok(NodeResponse::Accepted)
15976 }
15977 NodeRequest::TerminalBytes { session, bytes } => {
15978 controlled_session(shared, connection_id, role, &session)?;
15979 let action = terminal_bytes_action(bytes)?;
15980 shared
15981 .dispatch_input_bounded(&session, action)
15982 .await?;
15983 Ok(NodeResponse::Accepted)
15984 }
15985 NodeRequest::TerminalControl { session, control } => {
15986 controlled_session(shared, connection_id, role, &session)?;
15987 shared
15988 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
15989 .await?;
15990 Ok(NodeResponse::Accepted)
15991 }
15992 NodeRequest::Resize { session, size } => {
15993 controlled_session(shared, connection_id, role, &session)?;
15994 if !size.is_valid() {
15995 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
15996 }
15997 shared.dispatch_resize_bounded(&session, size).await?;
15998 Ok(NodeResponse::Accepted)
15999 }
16000 NodeRequest::Interrupt { session } => {
16001 controlled_session(shared, connection_id, role, &session)?;
16002 shared
16003 .dispatch_input_bounded(
16004 &session,
16005 InputAction::TerminalControl(TerminalControl::Interrupt),
16006 )
16007 .await?;
16008 Ok(NodeResponse::Accepted)
16009 }
16010 NodeRequest::Stop { session, force } => {
16011 controlled_session(shared, connection_id, role, &session)?;
16012 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16013 Ok(NodeResponse::Accepted)
16014 }
16015 NodeRequest::Remove { session } => {
16016 controlled_session(shared, connection_id, role, &session)?;
16017 shared.remove_session(&session).await?;
16018 Ok(NodeResponse::Accepted)
16019 }
16020 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16021 shared.require_controller(connection_id, role)?;
16022 let snapshot = shared.require_acp_session(&session)?;
16023 let interaction = snapshot
16024 .provider
16025 .interactions
16026 .iter()
16027 .find(|interaction| {
16028 interaction_correlation(
16029 session.session.instance_id,
16030 session.session.generation,
16031 interaction.id.0,
16032 ) == correlation_id
16033 })
16034 .ok_or_else(|| failure(
16035 NodeFailureCode::InvalidRequest,
16036 "unknown interaction correlation",
16037 ))?;
16038 if interaction.status != ProviderInteractionStatus::Pending {
16039 return Err(failure(
16040 NodeFailureCode::BackendOperationFailed,
16041 "interaction has already been resolved",
16042 ));
16043 }
16044 response
16045 .validate_for(interaction.interaction_kind)
16046 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16047 let interaction_id = interaction.id;
16048 let response_kind = response.kind();
16049 let response_outcome = response.outcome();
16050 shared.dispatch(ControlCommand::ResolveInteraction {
16051 instance_id: session.session.instance_id,
16052 generation: session.session.generation,
16053 interaction_id,
16054 response,
16055 })?;
16056 tracing::info!(
16062 node_id = %shared.node_id,
16063 session = ?session.session,
16064 correlation_id = %correlation_id,
16065 interaction_id = interaction_id.0,
16066 response_kind = ?response_kind,
16067 outcome = ?response_outcome,
16068 "interaction resolved",
16069 );
16070 Ok(NodeResponse::Accepted)
16071 }
16072 NodeRequest::SetSessionMode { session, mode_id } => {
16073 shared.require_controller(connection_id, role)?;
16074 shared.require_acp_session(&session)?;
16075 shared.dispatch(ControlCommand::SetSessionMode {
16076 instance_id: session.session.instance_id,
16077 mode_id,
16078 })?;
16079 Ok(NodeResponse::Accepted)
16080 }
16081 NodeRequest::SetSessionConfigOption {
16082 session,
16083 option_id,
16084 value_json,
16085 } => {
16086 shared.require_controller(connection_id, role)?;
16087 shared.require_acp_session(&session)?;
16088 shared.dispatch(ControlCommand::SetSessionConfigOption {
16089 instance_id: session.session.instance_id,
16090 option_id,
16091 value_json,
16092 })?;
16093 Ok(NodeResponse::Accepted)
16094 }
16095 NodeRequest::SetSessionModel { session, model_id } => {
16096 shared.require_controller(connection_id, role)?;
16097 shared.require_acp_session(&session)?;
16098 shared.dispatch(ControlCommand::SetSessionModel {
16099 instance_id: session.session.instance_id,
16100 model_id,
16101 })?;
16102 Ok(NodeResponse::Accepted)
16103 }
16104 NodeRequest::Shutdown => {
16105 shared.require_controller(connection_id, role)?;
16106 shared.begin_shutdown_locked().await?;
16107 Ok(NodeResponse::ShuttingDown)
16108 }
16109 }
16110}
16111
16112fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16113 shared.require_controller(connection_id, role)?;
16114 shared.validate_address(session)
16115}
16116
16117fn select_history_candidate(
16123 candidates: &[HistoryCandidateSummary],
16124 provider_session_id: Option<&str>,
16125) -> Result<String, NodeFailure> {
16126 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16127 Some(id) => candidate.session_id_hint == id,
16128 None => true,
16129 });
16130 let candidate = matches.next().ok_or_else(|| {
16131 failure(
16132 NodeFailureCode::SessionRecordConflict,
16133 if provider_session_id.is_some() {
16134 "no history candidate matches the managed provider session"
16135 } else {
16136 "no history candidate exists for the managed session"
16137 },
16138 )
16139 })?;
16140 if matches.next().is_some() {
16141 return Err(failure(
16142 NodeFailureCode::SessionRecordConflict,
16143 if provider_session_id.is_some() {
16144 "history identity is ambiguous for the managed provider session"
16145 } else {
16146 "history is ambiguous without a verified provider session identity"
16147 },
16148 ));
16149 }
16150 Ok(candidate.id.clone())
16151}
16152
16153fn resolved_spawn_profile_summary(
16154 profile: &SpawnProfileDefaults,
16155 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16156) -> Option<SpawnProfileSummary> {
16157 let environment_profile = match profile.environment_profile_id.as_ref() {
16158 None => None,
16159 Some(profile_id) => {
16160 let binding = environment_profiles.get(profile_id)?;
16161 if binding.provider != profile.provider
16162 || binding.native_profile_id(profile.mode).is_none()
16163 {
16164 return None;
16165 }
16166 Some(ResolvedEnvironmentProfileReceipt {
16167 profile_id: binding.id.clone(),
16168 profile_revision: binding.revision.clone(),
16169 network_allowlist: None,
16170 browser_profile_id: None,
16171 })
16172 }
16173 };
16174 Some(SpawnProfileSummary {
16175 id: profile.profile_id.clone(),
16176 revision: profile.revision.clone(),
16177 environment_profile,
16178 })
16179}
16180
16181fn session_record_export_target_matches(
16200 expected: &ManagedSessionRecord,
16201 current: &ManagedSessionRecord,
16202 session: &SessionAddress,
16203 allow_clean_detachment: bool,
16204) -> bool {
16205 if current == expected && current.active_session.as_ref() == Some(session) {
16206 return current.state == ManagedSessionState::Live;
16207 }
16208 allow_clean_detachment
16209 && current.state == ManagedSessionState::Dormant
16210 && current.active_session.is_none()
16211 && current.record_id == expected.record_id
16212 && current.display_name == expected.display_name
16213 && current.provider == expected.provider
16214 && current.mode == expected.mode
16215 && current.workspace_id == expected.workspace_id
16216 && current.canonical_root == expected.canonical_root
16217 && current.provider_session == expected.provider_session
16218 && current.environment_profile == expected.environment_profile
16219 && current.bundle == expected.bundle
16220 && current.context_id == expected.context_id
16221 && current.context == expected.context
16222 && current.exported_context == expected.exported_context
16223 && current.task_binding == expected.task_binding
16224 && current.created_at_unix_ms == expected.created_at_unix_ms
16225 && current.last_error == expected.last_error
16226}
16227
16228fn session_record_context_export_binding_is_exact(
16229 expected: &ManagedSessionRecord,
16230 current: &ManagedSessionRecord,
16231 identity: Option<&ProviderSessionIdentity>,
16232 session: &SessionAddress,
16233 binding: &SessionBinding,
16234 provider: &AgentId,
16235 current_root: &str,
16236 allow_clean_detachment: bool,
16237) -> bool {
16238 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
16239 && current.provider_session.as_ref() == identity
16240 && current.workspace_id == session.workspace_id
16241 && provider == ¤t.provider
16242 && binding.workspace_id == session.workspace_id
16243 && binding.generation == session.session.generation
16244 && binding.record_id.as_ref() == Some(¤t.record_id)
16245 && platform::roots_equal(
16246 current_root,
16247 windows_path_text(¤t.canonical_root),
16248 )
16249}
16250
16251fn session_record_context_export_source_is_usable(
16252 record: &ManagedSessionRecord,
16253 status: &SessionStatus,
16254 allow_clean_detachment: bool,
16255) -> bool {
16256 (record.state == ManagedSessionState::Live
16257 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
16258 && context_pack_source_status_is_usable(status)
16259}
16260
16261fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
16262 PromptFraming::BracketedPaste
16263}
16264
16265fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
16266 if text.len() > MAX_NODE_TEXT_BYTES {
16267 return Err(failure(
16268 NodeFailureCode::InvalidRequest,
16269 &format!(
16270 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
16271 text.len(),
16272 ),
16273 ));
16274 }
16275 Ok(())
16276}
16277
16278fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
16279 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
16280 return Err(failure(
16281 NodeFailureCode::InvalidRequest,
16282 &format!(
16283 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
16284 bytes.len(),
16285 ),
16286 ));
16287 }
16288 Ok(())
16289}
16290
16291fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
16292 validate_terminal_bytes(&bytes)?;
16293 Ok(InputAction::TerminalBytes(bytes))
16294}
16295
16296fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
16297 if root.is_empty()
16298 || root.len() > MAX_WORKSPACE_ROOT_BYTES
16299 || root.chars().any(char::is_control)
16300 {
16301 return Err(failure(
16302 NodeFailureCode::InvalidWorkspaceRoot,
16303 &format!(
16304 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
16305 ),
16306 ));
16307 }
16308 Ok(())
16309}
16310
16311fn canonical_standalone_workspace_candidate(
16312 workspace_id: &WorkspaceId,
16313 root: &str,
16314) -> Result<String, NodeServerError> {
16315 let path = Path::new(root);
16316 match std::fs::symlink_metadata(path) {
16317 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
16318 .map(|workspace| workspace.canonical_root),
16319 Err(error) if error.kind() == io::ErrorKind::NotFound => {
16320 if !path.is_absolute() {
16321 return Err(NodeServerError::InvalidWorkspaceRoot {
16322 workspace_id: workspace_id.clone(),
16323 path: root.to_owned(),
16324 message: "path is not absolute".to_owned(),
16325 });
16326 }
16327 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16328 workspace_id: workspace_id.clone(),
16329 path: root.to_owned(),
16330 message: "path has no standalone directory name".to_owned(),
16331 })?;
16332 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16333 workspace_id: workspace_id.clone(),
16334 path: root.to_owned(),
16335 message: "path has no parent directory".to_owned(),
16336 })?;
16337 let canonical = std::fs::canonicalize(parent)
16338 .map(|parent| parent.join(name))
16339 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
16340 workspace_id: workspace_id.clone(),
16341 path: root.to_owned(),
16342 message: error.to_string(),
16343 })?;
16344 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
16345 NodeServerError::InvalidWorkspaceRoot {
16346 workspace_id: workspace_id.clone(),
16347 path: path.to_string_lossy().into_owned(),
16348 message: "canonical path is not valid Unicode".to_owned(),
16349 }
16350 })?;
16351 let canonical_root = platform::normalize_canonical_root(canonical_root);
16352 validate_workspace_root(workspace_id, &canonical_root)?;
16353 Ok(canonical_root)
16354 }
16355 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
16356 workspace_id: workspace_id.clone(),
16357 path: root.to_owned(),
16358 message: error.to_string(),
16359 }),
16360 }
16361}
16362
16363fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
16364 let Some(revision) = revision else {
16365 return Ok(());
16366 };
16367 if revision.is_empty()
16368 || revision.len() > MAX_REPOSITORY_PATH_BYTES
16369 || revision.chars().any(char::is_control)
16370 {
16371 return Err(failure(
16372 NodeFailureCode::InvalidRequest,
16373 &format!(
16374 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
16375 ),
16376 ));
16377 }
16378 Ok(())
16379}
16380
16381fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16382 let code = match error.kind {
16383 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
16384 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
16385 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
16386 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
16387 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
16388 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
16389 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
16390 };
16391 failure(code, &error.message)
16392}
16393
16394fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
16395 let code = match error.kind {
16396 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
16397 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
16398 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
16399 NodeFailureCode::BackendOperationFailed
16400 }
16401 StandaloneWorkspaceErrorKind::RecoveryRequired => {
16402 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
16403 }
16404 };
16405 failure(code, error.message)
16406}
16407
16408fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16409 let (code, message) = match error.kind {
16410 GitWorktreeErrorKind::Invalid => (
16411 NodeFailureCode::InvalidRequest,
16412 "managed Git input is invalid",
16413 ),
16414 GitWorktreeErrorKind::NotRepository => (
16415 NodeFailureCode::NotGitRepository,
16416 "managed source is not a Git repository",
16417 ),
16418 GitWorktreeErrorKind::Conflict => (
16419 NodeFailureCode::WorktreeConflict,
16420 "managed Git identity conflicts with existing repository state",
16421 ),
16422 GitWorktreeErrorKind::Protected => (
16423 NodeFailureCode::ManagedWorktreeOwnershipConflict,
16424 "managed worktree ownership validation failed",
16425 ),
16426 GitWorktreeErrorKind::Dirty => (
16427 NodeFailureCode::WorktreeDirty,
16428 "managed worktree contains uncommitted changes",
16429 ),
16430 GitWorktreeErrorKind::Locked => (
16431 NodeFailureCode::WorktreeLocked,
16432 "managed worktree is locked",
16433 ),
16434 GitWorktreeErrorKind::Failed => (
16435 NodeFailureCode::BackendOperationFailed,
16436 "managed Git operation failed",
16437 ),
16438 };
16439 NodeFailure { code, message: message.to_owned() }
16440}
16441
16442fn managed_spawn_receipt(
16443 resolved: &ResolvedSpawnSpec,
16444 incarnation_id: NodeIncarnationId,
16445 session: SessionAddress,
16446 lease: ManagedWorktreeLeaseSnapshot,
16447 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
16448 bundle: Option<ResolvedBundleReceipt>,
16449 context: Option<ResolvedContextPackReceipt>,
16450) -> ManagedWorktreeSpawnReceipt {
16451 let mut managed = resolved.clone();
16452 managed.target.worktree_id = Some(lease.workspace_id.clone());
16453 ManagedWorktreeSpawnReceipt {
16454 spawn: managed.receipt_with_materialization(
16455 incarnation_id,
16456 session,
16457 environment_profile,
16458 bundle,
16459 context,
16460 ),
16461 lease,
16462 }
16463}
16464
16465fn validate_selected_worktree(
16466 source_root: &str,
16467 selected_root: &str,
16468 worktrees: &[NativeGitWorktreeSnapshot],
16469) -> Result<(), NodeFailure> {
16470 if !worktrees
16471 .iter()
16472 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
16473 {
16474 return Err(failure(
16475 NodeFailureCode::WorktreeProtected,
16476 "source workspace is not an authoritative non-bare Git worktree root",
16477 ));
16478 }
16479 if !worktrees.iter().any(|worktree| {
16480 worktree_paths_equal(&worktree.path, selected_root)
16481 && !worktree.is_main
16482 && !worktree.is_bare
16483 }) {
16484 return Err(failure(
16485 NodeFailureCode::WorktreeProtected,
16486 "selected workspace is not an eligible Git worktree in the source repository",
16487 ));
16488 }
16489 Ok(())
16490}
16491
16492fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
16493 let code = match error.kind() {
16494 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16495 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16496 NodeFailureCode::RepositoryPathUnsafe
16497 }
16498 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16499 WorkspaceFileReadErrorKind::NotRegularFile => {
16500 NodeFailureCode::RepositoryFileNotRegular
16501 }
16502 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16503 NodeFailureCode::RepositoryFileReadFailed
16504 }
16505 WorkspaceFileReadErrorKind::RevisionConflict => {
16506 NodeFailureCode::RepositoryFileRevisionConflict
16507 }
16508 WorkspaceFileReadErrorKind::AlreadyExists
16509 | WorkspaceFileReadErrorKind::ParentNotFound
16510 | WorkspaceFileReadErrorKind::ParentNotDirectory
16511 | WorkspaceFileReadErrorKind::Canceled => {
16512 NodeFailureCode::RepositoryFileReadFailed
16513 }
16514 };
16515 failure(code, "repository file read failed")
16516}
16517
16518fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
16519 let code = match error.kind() {
16520 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16521 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16522 NodeFailureCode::RepositoryPathUnsafe
16523 }
16524 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16525 WorkspaceFileReadErrorKind::NotRegularFile => {
16526 NodeFailureCode::RepositoryFileNotRegular
16527 }
16528 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16529 NodeFailureCode::RepositoryFileWriteFailed
16530 }
16531 WorkspaceFileReadErrorKind::RevisionConflict => {
16532 NodeFailureCode::RepositoryFileRevisionConflict
16533 }
16534 WorkspaceFileReadErrorKind::AlreadyExists
16535 | WorkspaceFileReadErrorKind::ParentNotFound
16536 | WorkspaceFileReadErrorKind::ParentNotDirectory
16537 | WorkspaceFileReadErrorKind::Canceled => {
16538 NodeFailureCode::RepositoryFileWriteFailed
16539 }
16540 };
16541 failure(code, "repository file write failed")
16542}
16543
16544async fn settle_workspace_entry_create<T>(
16545 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
16546 commit_state: Arc<AtomicU8>,
16547 deadline: Duration,
16548 timeout_message: &'static str,
16549 task_failure_message: &'static str,
16550) -> Result<T, NodeFailure> {
16551 let joined = match timeout(deadline, &mut task).await {
16552 Ok(joined) => joined,
16553 Err(_) => {
16554 if commit_state
16555 .compare_exchange(
16556 WORKSPACE_ENTRY_CREATE_PENDING,
16557 WORKSPACE_ENTRY_CREATE_CANCELED,
16558 Ordering::AcqRel,
16559 Ordering::Acquire,
16560 )
16561 .is_ok()
16562 {
16563 return Err(failure(
16564 NodeFailureCode::RepositoryEntryCreateTimedOut,
16565 timeout_message,
16566 ));
16567 }
16568 debug_assert_eq!(
16569 commit_state.load(Ordering::Acquire),
16570 WORKSPACE_ENTRY_CREATE_COMMITTING,
16571 );
16572 task.await
16573 }
16574 };
16575 joined
16576 .map_err(|_| {
16577 failure(
16578 NodeFailureCode::RepositoryEntryCreateFailed,
16579 task_failure_message,
16580 )
16581 })?
16582 .map_err(workspace_entry_create_failure)
16583}
16584
16585fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
16586 let code = match error.kind() {
16587 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16588 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16589 NodeFailureCode::RepositoryPathUnsafe
16590 }
16591 WorkspaceFileReadErrorKind::AlreadyExists => {
16592 NodeFailureCode::RepositoryEntryAlreadyExists
16593 }
16594 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
16595 NodeFailureCode::RepositoryParentNotFound
16596 }
16597 WorkspaceFileReadErrorKind::ParentNotDirectory
16598 | WorkspaceFileReadErrorKind::NotRegularFile => {
16599 NodeFailureCode::RepositoryParentNotDirectory
16600 }
16601 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16602 NodeFailureCode::RepositoryEntryCreateFailed
16603 }
16604 WorkspaceFileReadErrorKind::RevisionConflict => {
16605 NodeFailureCode::RepositoryEntryCreateFailed
16606 }
16607 WorkspaceFileReadErrorKind::Canceled => {
16608 NodeFailureCode::RepositoryEntryCreateTimedOut
16609 }
16610 };
16611 failure(code, "repository entry create failed")
16612}
16613
16614fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
16615 let value = digest(&SHA256, bytes)
16616 .as_ref()
16617 .iter()
16618 .map(|byte| format!("{byte:02x}"))
16619 .collect();
16620 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
16621}
16622
16623fn managed_spawn_request_digest_v2(
16624 request: &ManagedWorktreeSpawnRequestV2,
16625) -> Result<String, NodeFailure> {
16626 let encoded = serde_json::to_vec(request).map_err(|_| {
16627 failure(
16628 NodeFailureCode::InvalidRequest,
16629 "managed worktree V2 request could not be canonicalized",
16630 )
16631 })?;
16632 let mut value = String::with_capacity(71);
16633 value.push_str("sha256:");
16634 for byte in digest(&SHA256, &encoded).as_ref() {
16635 use std::fmt::Write as _;
16636 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
16637 }
16638 Ok(value)
16639}
16640
16641fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
16642 let code = match error {
16643 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
16644 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
16645 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
16646 NativeSessionPreviewError::UnsupportedProvider
16647 | NativeSessionPreviewError::PreviewUnavailable
16648 | NativeSessionPreviewError::SessionNotFound
16649 | NativeSessionPreviewError::AmbiguousSession => {
16650 NodeFailureCode::BackendOperationFailed
16651 }
16652 };
16653 failure(code, "native session preview failed")
16654}
16655
16656fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
16657 let code = match error.kind() {
16658 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
16659 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
16660 };
16661 failure(code, "host directory browse failed")
16662}
16663
16664fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
16665 let code = match error {
16666 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
16667 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
16668 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
16669 NodeFailureCode::DeliveryStageConflict
16670 }
16671 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
16672 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
16673 NodeFailureCode::DeliveryChunkOutOfOrder
16674 }
16675 DeliveryStoreError::BlobDigestMismatch => {
16676 NodeFailureCode::DeliveryBlobDigestMismatch
16677 }
16678 DeliveryStoreError::BundleDigestMismatch => {
16679 NodeFailureCode::DeliveryBundleDigestMismatch
16680 }
16681 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
16682 DeliveryStoreError::Unavailable
16683 | DeliveryStoreError::Corrupt
16684 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
16685 };
16686 failure(code, "delivery operation failed")
16687}
16688
16689fn validate_workspace_root(
16690 workspace_id: &WorkspaceId,
16691 canonical_root: &str,
16692) -> Result<(), NodeServerError> {
16693 #[cfg(windows)]
16694 if canonical_root.starts_with(r"\\") {
16695 return Err(NodeServerError::InvalidWorkspaceRoot {
16696 workspace_id: workspace_id.clone(),
16697 path: canonical_root.to_owned(),
16698 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
16699 });
16700 }
16701 if !platform::workspace_root_supported(canonical_root)
16702 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
16703 {
16704 return Err(NodeServerError::InvalidWorkspaceRoot {
16705 workspace_id: workspace_id.clone(),
16706 path: canonical_root.to_owned(),
16707 message: "canonical path must be absolute, bounded, and free of control characters"
16708 .to_owned(),
16709 });
16710 }
16711 Ok(())
16712}
16713
16714fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
16715 NodeFailure {
16716 code,
16717 message: node_failure_category(code).to_owned(),
16718 }
16719}
16720
16721fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
16722 let code = match error {
16723 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
16724 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
16725 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
16726 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
16727 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
16728 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
16729 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
16730 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
16731 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
16732 };
16733 failure(code, "harness MCP proxy operation failed")
16734}
16735
16736static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
16740
16741fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
16748 let trace = launch.trace.as_ref()?;
16749 let trace_dir = std::env::var(&trace.dir_env).ok()?;
16750 if trace_dir.is_empty() {
16751 return None;
16752 }
16753 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
16754 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
16755 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
16756}
16757
16758fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
16759 let remaining = deadline.saturating_duration_since(Instant::now());
16760 if remaining.is_zero() {
16761 Err(failure(
16762 NodeFailureCode::SpawnDeadlineExceeded,
16763 "spawn deadline elapsed",
16764 ))
16765 } else {
16766 Ok(remaining)
16767 }
16768}
16769
16770fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
16771 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
16772 match deadline {
16773 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
16774 None => Ok(bounded),
16775 }
16776}
16777
16778fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
16779 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
16780 failure(
16781 NodeFailureCode::SpawnDeadlineExceeded,
16782 "spawn dispatch exceeded the spawn deadline",
16783 )
16784 } else {
16785 error
16786 }
16787}
16788
16789fn spawn_runtime_capabilities(
16790 capabilities: &SpawnRequiredCapabilities,
16791) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
16792 capabilities
16793 .iter()
16794 .map(|capability| match capability.as_str() {
16795 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
16796 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
16797 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
16798 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
16799 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
16800 }
16801 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
16802 _ => Err(failure(
16803 NodeFailureCode::UnsupportedSpawnCapability,
16804 "spawn required capability is unknown",
16805 )),
16806 })
16807 .collect()
16808}
16809
16810fn persistence_failure(_error: io::Error) -> NodeFailure {
16811 NodeFailure {
16812 code: NodeFailureCode::BackendOperationFailed,
16813 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
16814 }
16815}
16816
16817fn node_failure_category(code: NodeFailureCode) -> &'static str {
16818 match code {
16819 NodeFailureCode::InvalidRequest => "invalid-request",
16820 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
16821 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
16822 NodeFailureCode::ReservationNotFound => "reservation-not-found",
16823 NodeFailureCode::ReservationConflict => "reservation-conflict",
16824 NodeFailureCode::ReservationExpired => "reservation-expired",
16825 NodeFailureCode::BindingMismatch => "binding-mismatch",
16826 NodeFailureCode::NotActivated => "not-activated",
16827 NodeFailureCode::CallNotFound => "call-not-found",
16828 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
16829 NodeFailureCode::ResponseTooLarge => "response-too-large",
16830 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
16831 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
16832 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
16833 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
16834 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
16835 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
16836 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
16837 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
16838 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
16839 NodeFailureCode::Unauthorized => "unauthorized",
16840 NodeFailureCode::ObserverReadOnly => "observer-read-only",
16841 NodeFailureCode::ControllerBusy => "controller-busy",
16842 NodeFailureCode::ControllerRequired => "controller-required",
16843 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
16844 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
16845 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
16846 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
16847 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
16848 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
16849 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
16850 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
16851 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
16852 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
16853 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
16854 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
16855 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
16856 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
16857 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
16858 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
16859 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
16860 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
16861 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
16862 NodeFailureCode::GitReadFailed => "git-read-failed",
16863 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
16864 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
16865 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
16866 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
16867 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
16868 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
16869 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
16870 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
16871 NodeFailureCode::TurnInFlight => "turn-in-flight",
16872 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
16873 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
16874 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
16875 "unsupported-network-allowlist-mapping"
16876 }
16877 NodeFailureCode::BrowserStationProbeUnavailable => {
16878 "browser-station-probe-unavailable"
16879 }
16880 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
16881 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
16882 NodeFailureCode::EnvironmentProfileBindingMismatch => {
16883 "environment-profile-binding-mismatch"
16884 }
16885 NodeFailureCode::UnknownBundle => "unknown-bundle",
16886 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
16887 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
16888 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
16889 NodeFailureCode::ContextPackBusy => "context-pack-busy",
16890 NodeFailureCode::ContextPackMaterializationFailed => {
16891 "context-pack-materialization-failed"
16892 }
16893 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
16894 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
16895 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
16896 NodeFailureCode::WorkspaceBusy => "workspace-busy",
16897 NodeFailureCode::LastWorkspace => "last-workspace",
16898 NodeFailureCode::NotGitRepository => "not-git-repository",
16899 NodeFailureCode::WorktreeConflict => "worktree-conflict",
16900 NodeFailureCode::WorktreeProtected => "worktree-protected",
16901 NodeFailureCode::WorktreeDirty => "worktree-dirty",
16902 NodeFailureCode::WorktreeLocked => "worktree-locked",
16903 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
16904 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
16905 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
16906 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
16907 "managed-worktree-profile-revision-mismatch"
16908 }
16909 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
16910 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
16911 "standalone-workspace-recovery-required"
16912 }
16913 NodeFailureCode::UnknownSession => "unknown-session",
16914 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
16915 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
16916 NodeFailureCode::SessionRecordBusy => "session-record-busy",
16917 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
16918 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
16919 NodeFailureCode::WorkspaceRegistrationRequired => {
16920 "workspace-registration-required"
16921 }
16922 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
16923 NodeFailureCode::StaleGeneration => "stale-generation",
16924 NodeFailureCode::BackendBusy => "backend-busy",
16925 NodeFailureCode::BackendDisconnected => "backend-disconnected",
16926 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
16927 NodeFailureCode::ShuttingDown => "shutting-down",
16928 }
16929}
16930
16931fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
16932 NodeServerError::DurableState(io::Error::new(error.kind(), category))
16933}
16934
16935fn durable_state_load_error(error: io::Error) -> NodeServerError {
16936 let category = match session_registry::state_load_refusal(&error) {
16937 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
16938 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
16939 }
16940 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
16941 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
16942 }
16943 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
16944 DURABLE_STATE_CONFLICT_ERROR
16945 }
16946 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
16947 };
16948 durable_state_server_error(error, category)
16949}
16950
16951fn unix_time_ms() -> u64 {
16952 std::time::SystemTime::now()
16953 .duration_since(std::time::UNIX_EPOCH)
16954 .unwrap_or_default()
16955 .as_millis()
16956 .min(u64::MAX as u128) as u64
16957}
16958
16959fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
16960 if record.provider_session.is_some() {
16961 ManagedSessionState::Dormant
16962 } else {
16963 ManagedSessionState::Unavailable
16964 }
16965}
16966
16967fn active_record_state(
16968 identity_admitted: bool,
16969 provider_session_is_known: bool,
16970) -> ManagedSessionState {
16971 if identity_admitted && !provider_session_is_known {
16972 ManagedSessionState::IdentityPending
16973 } else {
16974 ManagedSessionState::Live
16975 }
16976}
16977
16978fn managed_worktree_record_holders(
16979 records: &[ManagedSessionRecord],
16980) -> Vec<(SessionRecordId, WorkspaceId)> {
16981 records
16982 .iter()
16983 .filter(|record| record.provider_session.is_some())
16984 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
16985 .collect()
16986}
16987
16988fn managed_spawn_record_ownership(
16989 binding: &SessionBinding,
16990 effective_runtime_policy: ProviderRuntimePolicy,
16991) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
16992 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
16993 if effective_runtime_policy.provider_session_identity {
16994 (binding.record_id.clone(), None)
16995 } else {
16996 (None, binding.record_id.clone())
16997 }
16998}
16999
17000#[derive(Debug, Error)]
17001pub enum NodeServerError {
17002 #[error("node endpoint must be a bounded absolute local transport path")]
17003 InvalidEndpoint,
17004 #[error("node access token must contain 1..=4096 bytes")]
17005 InvalidAccessToken,
17006 #[error("node HTTP observer must listen on a loopback address: {0}")]
17007 InvalidApiListen(std::net::SocketAddr),
17008 #[error("node browser bridge must listen on a loopback address: {0}")]
17009 InvalidBridgeListen(std::net::SocketAddr),
17010 #[error("node browser bridge token must contain 1..=4096 bytes")]
17011 InvalidBridgeToken,
17012 #[error("node call-home relay must be a loopback address with a nonzero port: {0}")]
17013 InvalidCallHome(std::net::SocketAddr),
17014 #[error("node could not reach its call-home relay: {0}")]
17015 CallHomeTransport(#[source] std::io::Error),
17016 #[error("node durable state path must be an absolute file path: {0}")]
17017 InvalidStatePath(String),
17018 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17019 InvalidHarnessMcpHelper,
17020 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17021 InvalidNetworkAllowlistCatalogPath(String),
17022 #[error("network allowlist catalog could not be read: {0}")]
17023 NetworkAllowlistCatalogIo(#[source] std::io::Error),
17024 #[error("network allowlist catalog line {line}: {message}")]
17025 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17026 #[error("network allowlist catalog: {0}")]
17027 InvalidNetworkAllowlistCatalog(String),
17028 #[error("network allowlist catalog exceeds the {max}-entry limit")]
17029 NetworkAllowlistCatalogCapacity { max: usize },
17030 #[error("the local state directory is unavailable; supply an explicit state path")]
17031 LocalStateDirectoryUnavailable,
17032 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17033 LocalRuntimeDirectoryUnavailable,
17034 #[error("node durable state failed: {0}")]
17035 DurableState(io::Error),
17036 #[error("node requires at least one configured workspace")]
17037 NoWorkspaces,
17038 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17039 InvalidWorkspaceRoot {
17040 workspace_id: WorkspaceId,
17041 path: String,
17042 message: String,
17043 },
17044 #[error("duplicate workspace ID: {0}")]
17045 DuplicateWorkspaceId(WorkspaceId),
17046 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17047 DuplicateWorkspaceRoot {
17048 first: WorkspaceId,
17049 second: WorkspaceId,
17050 root: String,
17051 },
17052 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17053 ManagedWorktreeProfileRequired(WorkspaceId),
17054 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17055 ManagedWorktreeProfileModeMismatch(WorkspaceId),
17056 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17057 DuplicateManagedWorktreeProfile {
17058 workspace_id: WorkspaceId,
17059 profile_id: WorktreeProfileId,
17060 },
17061 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17062 ManagedWorktreeProfileCapacity {
17063 workspace_id: WorkspaceId,
17064 max: usize,
17065 },
17066 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17067 InvalidManagedWorktreeProfile {
17068 workspace_id: WorkspaceId,
17069 profile_id: WorktreeProfileId,
17070 message: String,
17071 },
17072 #[error("duplicate node environment profile: {0}")]
17073 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17074 #[error("node environment profile provider is unavailable: {0}")]
17075 EnvironmentProfileProviderUnavailable(AgentId),
17076 #[error("node environment profile capacity is {max}")]
17077 EnvironmentProfileCapacity { max: usize },
17078 #[error("node environment profiles reuse native profile ID '{0}'")]
17079 DuplicateNativeEnvironmentProfile(String),
17080 #[error("node native environment profile is invalid: {0}")]
17081 NativeEnvironmentProfile(String),
17082 #[error("node session-environment materialization root must be absolute")]
17083 InvalidSessionEnvironmentRoot,
17084 #[error("node session-environment materializer is unavailable")]
17085 SessionEnvironmentMaterializerRequired,
17086 #[error("node session-environment materializer failed to initialize")]
17087 SessionEnvironmentMaterializer,
17088 #[error("node bundle catalog is invalid: {0}")]
17089 BundleCatalog(String),
17090 #[error("node delivery store failed to initialize")]
17091 DeliveryStore,
17092 #[error("node context pack store failed to initialize")]
17093 ContextPackStore,
17094 #[error("active agent registry failed: {0}")]
17095 Registry(String),
17096 #[error("node provider contract manifest is invalid: {0}")]
17097 ProviderContractManifest(String),
17098 #[error("named pipe I/O failed: {0}")]
17099 Io(#[from] io::Error),
17100 #[error("node HTTP observer failed: {0}")]
17101 HttpApi(io::Error),
17102 #[error("node browser bridge failed: {0}")]
17103 BridgeApi(io::Error),
17104 #[error(transparent)]
17105 Frame(#[from] FrameError),
17106 #[error("node handshake failed: {0}")]
17107 Handshake(String),
17108 #[error("node authentication frame was not received before the bounded deadline")]
17109 AuthenticationTimedOut,
17110 #[error("node authentication primitive failed: {0}")]
17111 Authentication(String),
17112 #[error("node incarnation identity generation failed: {0}")]
17113 IncarnationIdentity(String),
17114 #[error("node pre-authentication limiter is closed")]
17115 PreauthClosed,
17116 #[error("node authenticated connection limit was reached")]
17117 AuthenticatedConnectionLimit,
17118 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17119 SignalStreamClosed,
17120 #[error("node stop-all dispatch failed: {0:?}")]
17121 ShutdownDispatch(NodeFailure),
17122 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17123 ShutdownTimedOut { active_native_sessions: usize },
17124}
17125
17126impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17127 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17128 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17129 match error {
17130 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17131 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17132 E::InvalidEntry { line, message } => {
17133 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17134 }
17135 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17136 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17137 }
17138 }
17139}
17140
17141#[cfg(test)]
17142mod agent_stream_blocked_tests {
17143 use super::*;
17144 use gate4agent_types::{
17145 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17146 ProviderSource,
17147 };
17148
17149 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17150 ProviderSource {
17151 family,
17152 binding: AdapterBinding::new(
17153 AdapterId::new(adapter).unwrap(),
17154 "fixture/v1",
17155 AdapterVerification::SyntheticFixture,
17156 )
17157 .unwrap(),
17158 }
17159 }
17160
17161 fn provider_control_event_at(
17162 family: AdapterFamily,
17163 adapter: &str,
17164 source_sequence: u64,
17165 provider_event: ProviderEvent,
17166 ) -> ControlEvent {
17167 ControlEvent {
17168 sequence: 41,
17169 command_id: None,
17170 instance_id: AgentInstanceId(7),
17171 generation: SessionGeneration(3),
17172 event: ControlEventKind::ProviderEvent {
17173 sequence: 8,
17174 source: provider_source(family, adapter),
17175 source_sequence,
17176 event: provider_event,
17177 },
17178 }
17179 }
17180
17181 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17182 provider_control_event_at(family, "fixture", 9, provider_event)
17183 }
17184
17185 fn denied_host_request(
17186 by: ProviderHostDecisionAuthority,
17187 reason: Option<String>,
17188 ) -> ControlEvent {
17189 provider_control_event_at(
17190 AdapterFamily::Acp,
17191 "claude-code",
17192 2,
17193 ProviderEvent::HostRequestObserved {
17194 method: "terminal/create".to_owned(),
17195 params_json: String::new(),
17196 decision: ProviderHostRequestDecision::Denied { by },
17197 outcome: ProviderHostRequestOutcome::Executed,
17198 reason,
17199 },
17200 )
17201 }
17202
17203 fn blocked(
17204 event: &ControlEvent,
17205 ) -> (
17206 Option<String>,
17207 String,
17208 BlockAuthorityV1,
17209 Option<String>,
17210 String,
17211 Option<String>,
17212 ) {
17213 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17214 chunk.validate().expect("minted chunk must validate");
17215 let AgentStreamChunkKindV1::Blocked {
17216 correlation_id,
17217 tool_class,
17218 authority,
17219 reason_kind,
17220 reason,
17221 help,
17222 } = chunk.kind
17223 else {
17224 panic!("expected AgentStreamChunkKindV1::Blocked");
17225 };
17226 (correlation_id, tool_class, authority, reason_kind, reason, help)
17227 }
17228
17229 #[test]
17233 fn the_enabled_provider_matrix_is_exact() {
17234 let registry = active_registry().unwrap();
17235 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
17236 assert_eq!(
17237 providers
17238 .iter()
17239 .map(|contract| contract.provider.as_str())
17240 .collect::<Vec<_>>(),
17241 vec!["claude", "codex", "grok", "kimi"],
17242 );
17243 assert!(adapters.iter().all(|contract| {
17244 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
17245 }));
17246 }
17247
17248 #[test]
17251 fn gate_denied_host_request_mints_a_blocked_chunk() {
17252 let gate_text =
17253 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
17254 let event = denied_host_request(
17255 ProviderHostDecisionAuthority::Gate,
17256 Some(gate_text.to_owned()),
17257 );
17258 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
17259 assert_eq!(chunk.source_sequence, 2);
17260 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
17261 assert_eq!(authority, BlockAuthorityV1::HostGate);
17262 assert_eq!(reason, gate_text);
17263 assert_eq!(correlation_id, None);
17264 assert_eq!(reason_kind, None);
17265 assert_eq!(help, None);
17266 assert_eq!(tool_class, "Write");
17267 }
17268
17269 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.";
17270
17271 #[test]
17277 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
17278 let event = provider_control_event(
17279 AdapterFamily::Acp,
17280 ProviderEvent::ToolCompleted {
17281 id: "toolu_9".to_owned(),
17282 output: CLASSIFIER_SAMPLE.to_owned(),
17283 is_error: true,
17284 duration_ms: None,
17285 agent_id: None,
17286 non_execution_kind: Some("permission-rule".to_owned()),
17287 },
17288 );
17289 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
17290 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
17291 assert_eq!(reason, "Blocked by classifier");
17292 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
17293 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
17294 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
17295 }
17296
17297 #[test]
17302 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
17303 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.";
17304 let event = provider_control_event(
17305 AdapterFamily::Acp,
17306 ProviderEvent::SessionEnded {
17307 result: "JSON-RPC error -32603: Internal error".to_owned(),
17308 cost_usd: None,
17309 is_error: true,
17310 stop_reason: Some(ProviderStopReason::ProviderError {
17311 code: -32603,
17312 message: message.to_owned(),
17313 vendor_code: Some("usageLimitExceeded".to_owned()),
17314 }),
17315 },
17316 );
17317 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
17318 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
17319 assert_eq!(reason, message);
17320 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
17321 assert_eq!(tool_class, "Session");
17322 }
17323
17324 #[test]
17327 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
17328 let event = provider_control_event(
17329 AdapterFamily::Acp,
17330 ProviderEvent::SessionEnded {
17331 result: "refusal".to_owned(),
17332 cost_usd: None,
17333 is_error: true,
17334 stop_reason: Some(ProviderStopReason::Refusal),
17335 },
17336 );
17337 let (_, _, authority, _, _, _) = blocked(&event);
17338 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
17339 }
17340
17341 #[test]
17345 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
17346 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
17347 let event = provider_control_event(
17348 AdapterFamily::Acp,
17349 ProviderEvent::ToolCompleted {
17350 id: "toolu_1".to_owned(),
17351 output: "command exited with status 1".to_owned(),
17352 is_error: true,
17353 duration_ms: Some(12),
17354 agent_id: None,
17355 non_execution_kind,
17356 },
17357 );
17358 assert!(agent_stream_chunk(&event).is_none());
17359 }
17360 let narrative = provider_control_event(
17361 AdapterFamily::Acp,
17362 ProviderEvent::ToolCompleted {
17363 id: "toolu_2".to_owned(),
17364 output: "the harness blocked the write, approval needed".to_owned(),
17365 is_error: false,
17366 duration_ms: Some(3),
17367 agent_id: None,
17368 non_execution_kind: None,
17369 },
17370 );
17371 assert!(agent_stream_chunk(&narrative).is_none());
17372 }
17373
17374 #[test]
17378 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
17379 for (decision, outcome) in [
17380 (
17381 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17382 ProviderHostRequestOutcome::Executed,
17383 ),
17384 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
17385 (
17386 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17387 ProviderHostRequestOutcome::Failed {
17388 error: "terminal/create spawn failed: os error 3".to_owned(),
17389 },
17390 ),
17391 ] {
17392 let event = provider_control_event_at(
17393 AdapterFamily::Acp,
17394 "claude-code",
17395 3,
17396 ProviderEvent::HostRequestObserved {
17397 method: "fs/read_text_file".to_owned(),
17398 params_json: String::new(),
17399 decision: decision.clone(),
17400 outcome,
17401 reason: None,
17402 },
17403 );
17404 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
17405 }
17406 }
17407
17408 #[test]
17412 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
17413 for (by, expected_authority, expected_reason) in [
17414 (
17415 ProviderHostDecisionAuthority::Operator,
17416 BlockAuthorityV1::Operator,
17417 "declined by the operator",
17418 ),
17419 (
17420 ProviderHostDecisionAuthority::DeadlinePolicy,
17421 BlockAuthorityV1::HostDeadline,
17422 "denied by host policy after the operator deadline expired",
17423 ),
17424 ] {
17425 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
17426 assert_eq!(authority, expected_authority);
17427 assert_eq!(reason, expected_reason);
17428 }
17429 }
17430
17431 #[test]
17435 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
17436 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
17437 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
17438 ProviderHostDecisionAuthority::Policy,
17439 Some(oversized),
17440 ));
17441 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
17442 }
17443}
17444
17445#[cfg(test)]
17446mod standalone_capability_platform_tests {
17447 use super::*;
17448
17449 #[test]
17450 fn standalone_workspace_capability_requires_windows_identity_guards() {
17451 let capabilities = baseline_capabilities().unwrap();
17452 let advertised = capabilities.iter().any(|capability| {
17453 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
17454 });
17455 assert_eq!(advertised, cfg!(windows));
17456
17457 let request = NodeRequest::CreateStandaloneWorkspace {
17458 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
17459 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
17460 initial_branch: None,
17461 };
17462 assert_eq!(
17463 request_uses_unnegotiated_capability(&request, &capabilities),
17464 !cfg!(windows),
17465 );
17466 }
17467}
17468
17469
17470#[cfg(test)]
17471mod station_knobs_resolve_tests {
17472 use super::*;
17473 use gate4agent_catalog::EnvMutation;
17474 use gate4agent_runtime_native::{
17475 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
17476 NativeLaunchProfileId,
17477 };
17478 use std::ffi::OsString;
17479 use std::sync::Arc;
17480
17481 struct EmptyEnvironmentResolver;
17482
17483 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
17484 fn resolve_child_environment(
17485 &self,
17486 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
17487 Ok(vec![EnvMutation {
17488 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
17489 value: None,
17490 }])
17491 }
17492 }
17493
17494 fn agent(value: &str) -> AgentId {
17495 AgentId::new(value).unwrap()
17496 }
17497
17498 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
17499 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
17500 let profile_revision =
17501 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
17502 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
17503 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
17504 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
17505 provider: agent("claude"),
17506 mode: SessionMode::Pty,
17507 terminal_size: gate4agent_types::TerminalSize {
17508 rows: 24,
17509 columns: 80,
17510 },
17511 prompt: None,
17512 bundle_id: None,
17513 context_id: None,
17514 environment_profile_id: Some(profile_id.clone()),
17515 };
17516 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
17517 let catalog = active_registry().unwrap();
17518 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
17519 let control = runtime.native_launch_profile_control();
17520 let node_profile = NodeEnvironmentProfile::new(
17521 profile_id.clone(),
17522 profile_revision,
17523 agent("claude"),
17524 [NativeLaunchProfile::new(
17525 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
17526 agent("claude"),
17527 TransportKind::Pty,
17528 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
17529 Arc::new(EmptyEnvironmentResolver),
17530 )
17531 .unwrap()],
17532 )
17533 .unwrap();
17534 let (binding, native_profiles, _materialization) = node_profile.into_parts();
17535 for native_profile in native_profiles {
17536 runtime.upsert_native_launch_profile(native_profile).unwrap();
17537 }
17538 let workspace = WorkspaceConfig::new(
17539 WorkspaceId::new("primary").unwrap(),
17540 std::env::current_dir().unwrap(),
17541 )
17542 .unwrap();
17543 let shared = NodeShared::new_with_incarnation(
17544 handle,
17545 "fixture-token".to_owned(),
17546 NodeId::new("node-station-knobs-test").unwrap(),
17547 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
17548 vec![workspace],
17549 vec![agent("claude")],
17550 ProviderRuntimeStatuses::default(),
17551 None,
17552 Vec::new(),
17553 Vec::new(),
17554 spawn_profiles,
17555 Some(control),
17556 None,
17557 None,
17558 None,
17559 Vec::new(),
17560 Vec::new(),
17561 Vec::new(),
17562 Vec::new(),
17563 None,
17564 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
17565 );
17566 shared
17567 .environment_profiles
17568 .write()
17569 .unwrap_or_else(|poisoned| poisoned.into_inner())
17570 .insert(binding.id.clone(), binding);
17571 (shared, spawn_defaults)
17572 }
17573
17574 #[test]
17577 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
17578 let (shared, defaults) = station_knobs_fixture();
17579 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
17580 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17581 let spec = crate::protocol::SpawnSpec {
17582 target: crate::protocol::SpawnTarget {
17583 node_id: shared.node_id.clone(),
17584 workspace_id: WorkspaceId::new("primary").unwrap(),
17585 worktree_id: None,
17586 },
17587 profile_id: defaults.profile_id.clone(),
17588 expected_profile_revision: defaults.revision.clone(),
17589 overrides: crate::protocol::SpawnOverrides {
17590 network_allowlist: Some(unknown),
17591 browser_profile_id: Some(browser),
17592 ..crate::protocol::SpawnOverrides::default()
17593 },
17594 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17595 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17596 "station-knobs-unknown-allowlist",
17597 )
17598 .unwrap(),
17599 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17600 };
17601 let resolved = spec.resolve(&defaults).unwrap();
17602 let failure = shared
17603 .resolve_environment_profile(&resolved)
17604 .expect_err("unknown allowlist must refuse");
17605 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
17606 }
17607
17608 #[test]
17614 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
17615 let (shared, defaults) = station_knobs_fixture();
17616 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
17617 #[cfg(not(feature = "dig2-station-probe"))]
17618 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17619 shared
17620 .network_allowlist_catalog
17621 .write()
17622 .unwrap_or_else(|poisoned| poisoned.into_inner())
17623 .insert_id(allowlist.clone())
17624 .unwrap();
17625 let spec = crate::protocol::SpawnSpec {
17626 target: crate::protocol::SpawnTarget {
17627 node_id: shared.node_id.clone(),
17628 workspace_id: WorkspaceId::new("primary").unwrap(),
17629 worktree_id: None,
17630 },
17631 profile_id: defaults.profile_id.clone(),
17632 expected_profile_revision: defaults.revision.clone(),
17633 overrides: crate::protocol::SpawnOverrides {
17634 network_allowlist: Some(allowlist.clone()),
17635 #[cfg(not(feature = "dig2-station-probe"))]
17636 browser_profile_id: Some(browser.clone()),
17637 ..crate::protocol::SpawnOverrides::default()
17638 },
17639 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17640 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
17641 .unwrap(),
17642 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17643 };
17644 let resolved = spec.resolve(&defaults).unwrap();
17645 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
17646 #[cfg(not(feature = "dig2-station-probe"))]
17647 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
17648 let receipt = shared
17649 .resolve_environment_profile(&resolved)
17650 .expect("resolve ok")
17651 .expect("env profile present");
17652 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
17653 #[cfg(not(feature = "dig2-station-probe"))]
17654 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
17655 #[cfg(feature = "dig2-station-probe")]
17656 assert_eq!(receipt.browser_profile_id, None);
17657 assert_eq!(receipt.profile_id.as_str(), "local-claude");
17658
17659 let omitted = crate::protocol::SpawnSpec {
17660 overrides: crate::protocol::SpawnOverrides::default(),
17661 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
17662 .unwrap(),
17663 ..spec
17664 };
17665 let resolved_omit = omitted.resolve(&defaults).unwrap();
17666 let bare = shared
17667 .resolve_environment_profile(&resolved_omit)
17668 .expect("resolve ok")
17669 .expect("env profile present");
17670 assert_eq!(bare.network_allowlist, None);
17671 assert_eq!(bare.browser_profile_id, None);
17672 }
17673
17674 #[cfg(feature = "dig2-station-probe")]
17679 #[test]
17680 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
17681 let (shared, defaults) = station_knobs_fixture();
17682 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17683 let spec = crate::protocol::SpawnSpec {
17684 target: crate::protocol::SpawnTarget {
17685 node_id: shared.node_id.clone(),
17686 workspace_id: WorkspaceId::new("primary").unwrap(),
17687 worktree_id: None,
17688 },
17689 profile_id: defaults.profile_id.clone(),
17690 expected_profile_revision: defaults.revision.clone(),
17691 overrides: crate::protocol::SpawnOverrides {
17692 browser_profile_id: Some(browser),
17693 ..crate::protocol::SpawnOverrides::default()
17694 },
17695 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17696 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17697 "station-knobs-probe-refuse",
17698 )
17699 .unwrap(),
17700 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17701 };
17702 let resolved = spec.resolve(&defaults).unwrap();
17703 let failure = shared
17704 .resolve_environment_profile(&resolved)
17705 .expect_err("browser_profile with failing probe must refuse");
17706 #[cfg(windows)]
17707 {
17708 assert!(
17709 matches!(
17710 failure.code,
17711 NodeFailureCode::BrowserStationUnreachable
17712 | NodeFailureCode::BrowserStationProbeUnavailable
17713 ),
17714 "unexpected code {:?}",
17715 failure.code
17716 );
17717 }
17718 #[cfg(not(windows))]
17719 {
17720 assert_eq!(
17721 failure.code,
17722 NodeFailureCode::BrowserStationProbeUnavailable
17723 );
17724 }
17725 }
17726
17727 #[cfg(feature = "dig2-station-probe")]
17729 #[test]
17730 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
17731 let (shared, defaults) = station_knobs_fixture();
17732 let spec = crate::protocol::SpawnSpec {
17733 target: crate::protocol::SpawnTarget {
17734 node_id: shared.node_id.clone(),
17735 workspace_id: WorkspaceId::new("primary").unwrap(),
17736 worktree_id: None,
17737 },
17738 profile_id: defaults.profile_id.clone(),
17739 expected_profile_revision: defaults.revision.clone(),
17740 overrides: crate::protocol::SpawnOverrides::default(),
17741 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17742 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17743 "station-knobs-probe-omit",
17744 )
17745 .unwrap(),
17746 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17747 };
17748 let resolved = spec.resolve(&defaults).unwrap();
17749 let receipt = shared
17750 .resolve_environment_profile(&resolved)
17751 .expect("omit browser_profile must not probe")
17752 .expect("env profile present");
17753 assert_eq!(receipt.browser_profile_id, None);
17754 }
17755
17756 #[cfg(feature = "dig2-station-probe")]
17760 #[test]
17761 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
17762 let (shared, _defaults) = station_knobs_fixture();
17763 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17764 let workspace_id = WorkspaceId::new("primary").unwrap();
17765 let address_a = SessionAddress {
17766 workspace_id: workspace_id.clone(),
17767 session: SessionKey {
17768 instance_id: AgentInstanceId(101),
17769 generation: SessionGeneration(1),
17770 },
17771 };
17772 let address_b = SessionAddress {
17773 workspace_id: workspace_id.clone(),
17774 session: SessionKey {
17775 instance_id: AgentInstanceId(102),
17776 generation: SessionGeneration(1),
17777 },
17778 };
17779 let guard_a = crate::dig2_station_lease::acquire_lease(
17780 &shared.browser_station_leases,
17781 profile.clone(),
17782 address_a.session.instance_id,
17783 1,
17784 )
17785 .expect("first lease");
17786 let busy = crate::dig2_station_lease::acquire_lease(
17787 &shared.browser_station_leases,
17788 profile.clone(),
17789 address_b.session.instance_id,
17790 2,
17791 )
17792 .expect_err("second holder must refuse");
17793 assert!(matches!(
17794 busy,
17795 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
17796 ));
17797 shared.bind_session_with_policy(
17799 &address_a,
17800 ProviderRuntimePolicy::raw_pty(),
17801 None,
17802 );
17803 guard_a.retain();
17804 assert!(shared.remove_binding(&address_a).is_some());
17805 crate::dig2_station_lease::acquire_lease(
17806 &shared.browser_station_leases,
17807 profile,
17808 address_b.session.instance_id,
17809 3,
17810 )
17811 .expect("reacquire after clear")
17812 .retain();
17813 assert!(
17814 crate::dig2_station_lease::release_lease_for_holder(
17815 &shared.browser_station_leases,
17816 address_b.session.instance_id,
17817 )
17818 );
17819 }
17820
17821 #[cfg(not(feature = "dig2-station-probe"))]
17824 #[test]
17825 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
17826 let (shared, defaults) = station_knobs_fixture();
17827 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17828 let spec = crate::protocol::SpawnSpec {
17829 target: crate::protocol::SpawnTarget {
17830 node_id: shared.node_id.clone(),
17831 workspace_id: WorkspaceId::new("primary").unwrap(),
17832 worktree_id: None,
17833 },
17834 profile_id: defaults.profile_id.clone(),
17835 expected_profile_revision: defaults.revision.clone(),
17836 overrides: crate::protocol::SpawnOverrides {
17837 browser_profile_id: Some(browser.clone()),
17838 ..crate::protocol::SpawnOverrides::default()
17839 },
17840 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17841 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
17842 .unwrap(),
17843 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17844 };
17845 let resolved = spec.resolve(&defaults).unwrap();
17846 let receipt = shared
17847 .resolve_environment_profile(&resolved)
17848 .expect("stub resolve")
17849 .expect("env profile");
17850 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
17851 }
17852
17853
17854 #[test]
17857 fn network_allowlist_catalog_loads_from_temp_file() {
17858 let unique = std::time::SystemTime::now()
17859 .duration_since(std::time::UNIX_EPOCH)
17860 .unwrap()
17861 .as_nanos();
17862 let path = std::env::temp_dir().join(format!(
17863 "gate4agent-node-allowlist-config-{}-{unique}.txt",
17864 std::process::id(),
17865 ));
17866 std::fs::write(
17867 &path,
17868 "# opaque station allowlist ids\negress-default\nlab-egress\n",
17869 )
17870 .unwrap();
17871 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
17872 .expect("temp catalog file must load");
17873 let _ = std::fs::remove_file(&path);
17874 assert_eq!(loaded.len(), 2);
17875 let (shared, defaults) = station_knobs_fixture();
17876 {
17877 let mut catalog = shared
17878 .network_allowlist_catalog
17879 .write()
17880 .unwrap_or_else(|poisoned| poisoned.into_inner());
17881 *catalog = loaded;
17882 }
17883 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
17884 let spec = crate::protocol::SpawnSpec {
17885 target: crate::protocol::SpawnTarget {
17886 node_id: shared.node_id.clone(),
17887 workspace_id: WorkspaceId::new("primary").unwrap(),
17888 worktree_id: None,
17889 },
17890 profile_id: defaults.profile_id.clone(),
17891 expected_profile_revision: defaults.revision.clone(),
17892 overrides: crate::protocol::SpawnOverrides {
17893 network_allowlist: Some(allowlist.clone()),
17894 ..crate::protocol::SpawnOverrides::default()
17895 },
17896 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17897 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17898 "station-knobs-catalog-file",
17899 )
17900 .unwrap(),
17901 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17902 };
17903 let resolved = spec.resolve(&defaults).unwrap();
17904 let receipt = shared
17905 .resolve_environment_profile(&resolved)
17906 .expect("registered via file load")
17907 .expect("env profile present");
17908 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
17909 }
17910
17911 #[test]
17914 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
17915 let (shared, _defaults) = station_knobs_fixture();
17916 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
17917 shared
17918 .network_allowlist_catalog
17919 .write()
17920 .unwrap_or_else(|poisoned| poisoned.into_inner())
17921 .insert_id(allowlist.clone())
17922 .unwrap();
17923 let snapshot = shared.snapshot();
17924 let inventory = snapshot
17925 .launch_inventory
17926 .expect("launch inventory present");
17927 let ids = inventory
17928 .network_allowlists
17929 .expect("non-empty catalog surfaces ids");
17930 assert_eq!(ids, vec![allowlist]);
17931 }
17932
17933 #[test]
17937 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
17938 let (shared, defaults) = station_knobs_fixture();
17939 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
17940 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
17941 id: allowlist.clone(),
17942 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
17943 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
17944 peer: "127.0.0.1:443".to_owned(),
17945 }],
17946 provider_native: Some(
17947 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
17948 codex_network_access: Some(true),
17949 },
17950 ),
17951 };
17952 shared
17953 .network_allowlist_catalog
17954 .write()
17955 .unwrap_or_else(|poisoned| poisoned.into_inner())
17956 .insert(entry)
17957 .unwrap();
17958 let spec = crate::protocol::SpawnSpec {
17959 target: crate::protocol::SpawnTarget {
17960 node_id: shared.node_id.clone(),
17961 workspace_id: WorkspaceId::new("primary").unwrap(),
17962 worktree_id: None,
17963 },
17964 profile_id: defaults.profile_id.clone(),
17965 expected_profile_revision: defaults.revision.clone(),
17966 overrides: crate::protocol::SpawnOverrides {
17967 network_allowlist: Some(allowlist),
17968 ..crate::protocol::SpawnOverrides::default()
17969 },
17970 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17971 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17972 "station-knobs-unsupported-mapping",
17973 )
17974 .unwrap(),
17975 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17976 };
17977 let resolved = spec.resolve(&defaults).unwrap();
17978 assert_eq!(resolved.provider.as_str(), "claude");
17979 let failure = shared
17980 .resolve_environment_profile(&resolved)
17981 .expect_err("claude + codex_network_access must refuse");
17982 assert_eq!(
17983 failure.code,
17984 NodeFailureCode::UnsupportedNetworkAllowlistMapping
17985 );
17986 }
17987
17988 #[test]
17992 fn resolve_codex_network_access_overlay_moderate_only() {
17993 let (shared, defaults) = station_knobs_fixture();
17994 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
17995 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
17996 id: allowlist.clone(),
17997 permits: Vec::new(),
17998 provider_native: Some(
17999 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18000 codex_network_access: Some(true),
18001 },
18002 ),
18003 };
18004 shared
18005 .network_allowlist_catalog
18006 .write()
18007 .unwrap_or_else(|poisoned| poisoned.into_inner())
18008 .insert(entry)
18009 .unwrap();
18010
18011 let overlay = shared
18012 .resolve_codex_network_access_overlay_args(
18013 &agent("codex"),
18014 SessionMode::Pty,
18015 ApprovalLevel::Moderate,
18016 Some(&allowlist),
18017 )
18018 .expect("Moderate Codex maps network_access");
18019 assert_eq!(
18020 overlay,
18021 vec![
18022 std::ffi::OsString::from("-c"),
18023 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18024 ]
18025 );
18026
18027 let acp = shared
18028 .resolve_codex_network_access_overlay_args(
18029 &agent("codex"),
18030 SessionMode::Acp,
18031 ApprovalLevel::Moderate,
18032 Some(&allowlist),
18033 )
18034 .unwrap();
18035 assert_eq!(acp, overlay);
18036
18037 let inline = shared
18038 .resolve_codex_network_access_overlay_args(
18039 &agent("codex"),
18040 SessionMode::Inline,
18041 ApprovalLevel::Moderate,
18042 Some(&allowlist),
18043 )
18044 .unwrap_err();
18045 assert_eq!(
18046 inline.code,
18047 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18048 );
18049
18050 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18051 let spec = crate::protocol::SpawnSpec {
18052 target: crate::protocol::SpawnTarget {
18053 node_id: shared.node_id.clone(),
18054 workspace_id: WorkspaceId::new("primary").unwrap(),
18055 worktree_id: None,
18056 },
18057 profile_id: defaults.profile_id.clone(),
18058 expected_profile_revision: defaults.revision.clone(),
18059 overrides: crate::protocol::SpawnOverrides {
18060 provider: crate::protocol::SpawnOverride::Set {
18061 value: agent("codex"),
18062 },
18063 approval_level: Some(level),
18064 network_allowlist: Some(allowlist.clone()),
18065 ..crate::protocol::SpawnOverrides::default()
18066 },
18067 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18068 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18069 "codex-net-refuse-{}",
18070 match level {
18071 ApprovalLevel::ReadOnly => "ro",
18072 ApprovalLevel::FullAuto => "fa",
18073 _ => "other",
18074 }
18075 ))
18076 .unwrap(),
18077 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18078 };
18079 let resolved = spec.resolve(&defaults).unwrap();
18080 let failure = shared
18081 .resolve_environment_profile(&resolved)
18082 .expect_err("RO/FullAuto must refuse");
18083 assert_eq!(
18084 failure.code,
18085 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18086 );
18087 }
18088 }
18089
18090 #[test]
18094 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18095 let (shared, _defaults) = station_knobs_fixture();
18096 let allowlist =
18097 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18098 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18099 id: allowlist.clone(),
18100 permits: vec![
18101 crate::network_allowlist_catalog::NetworkPermitSketch {
18102 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18103 peer: "github.com:443".to_owned(),
18104 },
18105 crate::network_allowlist_catalog::NetworkPermitSketch {
18106 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18107 peer: "*.npmjs.org".to_owned(),
18108 },
18109 ],
18110 provider_native: None,
18111 };
18112 shared
18113 .network_allowlist_catalog
18114 .write()
18115 .unwrap_or_else(|poisoned| poisoned.into_inner())
18116 .insert(entry)
18117 .unwrap();
18118
18119 let unique = std::time::SystemTime::now()
18120 .duration_since(std::time::UNIX_EPOCH)
18121 .unwrap()
18122 .as_nanos();
18123 let provider_home = std::env::temp_dir().join(format!(
18124 "gate4agent-claude-provider-home-{}-{unique}",
18125 std::process::id(),
18126 ));
18127 std::fs::create_dir_all(&provider_home).unwrap();
18128
18129 let overlay = shared
18130 .resolve_network_allowlist_overlay_args(
18131 &agent("claude"),
18132 SessionMode::Pty,
18133 ApprovalLevel::Moderate,
18134 Some(&allowlist),
18135 Some(provider_home.as_path()),
18136 )
18137 .expect("Claude PTY settings overlay on unix");
18138 let settings_path = provider_home.join(
18139 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18140 );
18141 assert_eq!(
18142 overlay,
18143 vec![
18144 std::ffi::OsString::from("--settings"),
18145 std::ffi::OsString::from(settings_path.as_os_str()),
18146 ]
18147 );
18148 let body = std::fs::read_to_string(&settings_path).unwrap();
18149 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18150 assert_eq!(value["sandbox"]["enabled"], true);
18151 assert_eq!(
18152 value["sandbox"]["network"]["allowedDomains"],
18153 serde_json::json!(["github.com", "*.npmjs.org"])
18154 );
18155 assert!(settings_path.starts_with(&provider_home));
18156
18157 let acp = shared
18158 .resolve_network_allowlist_overlay_args(
18159 &agent("claude"),
18160 SessionMode::Acp,
18161 ApprovalLevel::Moderate,
18162 Some(&allowlist),
18163 Some(provider_home.as_path()),
18164 )
18165 .expect_err("Claude ACP must refuse settings overlay");
18166 assert_eq!(
18167 acp.code,
18168 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18169 );
18170
18171 let no_home = shared
18172 .resolve_network_allowlist_overlay_args(
18173 &agent("claude"),
18174 SessionMode::Pty,
18175 ApprovalLevel::Moderate,
18176 Some(&allowlist),
18177 None,
18178 )
18179 .expect_err("Claude overlay requires ProviderHome");
18180 assert_eq!(
18181 no_home.code,
18182 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18183 );
18184
18185 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18186 shared
18187 .network_allowlist_catalog
18188 .write()
18189 .unwrap_or_else(|poisoned| poisoned.into_inner())
18190 .insert_id(bare.clone())
18191 .unwrap();
18192 let empty = shared
18193 .resolve_network_allowlist_overlay_args(
18194 &agent("claude"),
18195 SessionMode::Pty,
18196 ApprovalLevel::Moderate,
18197 Some(&bare),
18198 Some(provider_home.as_path()),
18199 )
18200 .unwrap();
18201 assert!(empty.is_empty());
18202
18203 let _ = std::fs::remove_dir_all(&provider_home);
18204 }
18205
18206 #[test]
18208 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18209 let (shared, _defaults) = station_knobs_fixture();
18210 let allowlist =
18211 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18212 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18213 id: allowlist.clone(),
18214 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18215 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18216 peer: "example.com:443".to_owned(),
18217 }],
18218 provider_native: None,
18219 };
18220 shared
18221 .network_allowlist_catalog
18222 .write()
18223 .unwrap_or_else(|poisoned| poisoned.into_inner())
18224 .insert(entry)
18225 .unwrap();
18226 for provider in ["kimi", "grok"] {
18227 let overlay = shared
18228 .resolve_network_allowlist_overlay_args(
18229 &agent(provider),
18230 SessionMode::Pty,
18231 ApprovalLevel::Moderate,
18232 Some(&allowlist),
18233 None,
18234 )
18235 .unwrap();
18236 assert!(
18237 overlay.is_empty(),
18238 "{provider} must not invent network argv from permits"
18239 );
18240 }
18241 }
18242
18243 #[test]
18245 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
18246 let unique = std::time::SystemTime::now()
18247 .duration_since(std::time::UNIX_EPOCH)
18248 .unwrap()
18249 .as_nanos();
18250 let path = std::env::temp_dir().join(format!(
18251 "gate4agent-node-allowlist-json-{}-{unique}.json",
18252 std::process::id(),
18253 ));
18254 std::fs::write(
18255 &path,
18256 r#"{
18257 "schema_version": 2,
18258 "entries": [
18259 {
18260 "id": "egress-default",
18261 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
18262 },
18263 { "id": "lab-egress" }
18264 ]
18265 }"#,
18266 )
18267 .unwrap();
18268 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18269 .expect("json v2 catalog must load");
18270 let _ = std::fs::remove_file(&path);
18271 assert_eq!(loaded.len(), 2);
18272 let entry = loaded
18273 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
18274 .unwrap();
18275 assert_eq!(entry.permits.len(), 1);
18276 let (shared, defaults) = station_knobs_fixture();
18277 {
18278 let mut catalog = shared
18279 .network_allowlist_catalog
18280 .write()
18281 .unwrap_or_else(|poisoned| poisoned.into_inner());
18282 *catalog = loaded;
18283 }
18284 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18285 let spec = crate::protocol::SpawnSpec {
18286 target: crate::protocol::SpawnTarget {
18287 node_id: shared.node_id.clone(),
18288 workspace_id: WorkspaceId::new("primary").unwrap(),
18289 worktree_id: None,
18290 },
18291 profile_id: defaults.profile_id.clone(),
18292 expected_profile_revision: defaults.revision.clone(),
18293 overrides: crate::protocol::SpawnOverrides {
18294 network_allowlist: Some(allowlist.clone()),
18295 ..crate::protocol::SpawnOverrides::default()
18296 },
18297 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18298 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18299 "station-knobs-catalog-json",
18300 )
18301 .unwrap(),
18302 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18303 };
18304 let resolved = spec.resolve(&defaults).unwrap();
18305 let receipt = shared
18306 .resolve_environment_profile(&resolved)
18307 .expect("permits-only entry ok for claude")
18308 .expect("env profile present");
18309 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18310 let inventory = shared
18311 .snapshot()
18312 .launch_inventory
18313 .expect("launch inventory present");
18314 let ids = inventory.network_allowlists.expect("ids surface");
18315 assert_eq!(
18316 ids,
18317 vec![
18318 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
18319 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
18320 ]
18321 );
18322 }
18323
18324
18325}
18326
18327
18328#[cfg(test)]
18329mod workspace_environment_collision_tests {
18330 use super::*;
18331 use crate::session_environment::{
18332 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
18333 };
18334 use gate4agent_catalog::EnvMutation;
18335 use gate4agent_runtime_native::{
18336 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18337 NativeLaunchProfileId,
18338 };
18339 use std::ffi::OsString;
18340 use std::sync::Arc;
18341
18342 struct EmptyEnvironmentResolver;
18343
18344 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18345 fn resolve_child_environment(
18346 &self,
18347 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18348 Ok(vec![EnvMutation {
18349 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18350 value: None,
18351 }])
18352 }
18353 }
18354
18355 fn agent(value: &str) -> AgentId {
18356 AgentId::new(value).unwrap()
18357 }
18358
18359 fn claude_config_dir_collision_fixture() -> (
18361 NodeShared,
18362 ResolvedEnvironmentProfileReceipt,
18363 ResolvedEnvironmentProfileReceipt,
18364 ) {
18365 let catalog = active_registry().unwrap();
18366 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18367 let control = runtime.native_launch_profile_control();
18368 let workspace = WorkspaceConfig::new(
18369 WorkspaceId::new("primary").unwrap(),
18370 std::env::current_dir().unwrap(),
18371 )
18372 .unwrap();
18373 let shared = NodeShared::new_with_incarnation(
18374 handle,
18375 "fixture-token".to_owned(),
18376 NodeId::new("node-env-collision-test").unwrap(),
18377 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18378 vec![workspace],
18379 vec![agent("claude")],
18380 ProviderRuntimeStatuses::default(),
18381 None,
18382 Vec::new(),
18383 Vec::new(),
18384 SpawnProfileRegistry::default(),
18385 Some(control),
18386 None,
18387 None,
18388 None,
18389 Vec::new(),
18390 Vec::new(),
18391 Vec::new(),
18392 Vec::new(),
18393 None,
18394 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18395 );
18396
18397 let mut receipts = Vec::new();
18398 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
18399 let profile_id =
18400 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
18401 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
18402 "claude-config-dir-{suffix}-r1"
18403 ))
18404 .unwrap();
18405 let materialization = NodeSessionMaterializationProfile::new(
18406 Vec::new(),
18407 vec![NodeSessionPathBinding::new(
18408 "CLAUDE_CONFIG_DIR",
18409 NodeSessionPathClass::ProviderHome,
18410 )
18411 .unwrap()],
18412 Vec::new(),
18413 )
18414 .unwrap();
18415 let node_profile = NodeEnvironmentProfile::new_with_materialization(
18416 profile_id.clone(),
18417 profile_revision.clone(),
18418 agent("claude"),
18419 [NativeLaunchProfile::new(
18420 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
18421 .unwrap(),
18422 agent("claude"),
18423 TransportKind::Pty,
18424 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18425 Arc::new(EmptyEnvironmentResolver),
18426 )
18427 .unwrap()],
18428 Some(materialization),
18429 )
18430 .unwrap();
18431 let (binding, native_profiles, materialization) = node_profile.into_parts();
18432 for native_profile in native_profiles {
18433 runtime.upsert_native_launch_profile(native_profile).unwrap();
18434 }
18435 shared
18436 .environment_profiles
18437 .write()
18438 .unwrap_or_else(|poisoned| poisoned.into_inner())
18439 .insert(binding.id.clone(), binding);
18440 shared
18441 .environment_materialization_profiles
18442 .write()
18443 .unwrap_or_else(|poisoned| poisoned.into_inner())
18444 .insert(profile_id.clone(), materialization.unwrap());
18445 receipts.push(ResolvedEnvironmentProfileReceipt {
18446 profile_id,
18447 profile_revision,
18448 network_allowlist: None,
18449 browser_profile_id: None,
18450 });
18451 }
18452 let profile_b = receipts.pop().unwrap();
18453 let profile_a = receipts.pop().unwrap();
18454 (shared, profile_a, profile_b)
18455 }
18456
18457 #[test]
18458 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
18459 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18460 let primary = WorkspaceId::new("primary").unwrap();
18461 let worktree = WorkspaceId::new("worktree-b").unwrap();
18462 let address = SessionAddress {
18463 workspace_id: primary.clone(),
18464 session: SessionKey {
18465 instance_id: AgentInstanceId(41),
18466 generation: SessionGeneration(1),
18467 },
18468 };
18469 shared.bind_session_with_policy(
18470 &address,
18471 ProviderRuntimePolicy::raw_pty(),
18472 Some(profile_a.clone()),
18473 );
18474
18475 let refused = shared
18476 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
18477 .unwrap_err();
18478 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
18479
18480 let refused_none = shared
18482 .ensure_workspace_environment_profile_compatible(&primary, None)
18483 .unwrap_err();
18484 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
18485
18486 shared
18488 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
18489 .unwrap();
18490
18491 shared
18493 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
18494 .unwrap();
18495 }
18496
18497 #[tokio::test]
18498 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
18499 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18500 let primary = WorkspaceId::new("primary").unwrap();
18501 let address = SessionAddress {
18502 workspace_id: primary.clone(),
18503 session: SessionKey {
18504 instance_id: AgentInstanceId(41),
18505 generation: SessionGeneration(1),
18506 },
18507 };
18508 shared.bind_session_with_policy(
18509 &address,
18510 ProviderRuntimePolicy::raw_pty(),
18511 Some(profile_a),
18512 );
18513
18514 let failure = shared
18515 .spawn_session_with_deadline(
18516 primary,
18517 agent("claude"),
18518 SessionMode::Pty,
18519 gate4agent_types::TerminalSize {
18520 rows: 24,
18521 columns: 80,
18522 },
18523 None,
18524 Some(profile_b),
18525 None,
18526 None,
18527 None,
18528 None,
18529 SpawnRecordPolicy::ProviderIdentityOnly,
18530 Some(Instant::now() + Duration::from_secs(30)),
18531 &[],
18532 None,
18533 ApprovalLevel::default(),
18534 None,
18535 None,
18536 )
18537 .await
18538 .unwrap_err();
18539 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
18540 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
18542 assert!(shared.handle.snapshot().sessions.is_empty());
18543 }
18544}
18545
18546#[cfg(all(test, windows))]
18547mod tests {
18548 use super::*;
18549 use gate4agent_types::ProviderActivity;
18550 use crate::session_environment::{
18551 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
18552 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
18553 NodeSessionPathClass,
18554 };
18555 use gate4agent_catalog::EnvMutation;
18556 use gate4agent_runtime_native::{
18557 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
18558 NativeLaunchProfile, NativeLaunchProfileId,
18559 };
18560 use std::ffi::OsString;
18561 use std::sync::Arc;
18562
18563 struct EmptyEnvironmentResolver;
18564
18565 struct FixtureSecretResolver {
18566 deny: Arc<AtomicBool>,
18567 }
18568
18569 impl NodeSecretResolver for FixtureSecretResolver {
18570 fn resolve(
18571 &self,
18572 _reference: &NodeSecretReference,
18573 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
18574 if self.deny.load(Ordering::Acquire) {
18575 Err(NodeSecretResolveError::Denied)
18576 } else {
18577 NodeSecretValue::text("fixture-secret")
18578 .map_err(|_| NodeSecretResolveError::Unavailable)
18579 }
18580 }
18581 }
18582
18583 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18584 fn resolve_child_environment(
18585 &self,
18586 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18587 Ok(vec![EnvMutation {
18588 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18589 value: None,
18590 }])
18591 }
18592 }
18593
18594 fn agent(value: &str) -> AgentId {
18595 AgentId::new(value).unwrap()
18596 }
18597
18598 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
18599 let catalog = active_registry().unwrap();
18600 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18601 let workspace = WorkspaceConfig::new(
18602 WorkspaceId::new("primary").unwrap(),
18603 std::env::current_dir().unwrap(),
18604 )
18605 .unwrap()
18606 .with_worktree_service_mode(mode);
18607 NodeShared::new(
18608 handle,
18609 "fixture-token".to_owned(),
18610 NodeId::new("node-terminal-test").unwrap(),
18611 vec![workspace],
18612 vec![agent("claude")],
18613 )
18614 }
18615
18616 fn terminal_test_shared() -> NodeShared {
18617 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
18618 }
18619
18620 #[cfg(feature = "fixture")]
18621 #[test]
18622 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
18623 let probe = SpawnManagedWorktreeV2FailureProbe::default();
18624 assert_eq!(probe.latest_code(), None);
18625
18626 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
18627 assert_eq!(
18628 probe.latest_code(),
18629 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
18630 );
18631
18632 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
18633 assert_eq!(
18634 probe.latest_code(),
18635 Some(NodeFailureCode::SpawnDeadlineExceeded),
18636 );
18637
18638 probe.record(None);
18639 assert_eq!(probe.latest_code(), None);
18640 }
18641
18642 #[test]
18643 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
18644 let source = temporary_workspace_root("launch-inventory-source");
18645 let allocation = temporary_workspace_root("launch-inventory-allocation");
18646 std::fs::create_dir_all(&source).unwrap();
18647 std::fs::create_dir_all(&allocation).unwrap();
18648 let workspace_id = WorkspaceId::new("primary").unwrap();
18649 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
18650 .unwrap()
18651 .with_worktree_service_mode(WorktreeServiceMode::Managed);
18652 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
18653 workspace = workspace
18654 .with_managed_worktree_profile(
18655 ManagedWorktreeProfile::new(
18656 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
18657 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
18658 &allocation,
18659 "gate4agent",
18660 "HEAD",
18661 ManagedWorktreeRetention::RemoveWhenReleased,
18662 )
18663 .unwrap(),
18664 )
18665 .unwrap();
18666 }
18667 let overflow = workspace.clone().with_managed_worktree_profile(
18668 ManagedWorktreeProfile::new(
18669 WorktreeProfileId::new("overflow").unwrap(),
18670 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
18671 &allocation,
18672 "gate4agent",
18673 "HEAD",
18674 ManagedWorktreeRetention::Retain,
18675 )
18676 .unwrap(),
18677 );
18678 assert!(matches!(
18679 overflow,
18680 Err(NodeServerError::ManagedWorktreeProfileCapacity {
18681 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
18682 ..
18683 }),
18684 ));
18685
18686 let catalog = active_registry().unwrap();
18687 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18688 let shared = NodeShared::new(
18689 handle,
18690 "fixture-token".to_owned(),
18691 NodeId::new("node-launch-inventory").unwrap(),
18692 vec![workspace],
18693 vec![agent("claude")],
18694 );
18695 let snapshot = shared.snapshot();
18696 let launch = snapshot.launch_inventory.as_ref().unwrap();
18697 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
18698 assert!(launch.bundles.as_ref().unwrap().is_empty());
18699 let profiles = snapshot.workspaces[0]
18700 .managed_worktree_profiles
18701 .as_ref()
18702 .unwrap();
18703 assert_eq!(
18704 snapshot.workspaces[0].worktree_service_mode,
18705 Some(WorktreeServiceMode::Managed),
18706 );
18707 assert_eq!(
18708 profiles.profiles.len(),
18709 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
18710 );
18711 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
18712 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
18713 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
18714 }
18715 let legacy = snapshot_for_wire(
18716 &shared,
18717 true,
18718 true,
18719 false,
18720 false,
18721 true,
18722 false,
18723 true,
18724 false,
18725 true,
18726 );
18727 assert!(legacy.launch_inventory.is_none());
18728 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
18729
18730 drop(shared);
18731 std::fs::remove_dir_all(source).unwrap();
18732 std::fs::remove_dir_all(allocation).unwrap();
18733 }
18734
18735 #[test]
18736 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
18737 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
18738 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
18739 }));
18740 let source = gate4agent_types::ProviderSource {
18741 family: AdapterFamily::PtySemantic,
18742 binding: AdapterBinding::new(
18743 gate4agent_types::AdapterId::new("claude").unwrap(),
18744 "fixture/v1",
18745 gate4agent_types::AdapterVerification::SyntheticFixture,
18746 )
18747 .unwrap(),
18748 };
18749 let provider = ProviderSnapshot {
18750 sequence: 17,
18751 session: Some(ProviderSessionIdentity {
18752 key: ProviderSessionKey::SessionId,
18753 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
18754 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
18755 }),
18756 model: Some("PRIVATE_MODEL".to_owned()),
18757 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
18758 completed_turns: 3,
18759 usage: gate4agent_types::TokenUsage {
18760 input_tokens: 10,
18761 output_tokens: 20,
18762 cache_read_tokens: 30,
18763 cache_write_tokens: 40,
18764 reasoning_tokens: 50,
18765 context_window: Some(200_000),
18766 },
18767 lead_activity: ProviderActivity::WaitingForInput,
18768 activity: ProviderActivity::WaitingForInput,
18769 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
18770 active_tools: (0..9)
18771 .map(|index| gate4agent_types::ActiveProviderTool {
18772 id: format!("private-tool-id-{index}"),
18773 name: if index == 0 {
18774 format!(" Read\u{0000}{}", "x".repeat(80))
18775 } else {
18776 format!("Tool-{index}")
18777 },
18778 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
18779 })
18780 .collect(),
18781 interactions: vec![gate4agent_types::ProviderInteraction {
18782 id: gate4agent_types::ProviderInteractionId(17),
18783 source: source.clone(),
18784 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
18785 interaction_kind: ProviderInteractionKind::Approval,
18786 tool_name: " Approve\u{0007}Tool ".to_owned(),
18787 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
18788 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
18789 resume_lead_activity: Some(ProviderActivity::Working),
18790 status: ProviderInteractionStatus::Pending,
18791 }],
18792 subagents: vec![gate4agent_types::ProviderSubagent {
18793 source: source.clone(),
18794 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
18795 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
18796 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
18797 }],
18798 sources: vec![gate4agent_types::ProviderSourceCursor {
18799 source,
18800 sequence: 17,
18801 gap_count: 2,
18802 stale: true,
18803 }],
18804 last_event: Some(ProviderEvent::Error {
18805 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
18806 }),
18807 gap_count: 2,
18808 stale: true,
18809 };
18810 let address = terminal_address(3);
18811 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
18812 assert_eq!(entry.address, address);
18813 assert_eq!(entry.progress.provider_sequence, 17);
18814 assert_eq!(entry.progress.active_tool_count, 9);
18815 assert_eq!(
18816 entry.progress.active_tool_labels.len(),
18817 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
18818 );
18819 assert_eq!(entry.progress.subagent_count, 1);
18820 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
18821 assert!(entry.progress.truncated);
18822 let encoded = serde_json::to_string(&entry).unwrap();
18823 for forbidden in [
18824 "PRIVATE_PROVIDER_SESSION",
18825 "PRIVATE_PROVIDER_PATH",
18826 "PRIVATE_MODEL",
18827 "PRIVATE_TOOL_INVENTORY",
18828 "PRIVATE_CURRENT_PROMPT",
18829 "PRIVATE_TOOL_INPUT_JSON",
18830 "PRIVATE_PROVIDER_REQUEST",
18831 "PRIVATE_INTERACTION_PROMPT",
18832 "PRIVATE_INTERACTION_AGENT",
18833 "PRIVATE_SUBAGENT_ID",
18834 "PRIVATE_SUBAGENT_TYPE",
18835 "PRIVATE_SUBAGENT_DESCRIPTION",
18836 "PRIVATE_PROVIDER_ERROR",
18837 "input_json",
18838 "current_prompt",
18839 "session_id",
18840 ] {
18841 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
18842 }
18843
18844 let snapshot = NodeSnapshot {
18845 node_id: NodeId::new("node-progress-test").unwrap(),
18846 enabled_providers: vec![agent("claude")],
18847 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
18848 workspaces: Vec::new(),
18849 session_records: Vec::new(),
18850 managed_worktrees: Vec::new(),
18851 launch_inventory: None,
18852 agent_progress: vec![entry],
18853 };
18854 let mut legacy_reply = ResponseEnvelope {
18855 request_id: 1,
18856 result: Ok(NodeResponse::Snapshot {
18857 event_sequence: 0,
18858 controller: None,
18859 snapshot: snapshot.clone(),
18860 }),
18861 };
18862 project_response_without_agent_progress(&mut legacy_reply);
18863 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
18864 panic!("agent progress projection changed response kind");
18865 };
18866 assert!(legacy.agent_progress.is_empty());
18867 assert_eq!(snapshot.agent_progress.len(), 1);
18868 }
18869
18870 fn terminal_address(generation: u64) -> SessionAddress {
18871 SessionAddress {
18872 workspace_id: WorkspaceId::new("primary").unwrap(),
18873 session: SessionKey {
18874 instance_id: AgentInstanceId(41),
18875 generation: SessionGeneration(generation),
18876 },
18877 }
18878 }
18879
18880 fn spawn_spec_fixture() -> SpawnSpec {
18881 SpawnSpec {
18882 target: crate::protocol::SpawnTarget {
18883 node_id: NodeId::new("node-terminal-test").unwrap(),
18884 workspace_id: WorkspaceId::new("primary").unwrap(),
18885 worktree_id: None,
18886 },
18887 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18888 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
18889 .unwrap(),
18890 overrides: crate::protocol::SpawnOverrides::default(),
18891 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18892 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
18893 required_capabilities: SpawnRequiredCapabilities::default(),
18894 }
18895 }
18896
18897 fn environment_profile_test_shared(
18898 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
18899 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18900 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
18901 "local-claude-r1",
18902 )
18903 .unwrap();
18904 let spawn_profiles = SpawnProfileRegistry::new([
18905 crate::protocol::SpawnProfileDefaults {
18906 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18907 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18908 provider: agent("claude"),
18909 mode: SessionMode::Pty,
18910 terminal_size: gate4agent_types::TerminalSize {
18911 rows: 24,
18912 columns: 80,
18913 },
18914 prompt: None,
18915 bundle_id: None,
18916 context_id: None,
18917 environment_profile_id: Some(profile_id.clone()),
18918 },
18919 ])
18920 .unwrap();
18921 let catalog = active_registry().unwrap();
18922 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18923 let control = runtime.native_launch_profile_control();
18924 let node_profile = NodeEnvironmentProfile::new(
18925 profile_id.clone(),
18926 profile_revision.clone(),
18927 agent("claude"),
18928 [NativeLaunchProfile::new(
18929 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18930 agent("claude"),
18931 TransportKind::Pty,
18932 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18933 Arc::new(EmptyEnvironmentResolver),
18934 )
18935 .unwrap()],
18936 )
18937 .unwrap();
18938 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18939 for native_profile in native_profiles {
18940 runtime.upsert_native_launch_profile(native_profile).unwrap();
18941 }
18942 let workspace = WorkspaceConfig::new(
18943 WorkspaceId::new("primary").unwrap(),
18944 std::env::current_dir().unwrap(),
18945 )
18946 .unwrap();
18947 let shared = NodeShared::new_with_incarnation(
18948 handle,
18949 "fixture-token".to_owned(),
18950 NodeId::new("node-terminal-test").unwrap(),
18951 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18952 vec![workspace],
18953 vec![agent("claude")],
18954 ProviderRuntimeStatuses::default(),
18955 None,
18956 Vec::new(),
18957 Vec::new(),
18958 spawn_profiles,
18959 Some(control),
18960 None,
18961 None,
18962 None,
18963 Vec::new(),
18964 Vec::new(),
18965 Vec::new(),
18966 Vec::new(),
18967 None,
18968 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18969 );
18970 shared
18971 .environment_profiles
18972 .write()
18973 .unwrap_or_else(|poisoned| poisoned.into_inner())
18974 .insert(binding.id.clone(), binding);
18975 (
18976 shared,
18977 runtime,
18978 ResolvedEnvironmentProfileReceipt {
18979 profile_id,
18980 profile_revision,
18981 network_allowlist: None,
18982 browser_profile_id: None,
18983 },
18984 )
18985 }
18986
18987 fn materialization_test_shared(
18988 deny_secret: bool,
18989 ) -> (
18990 NodeShared,
18991 NativeRuntime,
18992 ResolvedEnvironmentProfileReceipt,
18993 PathBuf,
18994 Arc<AtomicBool>,
18995 ) {
18996 let workspace = WorkspaceConfig::new(
18997 WorkspaceId::new("primary").unwrap(),
18998 std::env::current_dir().unwrap(),
18999 )
19000 .unwrap();
19001 materialization_test_shared_with_workspace(deny_secret, workspace)
19002 }
19003
19004 fn materialization_test_shared_with_workspace(
19005 deny_secret: bool,
19006 workspace: WorkspaceConfig,
19007 ) -> (
19008 NodeShared,
19009 NativeRuntime,
19010 ResolvedEnvironmentProfileReceipt,
19011 PathBuf,
19012 Arc<AtomicBool>,
19013 ) {
19014 let root = temporary_workspace_root(if deny_secret {
19015 "materialization-denied"
19016 } else {
19017 "materialization-ready"
19018 });
19019 std::fs::create_dir_all(&root).unwrap();
19020 let materialization_root = root.join("session-environments");
19021 let deny = Arc::new(AtomicBool::new(deny_secret));
19022 let resolver = Arc::new(FixtureSecretResolver {
19023 deny: Arc::clone(&deny),
19024 });
19025 let materializer = SessionEnvironmentMaterializer::new(
19026 materialization_root,
19027 resolver,
19028 )
19029 .unwrap();
19030 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19031 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19032 "materialized-claude-r1",
19033 )
19034 .unwrap();
19035 let materialization = NodeSessionMaterializationProfile::new(
19036 vec![NodeSessionEnvironmentMutation::SetSecret {
19037 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19038 reference: NodeSecretReference::new("fixture-token").unwrap(),
19039 }],
19040 vec![NodeSessionPathBinding::new(
19041 "GATE4AGENT_PROVIDER_HOME",
19042 NodeSessionPathClass::ProviderHome,
19043 )
19044 .unwrap()],
19045 vec![NodeSessionFile::secret(
19046 NodeSessionPathClass::Config,
19047 "auth/token",
19048 NodeSecretReference::new("fixture-token").unwrap(),
19049 )
19050 .unwrap()],
19051 )
19052 .unwrap();
19053 let catalog = active_registry().unwrap();
19054 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19055 let control = runtime.native_launch_profile_control();
19056 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19057 profile_id.clone(),
19058 profile_revision.clone(),
19059 agent("claude"),
19060 [NativeLaunchProfile::new(
19061 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19062 agent("claude"),
19063 TransportKind::Pty,
19064 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19065 Arc::new(EmptyEnvironmentResolver),
19066 )
19067 .unwrap()],
19068 Some(materialization),
19069 )
19070 .unwrap();
19071 let (binding, native_profiles, materialization) = node_profile.into_parts();
19072 for native_profile in native_profiles {
19073 runtime.upsert_native_launch_profile(native_profile).unwrap();
19074 }
19075 let shared = NodeShared::new_with_incarnation(
19076 handle,
19077 "fixture-token".to_owned(),
19078 NodeId::new("node-materialization-test").unwrap(),
19079 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19080 vec![workspace],
19081 vec![agent("claude")],
19082 ProviderRuntimeStatuses::default(),
19083 None,
19084 Vec::new(),
19085 Vec::new(),
19086 SpawnProfileRegistry::default(),
19087 Some(control),
19088 None,
19089 Some(materializer),
19090 None,
19091 Vec::new(),
19092 Vec::new(),
19093 Vec::new(),
19094 Vec::new(),
19095 None,
19096 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19097 );
19098 shared
19099 .environment_profiles
19100 .write()
19101 .unwrap_or_else(|poisoned| poisoned.into_inner())
19102 .insert(binding.id.clone(), binding);
19103 shared
19104 .environment_materialization_profiles
19105 .write()
19106 .unwrap_or_else(|poisoned| poisoned.into_inner())
19107 .insert(profile_id.clone(), materialization.unwrap());
19108 (
19109 shared,
19110 runtime,
19111 ResolvedEnvironmentProfileReceipt {
19112 profile_id,
19113 profile_revision,
19114 network_allowlist: None,
19115 browser_profile_id: None,
19116 },
19117 root,
19118 deny,
19119 )
19120 }
19121
19122 #[cfg(feature = "fixture")]
19123 #[derive(Clone, Copy)]
19124 enum CodexHomeProfile {
19125 Missing,
19126 WrongClass,
19127 Exact,
19128 }
19129
19130 #[cfg(feature = "fixture")]
19131 fn codex_bundle_test_shared(
19132 home_profile: CodexHomeProfile,
19133 ) -> (
19134 NodeShared,
19135 NativeRuntime,
19136 ResolvedEnvironmentProfileReceipt,
19137 NodeBundle,
19138 PathBuf,
19139 ) {
19140 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
19141 const CLAUDE_MANIFEST: &[u8] =
19142 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
19143 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
19144 const DIGEST: &str =
19145 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
19146
19147 let (shared, mut runtime, _claude_receipt, root, _deny) =
19148 materialization_test_shared(false);
19149 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
19150 CodexHomeProfile::Missing => "codex-home-missing",
19151 CodexHomeProfile::WrongClass => "codex-home-wrong",
19152 CodexHomeProfile::Exact => "codex-home-exact",
19153 })
19154 .unwrap();
19155 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19156 "codex-home-r1",
19157 )
19158 .unwrap();
19159 let materialization = match home_profile {
19160 CodexHomeProfile::Missing => None,
19161 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
19162 Some(NodeSessionMaterializationProfile::new(
19163 Vec::new(),
19164 vec![NodeSessionPathBinding::new(
19165 "CODEX_HOME",
19166 if matches!(home_profile, CodexHomeProfile::Exact) {
19167 NodeSessionPathClass::ProviderHome
19168 } else {
19169 NodeSessionPathClass::Config
19170 },
19171 )
19172 .unwrap()],
19173 Vec::new(),
19174 )
19175 .unwrap())
19176 }
19177 };
19178 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19179 profile_id.clone(),
19180 profile_revision.clone(),
19181 agent("codex"),
19182 [NativeLaunchProfile::new(
19183 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
19184 agent("codex"),
19185 TransportKind::Pty,
19186 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19187 Arc::new(EmptyEnvironmentResolver),
19188 )
19189 .unwrap()],
19190 materialization,
19191 )
19192 .unwrap();
19193 let (binding, native_profiles, materialization) = node_profile.into_parts();
19194 for native_profile in native_profiles {
19195 runtime.upsert_native_launch_profile(native_profile).unwrap();
19196 }
19197 shared
19198 .environment_profiles
19199 .write()
19200 .unwrap_or_else(|poisoned| poisoned.into_inner())
19201 .insert(binding.id.clone(), binding);
19202 if let Some(materialization) = materialization {
19203 shared
19204 .environment_materialization_profiles
19205 .write()
19206 .unwrap_or_else(|poisoned| poisoned.into_inner())
19207 .insert(profile_id.clone(), materialization);
19208 }
19209
19210 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
19211 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
19212 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
19213 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
19214 std::fs::write(
19215 bundle_source.join(".claude-plugin/plugin.json"),
19216 CLAUDE_MANIFEST,
19217 )
19218 .unwrap();
19219 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
19220 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
19221 let bundle = NodeBundle::new(
19222 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
19223 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
19224 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
19225 &bundle_source,
19226 )
19227 .unwrap();
19228 *shared
19229 .bundle_catalog
19230 .write()
19231 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
19232 BundleCatalog::new([bundle.clone()]).unwrap();
19233 (
19234 shared,
19235 runtime,
19236 ResolvedEnvironmentProfileReceipt {
19237 profile_id,
19238 profile_revision,
19239 network_allowlist: None,
19240 browser_profile_id: None,
19241 },
19242 bundle,
19243 root,
19244 )
19245 }
19246
19247 #[cfg(feature = "fixture")]
19248 #[test]
19249 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
19250 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
19251 let (shared, runtime, receipt, bundle, root) =
19252 codex_bundle_test_shared(home_profile);
19253 let error = shared
19254 .bundle_layout(
19255 &agent("codex"),
19256 SessionMode::Pty,
19257 Some(&receipt),
19258 &bundle,
19259 )
19260 .unwrap_err();
19261 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
19262 assert!(shared
19263 .materializations
19264 .lock()
19265 .unwrap_or_else(|poisoned| poisoned.into_inner())
19266 .is_empty());
19267 let materialization_root = root.join("session-environments");
19268 assert!(!std::fs::read_dir(&materialization_root)
19269 .unwrap()
19270 .filter_map(Result::ok)
19271 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
19272 drop(shared);
19273 drop(runtime);
19274 let _ = std::fs::remove_dir_all(root);
19275 }
19276 }
19277
19278 #[cfg(feature = "fixture")]
19279 #[test]
19280 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
19281 let (shared, runtime, receipt, bundle, root) =
19282 codex_bundle_test_shared(CodexHomeProfile::Exact);
19283 let address = SessionAddress {
19284 workspace_id: WorkspaceId::new("primary").unwrap(),
19285 session: SessionKey {
19286 instance_id: AgentInstanceId(310),
19287 generation: SessionGeneration(1),
19288 },
19289 };
19290 let (overlay, guard) = shared
19291 .prepare_session_materialization(
19292 &address,
19293 &agent("codex"),
19294 SessionMode::Pty,
19295 Some(&receipt),
19296 Some(&bundle.receipt()),
19297 None,
19298 None,
19299 )
19300 .unwrap()
19301 .unwrap();
19302 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
19303 let ownership = shared
19304 .materializations
19305 .lock()
19306 .unwrap_or_else(|poisoned| poisoned.into_inner())
19307 .get(guard.id().unwrap())
19308 .unwrap()
19309 .clone();
19310 let (profile, _, _) = shared
19311 .materialization_profile(
19312 &agent("codex"),
19313 SessionMode::Pty,
19314 Some(&receipt),
19315 Some(&bundle.receipt()),
19316 None,
19317 )
19318 .unwrap()
19319 .unwrap();
19320 let environment = shared
19321 .session_environment_materializer
19322 .as_ref()
19323 .unwrap()
19324 .resolve_environment(&ownership, &profile)
19325 .unwrap();
19326 assert!(environment.iter().any(|mutation| {
19327 mutation.key == OsString::from("CODEX_HOME")
19328 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
19329 }));
19330 assert_eq!(
19331 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
19332 .unwrap(),
19333 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
19334 );
19335 assert!(std::fs::read_dir(ownership.bundle_root())
19336 .unwrap()
19337 .next()
19338 .is_none());
19339 drop(guard);
19340 drop(shared);
19341 drop(runtime);
19342 let _ = std::fs::remove_dir_all(root);
19343 }
19344
19345 #[cfg(feature = "fixture")]
19346 #[test]
19347 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
19348 let (shared, runtime, receipt, bundle, root) =
19349 codex_bundle_test_shared(CodexHomeProfile::Exact);
19350 let bundle_receipt = bundle.receipt();
19351 let address = SessionAddress {
19352 workspace_id: WorkspaceId::new("primary").unwrap(),
19353 session: SessionKey {
19354 instance_id: AgentInstanceId(311),
19355 generation: SessionGeneration(1),
19356 },
19357 };
19358 let (_overlay, guard) = shared
19359 .prepare_session_materialization(
19360 &address,
19361 &agent("codex"),
19362 SessionMode::Pty,
19363 Some(&receipt),
19364 Some(&bundle_receipt),
19365 None,
19366 None,
19367 )
19368 .unwrap()
19369 .unwrap();
19370 let materialization_id = guard.id().unwrap().clone();
19371 let skill_path = shared
19372 .materializations
19373 .lock()
19374 .unwrap_or_else(|poisoned| poisoned.into_inner())
19375 .get(&materialization_id)
19376 .unwrap()
19377 .provider_home()
19378 .join("skills/review-code/SKILL.md");
19379 let record_id = shared
19380 .bind_spawn_session_with_materialization(
19381 &address,
19382 agent("codex"),
19383 SessionMode::Pty,
19384 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
19385 SpawnRecordPolicy::ProviderIdentityOnly,
19386 Some(receipt.clone()),
19387 Some(bundle_receipt.clone()),
19388 None,
19389 Some(materialization_id.clone()),
19390 )
19391 .unwrap()
19392 .unwrap();
19393 guard.retain();
19394 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
19395
19396 let error = match shared.resolve_record_materialization(
19397 &record_id,
19398 &agent("codex"),
19399 SessionMode::Pty,
19400 Some(&receipt),
19401 Some(&bundle_receipt),
19402 None,
19403 ) {
19404 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
19405 Err(error) => error,
19406 };
19407 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
19408 assert_eq!(
19409 shared
19410 .materializations
19411 .lock()
19412 .unwrap_or_else(|poisoned| poisoned.into_inner())
19413 .get(&materialization_id)
19414 .unwrap()
19415 .state(),
19416 MaterializationState::RecoveryRequired,
19417 );
19418 drop(shared);
19419 drop(runtime);
19420 let _ = std::fs::remove_dir_all(root);
19421 }
19422
19423 fn bind_record_owned_materialization_fixture(
19424 shared: &NodeShared,
19425 receipt: &ResolvedEnvironmentProfileReceipt,
19426 instance_id: u64,
19427 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
19428 let address = SessionAddress {
19429 workspace_id: WorkspaceId::new("primary").unwrap(),
19430 session: SessionKey {
19431 instance_id: AgentInstanceId(instance_id),
19432 generation: SessionGeneration(1),
19433 },
19434 };
19435 let (overlay, materialization_guard) = shared
19436 .prepare_session_materialization(
19437 &address,
19438 &agent("claude"),
19439 SessionMode::Pty,
19440 Some(receipt),
19441 None,
19442 None,
19443 None,
19444 )
19445 .unwrap()
19446 .unwrap();
19447 let materialization_id = materialization_guard.id().unwrap().clone();
19448 let materialization_root = shared
19449 .materializations
19450 .lock()
19451 .unwrap_or_else(|poisoned| poisoned.into_inner())
19452 .get(&materialization_id)
19453 .unwrap()
19454 .root()
19455 .to_path_buf();
19456 let selection = shared
19457 .select_environment_profile(
19458 address.session.instance_id,
19459 &agent("claude"),
19460 SessionMode::Pty,
19461 Some(receipt),
19462 )
19463 .unwrap()
19464 .unwrap();
19465 assert!(shared
19466 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
19467 .unwrap()
19468 .is_none());
19469 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
19470 let record_id = shared
19471 .bind_spawn_session_with_materialization(
19472 &address,
19473 agent("claude"),
19474 SessionMode::Pty,
19475 policy,
19476 SpawnRecordPolicy::ProviderIdentityOnly,
19477 Some(receipt.clone()),
19478 None,
19479 None,
19480 Some(materialization_id.clone()),
19481 )
19482 .unwrap()
19483 .unwrap();
19484 selection.retain();
19485 materialization_guard.retain();
19486 (address, record_id, materialization_id, materialization_root)
19487 }
19488
19489 #[test]
19490 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
19491 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
19492 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19493 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
19494 }));
19495 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
19496 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
19497 let mut spec = spawn_spec_fixture();
19498 spec.expected_profile_revision =
19499 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
19500 assert!(matches!(
19501 &spec.overrides.environment_profile_id,
19502 crate::protocol::SpawnOverride::Inherit,
19503 ));
19504 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
19505 .requires_child_environment_profile_capability());
19506 assert!(request_requires_child_environment_profile(
19507 &shared,
19508 &NodeRequest::SpawnSpec { spec: spec.clone() },
19509 ));
19510
19511 let address = terminal_address(1);
19512 shared.bind_session_with_policy(
19513 &address,
19514 ProviderRuntimePolicy::raw_pty(),
19515 Some(environment_profile.clone()),
19516 );
19517 assert!(request_requires_child_environment_profile(
19518 &shared,
19519 &NodeRequest::Input {
19520 session: address.clone(),
19521 text: "x".to_owned(),
19522 },
19523 ));
19524
19525 let mut record = record("claude", "profile-record");
19526 record.active_session = Some(address.clone());
19527 record.environment_profile = Some(environment_profile.clone());
19528 let record_id = record.record_id.clone();
19529 shared.insert_record(record.clone()).unwrap();
19530 assert!(request_requires_child_environment_profile(
19531 &shared,
19532 &NodeRequest::ResumeSessionRecord {
19533 record_id,
19534 terminal_size: gate4agent_types::TerminalSize {
19535 rows: 24,
19536 columns: 80,
19537 },
19538 initial_prompt: None,
19539 },
19540 ));
19541
19542 let snapshot = snapshot_for_wire(
19543 &shared, false, true, true, true, false, false, false, false, true,
19544 );
19545 assert_eq!(snapshot.session_records.len(), 1);
19546 assert!(snapshot.session_records[0].environment_profile.is_none());
19547 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
19548 sequence: 1,
19549 event: NodeEvent::SessionRecordUpserted { record },
19550 });
19551 let NodeEvent::SessionRecordUpserted { record } = event.event else {
19552 panic!("record projection changed the event kind");
19553 };
19554 assert!(record.environment_profile.is_none());
19555
19556 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
19557 let mut reply = ResponseEnvelope {
19558 request_id: 1,
19559 result: Ok(NodeResponse::SpawnSpecAccepted {
19560 receipt: resolved.receipt_with_environment(
19561 shared.incarnation_id,
19562 address,
19563 Some(environment_profile),
19564 ),
19565 }),
19566 };
19567 project_response_without_child_environment_profile(&mut reply);
19568 assert_eq!(
19569 reply.result.unwrap_err().code,
19570 NodeFailureCode::UnsupportedCapability,
19571 );
19572 }
19573
19574 #[test]
19575 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
19576 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
19577 let snapshot = shared.snapshot();
19578 let profiles = snapshot
19579 .launch_inventory
19580 .as_ref()
19581 .and_then(|inventory| inventory.spawn_profiles.as_ref())
19582 .unwrap();
19583 assert_eq!(profiles.len(), 1);
19584 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
19585 assert!(snapshot.requires_child_environment_profile_capability());
19586
19587 let mut legacy = snapshot.clone();
19588 clear_snapshot_child_environment_profiles(&mut legacy);
19589 assert!(legacy
19590 .launch_inventory
19591 .as_ref()
19592 .unwrap()
19593 .spawn_profiles
19594 .as_ref()
19595 .unwrap()[0]
19596 .environment_profile
19597 .is_none());
19598 assert!(!legacy.requires_child_environment_profile_capability());
19599
19600 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
19601 let bindings = shared
19602 .environment_profiles
19603 .read()
19604 .unwrap_or_else(|poisoned| poisoned.into_inner())
19605 .clone();
19606 let mut provider_mismatch = bindings.clone();
19607 provider_mismatch
19608 .values_mut()
19609 .next()
19610 .unwrap()
19611 .provider = agent("codex");
19612 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
19613 let mut mode_mismatch = profile;
19614 mode_mismatch.mode = SessionMode::Inline;
19615 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
19616
19617 shared
19618 .environment_profiles
19619 .write()
19620 .unwrap_or_else(|poisoned| poisoned.into_inner())
19621 .clear();
19622 assert!(shared
19623 .snapshot()
19624 .launch_inventory
19625 .unwrap()
19626 .spawn_profiles
19627 .unwrap()
19628 .is_empty());
19629 }
19630
19631 #[test]
19632 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
19633 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19634 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
19635 }));
19636 let shared = terminal_test_shared();
19637 let bundle = ResolvedBundleReceipt {
19638 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
19639 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
19640 digest: crate::protocol::SpawnBundleDigest::new(format!(
19641 "sha256:{}",
19642 "0".repeat(64),
19643 ))
19644 .unwrap(),
19645 };
19646 let mut bundled = record("claude", "bundle-record");
19647 bundled.bundle = Some(bundle.clone());
19648 let record_id = bundled.record_id.clone();
19649 shared.insert_record(bundled.clone()).unwrap();
19650 assert!(request_requires_session_bundle(
19651 &shared,
19652 &NodeRequest::ForgetSessionRecord {
19653 record_id: record_id.clone(),
19654 },
19655 ));
19656
19657 let snapshot = snapshot_for_wire(
19658 &shared, false, true, true, true, true, false, false, false, true,
19659 );
19660 assert!(snapshot.session_records[0].bundle.is_none());
19661 let launch = snapshot.launch_inventory.as_ref().unwrap();
19662 assert!(launch.spawn_profiles.is_some());
19663 assert!(launch.bundles.is_none());
19664 let event = project_event_without_session_bundle(NodeEventEnvelope {
19665 sequence: 1,
19666 event: NodeEvent::SessionRecordUpserted { record: bundled },
19667 });
19668 let NodeEvent::SessionRecordUpserted { record } = event.event else {
19669 panic!("record projection changed the event kind");
19670 };
19671 assert!(record.bundle.is_none());
19672
19673 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
19674 let mut reply = ResponseEnvelope {
19675 request_id: 1,
19676 result: Ok(NodeResponse::SpawnSpecAccepted {
19677 receipt: resolved.receipt_with_materialization(
19678 shared.incarnation_id,
19679 terminal_address(17),
19680 None,
19681 Some(bundle),
19682 None,
19683 ),
19684 }),
19685 };
19686 project_response_without_session_bundle(&mut reply);
19687 assert_eq!(
19688 reply.result.unwrap_err().code,
19689 NodeFailureCode::UnsupportedCapability,
19690 );
19691 }
19692
19693 #[test]
19694 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
19695 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19696 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
19697 }));
19698 let context_id = SpawnContextId::new("context-review").unwrap();
19699 let context = ResolvedContextPackReceipt {
19700 id: context_id.clone(),
19701 digest: crate::protocol::SpawnContextDigest::new(format!(
19702 "sha256:{}",
19703 "c".repeat(64),
19704 ))
19705 .unwrap(),
19706 lineage: ContextPackLineageReceipt {
19707 source_node_id: NodeId::new("node-source").unwrap(),
19708 source_session: terminal_address(1),
19709 source_provider: agent("kimi"),
19710 },
19711 source_message_count: 2,
19712 retained_message_count: 2,
19713 byte_len: 256,
19714 truncated: false,
19715 };
19716 assert!(context.is_valid());
19717 let mut bound_record = record("codex", "context-record");
19718 bound_record.context_id = Some(context_id);
19719 bound_record.context = Some(context.clone());
19720 let session = gate4agent_types::SessionSnapshot {
19721 instance_id: AgentInstanceId(91),
19722 agent_id: agent("kimi"),
19723 transport: TransportKind::Pty,
19724 generation: SessionGeneration(3),
19725 status: SessionStatus::Running,
19726 pending_operation: None,
19727 pending_input: None,
19728 process_id: Some(9001),
19729 terminal_size: None,
19730 terminal_frame: None,
19731 terminal_stale: None,
19732 session_options: None,
19733 capabilities: gate4agent_types::CapabilitySnapshot::default(),
19734 history: gate4agent_types::HistorySnapshot {
19735 pending: None,
19736 candidates: vec![HistoryCandidateSummary {
19737 id: "candidate-opaque".to_owned(),
19738 session_id_hint: "session-hint".to_owned(),
19739 modified_at_unix_ms: Some(1),
19740 }],
19741 loaded_candidate_id: Some("candidate-opaque".to_owned()),
19742 loaded: Some(HistorySessionRecord {
19743 session_id: "private-vendor-session".to_owned(),
19744 title: Some("private title".to_owned()),
19745 cwd: Some(r"C:\private\history-root".to_owned()),
19746 model: Some("private-model".to_owned()),
19747 message_count: 2,
19748 completed_turn_count: None,
19749 total_tokens: 19,
19750 messages: vec![
19751 gate4agent_types::HistoryMessageRecord {
19752 role: gate4agent_types::HistoryMessageRole::User,
19753 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
19754 },
19755 gate4agent_types::HistoryMessageRecord {
19756 role: gate4agent_types::HistoryMessageRole::Assistant,
19757 text: "private answer".to_owned(),
19758 },
19759 ],
19760 }),
19761 last_error: None,
19762 },
19763 resume: gate4agent_types::ResumeSnapshot::default(),
19764 foreground: gate4agent_types::ForegroundSnapshot::default(),
19765 provider: gate4agent_types::ProviderSnapshot::default(),
19766 screen_state: gate4agent_types::PtyScreenState::default(),
19767 };
19768 let original = NodeSnapshot {
19769 node_id: NodeId::new("node-terminal-test").unwrap(),
19770 enabled_providers: vec![agent("kimi"), agent("codex")],
19771 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19772 workspaces: vec![WorkspaceSnapshot {
19773 workspace_id: WorkspaceId::new("primary").unwrap(),
19774 canonical_root: opaque_windows_path(
19775 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
19776 ),
19777 sessions: vec![session],
19778 worktree_service_mode: None,
19779 managed_worktree_profiles: None,
19780 }],
19781 session_records: vec![bound_record],
19782 managed_worktrees: Vec::new(),
19783 launch_inventory: None,
19784 agent_progress: Vec::new(),
19785 };
19786
19787 let mut negotiated = original.clone();
19788 project_snapshot_history_for_wire(&mut negotiated, true);
19789 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
19790 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
19791 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
19792 assert!(!serde_json::to_string(&negotiated)
19793 .unwrap()
19794 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
19795
19796 let mut legacy = original;
19797 project_snapshot_history_for_wire(&mut legacy, false);
19798 clear_snapshot_context_packs(&mut legacy);
19799 assert_eq!(
19800 legacy.workspaces[0].sessions[0].history,
19801 gate4agent_types::HistorySnapshot::default(),
19802 );
19803 assert!(legacy.session_records[0].context_id.is_none());
19804 assert!(legacy.session_records[0].context.is_none());
19805 let encoded = serde_json::to_string(&legacy).unwrap();
19806 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
19807 assert!(!encoded.contains("context-review"));
19808
19809 let mut reply = ResponseEnvelope {
19810 request_id: 7,
19811 result: Ok(NodeResponse::ContextPackExported { context }),
19812 };
19813 project_response_without_context_pack(&mut reply);
19814 assert_eq!(
19815 reply.result.unwrap_err().code,
19816 NodeFailureCode::UnsupportedCapability,
19817 );
19818 }
19819
19820 #[test]
19821 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
19822 let shared = terminal_test_shared();
19823 let history = HistorySessionRecord {
19824 session_id: "fixture-history".to_owned(),
19825 title: None,
19826 cwd: None,
19827 model: None,
19828 message_count: 1,
19829 completed_turn_count: None,
19830 total_tokens: 0,
19831 messages: vec![gate4agent_types::HistoryMessageRecord {
19832 role: gate4agent_types::HistoryMessageRole::User,
19833 text: "bounded handoff".to_owned(),
19834 }],
19835 };
19836 let pack = NodeContextPack::export(
19837 ContextPackLineageReceipt {
19838 source_node_id: shared.node_id.clone(),
19839 source_session: terminal_address(1),
19840 source_provider: agent("claude"),
19841 },
19842 &history,
19843 )
19844 .unwrap();
19845 let context = shared
19846 .context_catalog
19847 .write()
19848 .unwrap_or_else(|poisoned| poisoned.into_inner())
19849 .insert(pack)
19850 .unwrap();
19851 let mut retained = record("codex", "context-retained-record");
19852 retained.context_id = Some(context.id.clone());
19853 retained.context = Some(context.clone());
19854 let record_id = retained.record_id.clone();
19855 shared.insert_record(retained).unwrap();
19856 assert_eq!(
19857 shared.forget_context_pack(&context.id).unwrap_err().code,
19858 NodeFailureCode::ContextPackBusy,
19859 );
19860 assert!(shared
19861 .context_catalog
19862 .read()
19863 .unwrap_or_else(|poisoned| poisoned.into_inner())
19864 .get(&context.id)
19865 .is_some());
19866 shared
19867 .session_records
19868 .lock()
19869 .unwrap_or_else(|poisoned| poisoned.into_inner())
19870 .records
19871 .remove(&record_id);
19872
19873 let mut spec = spawn_spec_fixture();
19874 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
19875 value: context.id.clone(),
19876 };
19877 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
19878 let receipt = resolved.receipt_with_materialization(
19879 shared.incarnation_id,
19880 terminal_address(2),
19881 None,
19882 None,
19883 Some(context.clone()),
19884 );
19885 assert!(receipt.context_binding_is_valid());
19886 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
19887
19888 shared.forget_context_pack(&context.id).unwrap();
19889 assert!(shared
19890 .context_catalog
19891 .read()
19892 .unwrap_or_else(|poisoned| poisoned.into_inner())
19893 .get(&context.id)
19894 .is_none());
19895 assert!(shared
19896 .replay_spawn_spec(&spec, Instant::now())
19897 .unwrap()
19898 .is_none());
19899 }
19900
19901 #[test]
19902 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
19903 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
19904 let address = terminal_address(1);
19905 let selection = shared
19906 .select_environment_profile(
19907 address.session.instance_id,
19908 &agent("claude"),
19909 SessionMode::Pty,
19910 Some(&environment_profile),
19911 )
19912 .unwrap()
19913 .unwrap();
19914 shared.bind_session_with_policy(
19915 &address,
19916 ProviderRuntimePolicy::raw_pty(),
19917 Some(environment_profile.clone()),
19918 );
19919 selection.retain();
19920 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
19921 assert!(matches!(
19922 runtime.remove_native_launch_profile(&native_id),
19923 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
19924 ));
19925 assert!(shared.remove_binding(&address).is_some());
19926 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
19927
19928 let mismatched = ResolvedEnvironmentProfileReceipt {
19929 profile_id: environment_profile.profile_id,
19930 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
19931 "local-claude-r2",
19932 )
19933 .unwrap(),
19934 network_allowlist: None,
19935 browser_profile_id: None,
19936 };
19937 let error = match shared.select_environment_profile(
19938 AgentInstanceId(42),
19939 &agent("claude"),
19940 SessionMode::Pty,
19941 Some(&mismatched),
19942 ) {
19943 Ok(_) => panic!("mismatched environment profile revision was selected"),
19944 Err(error) => error,
19945 };
19946 assert_eq!(
19947 error.code,
19948 NodeFailureCode::EnvironmentProfileBindingMismatch,
19949 );
19950 assert!(!shared
19951 .session_bindings
19952 .lock()
19953 .unwrap_or_else(|poisoned| poisoned.into_inner())
19954 .contains_key(&AgentInstanceId(42)));
19955 }
19956
19957 #[test]
19958 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
19959 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
19960 let address = terminal_address(1);
19961 let selection = shared
19962 .select_environment_profile(
19963 address.session.instance_id,
19964 &agent("claude"),
19965 SessionMode::Pty,
19966 Some(&environment_profile),
19967 )
19968 .unwrap()
19969 .unwrap();
19970 let overlay = NativeInstanceLaunchOverlay::new(
19971 agent("claude"),
19972 TransportKind::Pty,
19973 vec![EnvMutation {
19974 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
19975 value: Some(OsString::from("fixture-secret")),
19976 }],
19977 vec![
19978 OsString::from("--plugin-dir"),
19979 std::env::current_dir().unwrap().into_os_string(),
19980 ],
19981 )
19982 .unwrap();
19983 let instance_overlay = shared
19984 .install_prepared_launch_overlay(
19985 address.session.instance_id,
19986 PreparedNativeLaunchOverlay::Instance(overlay),
19987 )
19988 .unwrap()
19989 .unwrap();
19990 let bundle = ResolvedBundleReceipt {
19991 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
19992 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
19993 digest: crate::protocol::SpawnBundleDigest::new(format!(
19994 "sha256:{}",
19995 "0".repeat(64),
19996 ))
19997 .unwrap(),
19998 };
19999 shared.bind_session_with_materialization(
20000 &address,
20001 ProviderRuntimePolicy::raw_pty(),
20002 Some(environment_profile),
20003 Some(bundle),
20004 None,
20005 None,
20006 );
20007 selection.retain();
20008 instance_overlay.retain();
20009
20010 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20011 assert!(matches!(
20012 runtime.remove_native_launch_profile(&native_id),
20013 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20014 ));
20015 assert!(shared.remove_binding(&address).is_some());
20016 assert!(!shared
20017 .native_launch_profile_control
20018 .as_ref()
20019 .unwrap()
20020 .clear_native_instance_launch_overlay(address.session.instance_id));
20021 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20022 }
20023
20024 #[test]
20025 fn session_environment_materialization_failure_is_pre_child_and_durable() {
20026 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20027 let address = SessionAddress {
20028 workspace_id: WorkspaceId::new("primary").unwrap(),
20029 session: SessionKey {
20030 instance_id: AgentInstanceId(201),
20031 generation: SessionGeneration(1),
20032 },
20033 };
20034 let error = match shared.prepare_session_materialization(
20035 &address,
20036 &agent("claude"),
20037 SessionMode::Pty,
20038 Some(&receipt),
20039 None,
20040 None,
20041 None,
20042 ) {
20043 Ok(_) => panic!("denied local reference unexpectedly materialized"),
20044 Err(error) => error,
20045 };
20046 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20047 assert!(shared.handle.snapshot().sessions.is_empty());
20048 assert!(shared
20049 .session_bindings
20050 .lock()
20051 .unwrap_or_else(|poisoned| poisoned.into_inner())
20052 .is_empty());
20053 assert!(shared
20054 .materializations
20055 .lock()
20056 .unwrap_or_else(|poisoned| poisoned.into_inner())
20057 .is_empty());
20058 drop(shared);
20059 drop(runtime);
20060 std::fs::remove_dir_all(root).unwrap();
20061 }
20062
20063 #[test]
20064 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
20065 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20066 let address = SessionAddress {
20067 workspace_id: WorkspaceId::new("primary").unwrap(),
20068 session: SessionKey {
20069 instance_id: AgentInstanceId(202),
20070 generation: SessionGeneration(1),
20071 },
20072 };
20073 let (overlay, materialization_guard) = shared
20074 .prepare_session_materialization(
20075 &address,
20076 &agent("claude"),
20077 SessionMode::Pty,
20078 Some(&receipt),
20079 None,
20080 None,
20081 None,
20082 )
20083 .unwrap()
20084 .unwrap();
20085 let materialization_id = materialization_guard.id().unwrap().clone();
20086 let materialization_root = shared
20087 .materializations
20088 .lock()
20089 .unwrap_or_else(|poisoned| poisoned.into_inner())
20090 .get(&materialization_id)
20091 .unwrap()
20092 .root()
20093 .to_path_buf();
20094 let selection = shared
20095 .select_environment_profile(
20096 address.session.instance_id,
20097 &agent("claude"),
20098 SessionMode::Pty,
20099 Some(&receipt),
20100 )
20101 .unwrap()
20102 .unwrap();
20103 assert!(shared
20104 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20105 .unwrap()
20106 .is_none());
20107 shared.bind_session_with_materialization(
20108 &address,
20109 ProviderRuntimePolicy::raw_pty(),
20110 Some(receipt),
20111 None,
20112 None,
20113 Some(materialization_id),
20114 );
20115 selection.retain();
20116 materialization_guard.retain();
20117
20118 assert!(materialization_root.is_dir());
20119 assert!(shared
20120 .session_bindings
20121 .lock()
20122 .unwrap_or_else(|poisoned| poisoned.into_inner())
20123 .contains_key(&address.session.instance_id));
20124
20125 let removed = shared.remove_binding(&address).unwrap();
20126 shared
20127 .cleanup_session_owned_materialization(&address, &removed)
20128 .unwrap();
20129 assert!(!materialization_root.exists());
20130 assert!(shared
20131 .materializations
20132 .lock()
20133 .unwrap_or_else(|poisoned| poisoned.into_inner())
20134 .is_empty());
20135 drop(shared);
20136 drop(runtime);
20137 std::fs::remove_dir_all(root).unwrap();
20138 }
20139
20140 #[tokio::test]
20141 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
20142 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20143 let address = SessionAddress {
20144 workspace_id: WorkspaceId::new("primary").unwrap(),
20145 session: SessionKey {
20146 instance_id: AgentInstanceId(203),
20147 generation: SessionGeneration(1),
20148 },
20149 };
20150 let (overlay, materialization_guard) = shared
20151 .prepare_session_materialization(
20152 &address,
20153 &agent("claude"),
20154 SessionMode::Pty,
20155 Some(&receipt),
20156 None,
20157 None,
20158 None,
20159 )
20160 .unwrap()
20161 .unwrap();
20162 let materialization_id = materialization_guard.id().unwrap().clone();
20163 let materialization_root = shared
20164 .materializations
20165 .lock()
20166 .unwrap_or_else(|poisoned| poisoned.into_inner())
20167 .get(&materialization_id)
20168 .unwrap()
20169 .root()
20170 .to_path_buf();
20171 let selection = shared
20172 .select_environment_profile(
20173 address.session.instance_id,
20174 &agent("claude"),
20175 SessionMode::Pty,
20176 Some(&receipt),
20177 )
20178 .unwrap()
20179 .unwrap();
20180 assert!(shared
20181 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20182 .unwrap()
20183 .is_none());
20184 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20185 let record_id = shared
20186 .bind_spawn_session_with_materialization(
20187 &address,
20188 agent("claude"),
20189 SessionMode::Pty,
20190 policy,
20191 SpawnRecordPolicy::ProviderIdentityOnly,
20192 Some(receipt.clone()),
20193 None,
20194 None,
20195 Some(materialization_id.clone()),
20196 )
20197 .unwrap()
20198 .unwrap();
20199 selection.retain();
20200 materialization_guard.retain();
20201 assert_eq!(
20202 shared
20203 .materializations
20204 .lock()
20205 .unwrap_or_else(|poisoned| poisoned.into_inner())
20206 .get(&materialization_id)
20207 .unwrap()
20208 .owner(),
20209 &MaterializationOwner::Record {
20210 record_id: record_id.clone(),
20211 },
20212 );
20213
20214 let removed = shared.remove_binding(&address).unwrap();
20215 shared
20216 .cleanup_session_owned_materialization(&address, &removed)
20217 .unwrap();
20218 assert!(materialization_root.is_dir());
20219 assert!(shared
20220 .resolve_record_materialization(
20221 &record_id,
20222 &agent("claude"),
20223 SessionMode::Pty,
20224 Some(&receipt),
20225 None,
20226 None,
20227 )
20228 .unwrap()
20229 .is_some());
20230 {
20231 let mut records = shared
20232 .session_records
20233 .lock()
20234 .unwrap_or_else(|poisoned| poisoned.into_inner());
20235 let record = records.records.get_mut(&record_id).unwrap();
20236 record.active_session = None;
20237 record.state = ManagedSessionState::Unavailable;
20238 }
20239 shared.forget_session_record(&record_id).await.unwrap();
20240 assert!(!materialization_root.exists());
20241 assert!(shared
20242 .materializations
20243 .lock()
20244 .unwrap_or_else(|poisoned| poisoned.into_inner())
20245 .is_empty());
20246 drop(shared);
20247 drop(runtime);
20248 std::fs::remove_dir_all(root).unwrap();
20249 }
20250
20251 #[test]
20252 fn record_materialization_revalidation_failure_persists_recovery_required() {
20253 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20254 let state_path = root.join("node-state-v6.json");
20255 shared.state_path = Some(state_path.clone());
20256 let (_address, _record_id, materialization_id, materialization_root) =
20257 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
20258 std::fs::write(
20259 materialization_root.join(".gate4agent-materialization-owner"),
20260 b"tampered",
20261 )
20262 .unwrap();
20263
20264 let error = match shared.resolve_record_materialization(
20265 &_record_id,
20266 &agent("claude"),
20267 SessionMode::Pty,
20268 Some(&receipt),
20269 None,
20270 None,
20271 ) {
20272 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
20273 Err(error) => error,
20274 };
20275 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20276 assert_eq!(
20277 shared
20278 .materializations
20279 .lock()
20280 .unwrap_or_else(|poisoned| poisoned.into_inner())
20281 .get(&materialization_id)
20282 .unwrap()
20283 .state(),
20284 MaterializationState::RecoveryRequired,
20285 );
20286 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
20287 assert_eq!(loaded.materializations.len(), 1);
20288 assert_eq!(
20289 loaded.materializations[0].state(),
20290 MaterializationState::RecoveryRequired,
20291 );
20292 drop(shared);
20293 drop(runtime);
20294 std::fs::remove_dir_all(root).unwrap();
20295 }
20296
20297 #[test]
20298 fn transient_record_materialization_resolution_failure_remains_ready() {
20299 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
20300 let (_address, record_id, materialization_id, _materialization_root) =
20301 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
20302 deny.store(true, Ordering::Release);
20303
20304 let error = match shared.resolve_record_materialization(
20305 &record_id,
20306 &agent("claude"),
20307 SessionMode::Pty,
20308 Some(&receipt),
20309 None,
20310 None,
20311 ) {
20312 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
20313 Err(error) => error,
20314 };
20315 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20316 assert_eq!(
20317 shared
20318 .materializations
20319 .lock()
20320 .unwrap_or_else(|poisoned| poisoned.into_inner())
20321 .get(&materialization_id)
20322 .unwrap()
20323 .state(),
20324 MaterializationState::Ready,
20325 );
20326 drop(shared);
20327 drop(runtime);
20328 std::fs::remove_dir_all(root).unwrap();
20329 }
20330
20331 #[test]
20332 fn startup_missing_record_materialization_is_not_resumable() {
20333 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20334 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
20335 shared
20336 .insert_record(ManagedSessionRecord {
20337 record_id: record_id.clone(),
20338 display_name: "missing materialization".to_owned(),
20339 provider: agent("claude"),
20340 mode: SessionMode::Pty,
20341 state: ManagedSessionState::Dormant,
20342 workspace_id: WorkspaceId::new("primary").unwrap(),
20343 canonical_root: opaque_windows_path(
20344 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20345 ),
20346 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
20347 key: gate4agent_types::ProviderSessionKey::SessionId,
20348 id: "provider-session-missing-materialization".to_owned(),
20349 transcript_path: None,
20350 }),
20351 active_session: None,
20352 environment_profile: Some(receipt),
20353 bundle: None,
20354 context_id: None,
20355 context: None,
20356 exported_context: None,
20357 task_binding: None,
20358 created_at_unix_ms: 1,
20359 updated_at_unix_ms: 1,
20360 last_error: None,
20361 })
20362 .unwrap();
20363
20364 shared.reconcile_materializations();
20365 let record = shared.record(&record_id).unwrap();
20366 assert_eq!(record.state, ManagedSessionState::Unavailable);
20367 assert!(record.provider_session.is_none());
20368 assert_eq!(
20369 record.last_error.as_deref(),
20370 Some("environment-profile-unavailable"),
20371 );
20372 drop(shared);
20373 drop(runtime);
20374 std::fs::remove_dir_all(root).unwrap();
20375 }
20376
20377 #[tokio::test]
20378 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
20379 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20380 let lease = install_managed_lease(&shared);
20381 let address = SessionAddress {
20382 workspace_id: WorkspaceId::new("primary").unwrap(),
20383 session: SessionKey {
20384 instance_id: AgentInstanceId(204),
20385 generation: SessionGeneration(1),
20386 },
20387 };
20388 let (overlay, materialization_guard) = shared
20389 .prepare_session_materialization(
20390 &address,
20391 &agent("claude"),
20392 SessionMode::Pty,
20393 Some(&receipt),
20394 None,
20395 None,
20396 Some(lease.lease_id.clone()),
20397 )
20398 .unwrap()
20399 .unwrap();
20400 let materialization_id = materialization_guard.id().unwrap().clone();
20401 let materialization_root = shared
20402 .materializations
20403 .lock()
20404 .unwrap_or_else(|poisoned| poisoned.into_inner())
20405 .get(&materialization_id)
20406 .unwrap()
20407 .root()
20408 .to_path_buf();
20409 let selection = shared
20410 .select_environment_profile(
20411 address.session.instance_id,
20412 &agent("claude"),
20413 SessionMode::Pty,
20414 Some(&receipt),
20415 )
20416 .unwrap()
20417 .unwrap();
20418 assert!(shared
20419 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20420 .unwrap()
20421 .is_none());
20422 shared.bind_session_with_materialization(
20423 &address,
20424 ProviderRuntimePolicy::raw_pty(),
20425 Some(receipt),
20426 None,
20427 None,
20428 Some(materialization_id),
20429 );
20430 selection.retain();
20431 materialization_guard.retain();
20432
20433 let blocked = shared
20434 .cleanup_managed_worktree(&lease.lease_id, false)
20435 .await
20436 .unwrap_err();
20437 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
20438 assert!(materialization_root.is_dir());
20439 let removed = shared.remove_binding(&address).unwrap();
20440 shared
20441 .cleanup_session_owned_materialization(&address, &removed)
20442 .unwrap();
20443 assert!(!materialization_root.exists());
20444 drop(shared);
20445 drop(runtime);
20446 std::fs::remove_dir_all(root).unwrap();
20447 }
20448
20449 #[tokio::test]
20450 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
20451 let git_root = temporary_workspace_root("raw-managed-inventory");
20452 let source = git_root.join("source");
20453 let allocation = git_root.join("allocation");
20454 std::fs::create_dir_all(&source).unwrap();
20455 std::fs::create_dir_all(&allocation).unwrap();
20456 run_test_git(&source, &["init"]);
20457 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
20458 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
20459 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
20460 run_test_git(&source, &["add", "seed.txt"]);
20461 run_test_git(&source, &["commit", "-m", "seed"]);
20462 let profile = ManagedWorktreeProfile::new(
20463 WorktreeProfileId::new("default").unwrap(),
20464 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20465 &allocation,
20466 "gate4agent",
20467 "HEAD",
20468 ManagedWorktreeRetention::RemoveWhenReleased,
20469 )
20470 .unwrap();
20471 let workspace = WorkspaceConfig::new(
20472 WorkspaceId::new("primary").unwrap(),
20473 &source,
20474 )
20475 .unwrap()
20476 .with_worktree_service_mode(WorktreeServiceMode::Managed)
20477 .with_managed_worktree_profile(profile.clone())
20478 .unwrap();
20479 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
20480 materialization_test_shared_with_workspace(false, workspace);
20481 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
20482 let source_text = source.to_string_lossy().into_owned();
20483 let target_text = target.to_string_lossy().into_owned();
20484 let created = create_git_worktree(
20485 &source_text,
20486 &target_text,
20487 "gate4agent/mw-raw-inventory",
20488 Some("HEAD"),
20489 )
20490 .await
20491 .unwrap();
20492 let lease = ManagedWorktreeLeaseRecord {
20493 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
20494 source_workspace_id: WorkspaceId::new("primary").unwrap(),
20495 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
20496 profile_id: profile.profile_id().clone(),
20497 profile_revision: profile.revision().clone(),
20498 target_root: created.path.clone(),
20499 branch: "gate4agent/mw-raw-inventory".to_owned(),
20500 base_commit: created.head.clone(),
20501 expected_head: Some(created.head.clone()),
20502 retention: ManagedWorktreeRetention::RemoveWhenReleased,
20503 state: ManagedWorktreeLeaseState::Ready,
20504 session_holders: Vec::new(),
20505 record_holders: Vec::new(),
20506 cleanup_failure: None,
20507 created_at_unix_ms: 1,
20508 updated_at_unix_ms: 1,
20509 };
20510 shared
20511 .workspaces
20512 .write()
20513 .unwrap_or_else(|poisoned| poisoned.into_inner())
20514 .insert(lease.workspace_id.clone(), created.path.clone());
20515 *shared
20516 .managed_worktrees
20517 .lock()
20518 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20519 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
20520 let address = SessionAddress {
20521 workspace_id: lease.workspace_id.clone(),
20522 session: SessionKey {
20523 instance_id: AgentInstanceId(205),
20524 generation: SessionGeneration(1),
20525 },
20526 };
20527 let (overlay, materialization_guard) = shared
20528 .prepare_session_materialization(
20529 &address,
20530 &agent("claude"),
20531 SessionMode::Pty,
20532 Some(&receipt),
20533 None,
20534 None,
20535 Some(lease.lease_id.clone()),
20536 )
20537 .unwrap()
20538 .unwrap();
20539 let materialization_id = materialization_guard.id().unwrap().clone();
20540 let materialization_root = shared
20541 .materializations
20542 .lock()
20543 .unwrap_or_else(|poisoned| poisoned.into_inner())
20544 .get(&materialization_id)
20545 .unwrap()
20546 .root()
20547 .to_path_buf();
20548 let selection = shared
20549 .select_environment_profile(
20550 address.session.instance_id,
20551 &agent("claude"),
20552 SessionMode::Pty,
20553 Some(&receipt),
20554 )
20555 .unwrap()
20556 .unwrap();
20557 assert!(shared
20558 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20559 .unwrap()
20560 .is_none());
20561 let record_id = shared
20562 .bind_spawn_session_with_materialization(
20563 &address,
20564 agent("claude"),
20565 SessionMode::Pty,
20566 ProviderRuntimePolicy::raw_pty(),
20567 SpawnRecordPolicy::Always,
20568 Some(receipt.clone()),
20569 None,
20570 None,
20571 Some(materialization_id.clone()),
20572 )
20573 .unwrap()
20574 .unwrap();
20575 selection.retain();
20576 materialization_guard.retain();
20577 let record = shared.record(&record_id).unwrap();
20578 assert_eq!(record.state, ManagedSessionState::Live);
20579 assert!(record.provider_session.is_none());
20580 assert!(record.environment_profile.is_none());
20581 assert!(record.bundle.is_none());
20582 assert!(record.context_id.is_none());
20583 assert!(record.context.is_none());
20584 assert_eq!(record.active_session.as_ref(), Some(&address));
20585 assert_eq!(
20586 shared
20587 .materializations
20588 .lock()
20589 .unwrap_or_else(|poisoned| poisoned.into_inner())
20590 .get(&materialization_id)
20591 .unwrap()
20592 .owner(),
20593 &MaterializationOwner::Session {
20594 incarnation_id: shared.incarnation_id,
20595 instance_id: address.session.instance_id,
20596 generation: address.session.generation,
20597 },
20598 );
20599 {
20600 let mut bindings = shared
20601 .session_bindings
20602 .lock()
20603 .unwrap_or_else(|poisoned| poisoned.into_inner());
20604 bindings
20605 .get_mut(&address.session.instance_id)
20606 .unwrap()
20607 .managed_worktree_lease_id = Some(lease.lease_id.clone());
20608 }
20609 let lease_snapshot = shared
20610 .managed_worktrees
20611 .lock()
20612 .unwrap_or_else(|poisoned| poisoned.into_inner())
20613 .bind_session(
20614 &lease.lease_id,
20615 ManagedWorktreeSessionHolder {
20616 incarnation_id: shared.incarnation_id,
20617 instance_id: address.session.instance_id,
20618 generation: address.session.generation,
20619 },
20620 None,
20621 unix_time_ms(),
20622 )
20623 .unwrap();
20624 assert_eq!(lease_snapshot.active_session_count, 1);
20625 assert_eq!(lease_snapshot.managed_record_count, 0);
20626 shared.persist_state().unwrap();
20627 assert!(shared
20628 .managed_worktrees
20629 .lock()
20630 .unwrap_or_else(|poisoned| poisoned.into_inner())
20631 .get(&lease.lease_id)
20632 .unwrap()
20633 .record_holders
20634 .is_empty());
20635 shared
20636 .handle
20637 .dispatch(shared.prepare_command(ControlCommand::Register {
20638 instance_id: address.session.instance_id,
20639 agent_id: agent("claude"),
20640 transport: TransportKind::Pty,
20641 }))
20642 .unwrap();
20643 runtime.tick().await;
20644 shared.reconcile_managed_record(
20645 &record_id,
20646 &address,
20647 &ControlEvent {
20648 sequence: 1,
20649 command_id: None,
20650 instance_id: address.session.instance_id,
20651 generation: address.session.generation,
20652 event: ControlEventKind::Removed,
20653 },
20654 false,
20655 false,
20656 );
20657 let remove = shared.remove_session(&address);
20658 let drive_runtime = async {
20659 runtime.tick().await;
20660 };
20661 let (removed, ()) = tokio::join!(remove, drive_runtime);
20662 removed.unwrap();
20663
20664 assert!(!materialization_root.exists());
20665 assert!(!Path::new(&created.path).exists());
20666 assert_eq!(
20667 shared
20668 .managed_worktrees
20669 .lock()
20670 .unwrap_or_else(|poisoned| poisoned.into_inner())
20671 .get(&lease.lease_id)
20672 .unwrap()
20673 .state,
20674 ManagedWorktreeLeaseState::Removed,
20675 );
20676 let renamed = shared
20677 .rename_session_record(&record_id, "raw managed inventory".to_owned())
20678 .unwrap();
20679 assert_eq!(renamed.record_id, record_id);
20680 assert_eq!(renamed.display_name, "raw managed inventory");
20681 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
20682 assert!(renamed.active_session.is_none());
20683
20684 drop(shared);
20685 drop(runtime);
20686 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
20687 std::fs::remove_dir_all(git_root).unwrap();
20688 }
20689
20690 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
20691 let root = std::env::current_dir().unwrap()
20692 .join("managed-test-target")
20693 .to_string_lossy()
20694 .into_owned();
20695 let lease = ManagedWorktreeLeaseRecord {
20696 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
20697 source_workspace_id: WorkspaceId::new("primary").unwrap(),
20698 workspace_id: WorkspaceId::new("managed-test").unwrap(),
20699 profile_id: WorktreeProfileId::new("default").unwrap(),
20700 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20701 target_root: root.clone(),
20702 branch: "gate4agent/mw-test".to_owned(),
20703 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
20704 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
20705 retention: ManagedWorktreeRetention::RemoveWhenReleased,
20706 state: ManagedWorktreeLeaseState::Ready,
20707 session_holders: Vec::new(),
20708 record_holders: Vec::new(),
20709 cleanup_failure: None,
20710 created_at_unix_ms: 1,
20711 updated_at_unix_ms: 1,
20712 };
20713 shared.workspaces.write()
20714 .unwrap_or_else(|poisoned| poisoned.into_inner())
20715 .insert(lease.workspace_id.clone(), root);
20716 *shared.managed_worktrees.lock()
20717 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20718 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
20719 lease
20720 }
20721
20722 #[cfg(feature = "fixture")]
20723 #[test]
20724 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
20725 let mut shared = terminal_test_shared();
20726 shared.fixture_semantic_hook_policy = true;
20727 let lease = install_managed_lease(&shared);
20728 let address = SessionAddress {
20729 workspace_id: lease.workspace_id.clone(),
20730 session: SessionKey {
20731 instance_id: AgentInstanceId(206),
20732 generation: SessionGeneration(1),
20733 },
20734 };
20735 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
20736 let effective_runtime_policy =
20737 shared.effective_spawn_runtime_policy(pre_admission_policy);
20738 assert_ne!(effective_runtime_policy, pre_admission_policy);
20739 assert!(effective_runtime_policy.provider_session_identity);
20740
20741 let record_id = shared
20742 .bind_spawn_session(
20743 &address,
20744 agent("claude"),
20745 SessionMode::Pty,
20746 effective_runtime_policy,
20747 SpawnRecordPolicy::Always,
20748 None,
20749 )
20750 .unwrap()
20751 .unwrap();
20752 assert_eq!(
20753 shared.record(&record_id).unwrap().state,
20754 ManagedSessionState::IdentityPending,
20755 );
20756 let (record_holder, raw_inventory_record_id) = {
20757 let mut bindings = shared
20758 .session_bindings
20759 .lock()
20760 .unwrap_or_else(|poisoned| poisoned.into_inner());
20761 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
20762 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
20763 managed_spawn_record_ownership(binding, effective_runtime_policy)
20764 };
20765 assert_eq!(record_holder.as_ref(), Some(&record_id));
20766 assert!(raw_inventory_record_id.is_none());
20767 let snapshot = shared
20768 .managed_worktrees
20769 .lock()
20770 .unwrap_or_else(|poisoned| poisoned.into_inner())
20771 .bind_session(
20772 &lease.lease_id,
20773 ManagedWorktreeSessionHolder {
20774 incarnation_id: shared.incarnation_id,
20775 instance_id: address.session.instance_id,
20776 generation: address.session.generation,
20777 },
20778 record_holder,
20779 unix_time_ms(),
20780 )
20781 .unwrap();
20782 assert_eq!(snapshot.managed_record_count, 1);
20783 assert_eq!(
20784 shared
20785 .managed_worktrees
20786 .lock()
20787 .unwrap_or_else(|poisoned| poisoned.into_inner())
20788 .get(&lease.lease_id)
20789 .unwrap()
20790 .record_holders,
20791 vec![record_id.clone()],
20792 );
20793
20794 let identity = ProviderSessionIdentity {
20795 key: ProviderSessionKey::SessionId,
20796 id: "monitoring-fixture-managed-session".to_owned(),
20797 transcript_path: None,
20798 };
20799 shared.publish_control(ControlEvent {
20800 sequence: 1,
20801 command_id: None,
20802 instance_id: address.session.instance_id,
20803 generation: address.session.generation,
20804 event: ControlEventKind::ProviderEvent {
20805 sequence: 1,
20806 source: gate4agent_types::ProviderSource {
20807 family: AdapterFamily::Hook,
20808 binding: AdapterBinding::new(
20809 gate4agent_types::AdapterId::new("claude-code").unwrap(),
20810 "fixture/v1",
20811 gate4agent_types::AdapterVerification::SyntheticFixture,
20812 )
20813 .unwrap(),
20814 },
20815 source_sequence: 1,
20816 event: ProviderEvent::SessionIdentityObserved {
20817 identity: identity.clone(),
20818 },
20819 },
20820 });
20821
20822 let live = shared.record(&record_id).unwrap();
20823 assert_eq!(live.state, ManagedSessionState::Live);
20824 assert_eq!(live.provider_session.as_ref(), Some(&identity));
20825 assert_eq!(live.active_session.as_ref(), Some(&address));
20826 assert_eq!(
20827 shared
20828 .managed_worktrees
20829 .lock()
20830 .unwrap_or_else(|poisoned| poisoned.into_inner())
20831 .get(&lease.lease_id)
20832 .unwrap()
20833 .record_holders,
20834 vec![record_id],
20835 );
20836 }
20837
20838 fn managed_v2_test_shared(
20839 allocation_root: &Path,
20840 state_path: Option<PathBuf>,
20841 profile_revision: &str,
20842 ) -> NodeShared {
20843 let profile = ManagedWorktreeProfile::new(
20844 WorktreeProfileId::new("default").unwrap(),
20845 WorktreeProfileRevision::new(profile_revision).unwrap(),
20846 allocation_root,
20847 "gate4agent",
20848 "HEAD",
20849 ManagedWorktreeRetention::RemoveWhenReleased,
20850 )
20851 .unwrap();
20852 let workspace = WorkspaceConfig::new(
20853 WorkspaceId::new("primary").unwrap(),
20854 std::env::current_dir().unwrap(),
20855 )
20856 .unwrap()
20857 .with_worktree_service_mode(WorktreeServiceMode::Managed)
20858 .with_managed_worktree_profile(profile)
20859 .unwrap();
20860 let catalog = active_registry().unwrap();
20861 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20862 let mut shared = NodeShared::new(
20863 handle,
20864 "fixture-token".to_owned(),
20865 NodeId::new("node-terminal-test").unwrap(),
20866 vec![workspace],
20867 vec![agent("claude")],
20868 );
20869 shared.state_path = state_path;
20870 shared
20871 }
20872
20873 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
20874 let mut spawn_spec = spawn_spec_fixture();
20875 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
20876 ManagedWorktreeSpawnRequestV2 {
20877 spawn_spec,
20878 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
20879 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
20880 }
20881 }
20882
20883 #[test]
20884 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
20885 let shared = terminal_test_shared();
20886 let mut lease = install_managed_lease(&shared);
20887 let session = SessionAddress {
20888 workspace_id: lease.workspace_id.clone(),
20889 session: SessionKey {
20890 instance_id: AgentInstanceId(73),
20891 generation: SessionGeneration(4),
20892 },
20893 };
20894 lease.state = ManagedWorktreeLeaseState::InUse;
20895 lease.session_holders.push(ManagedWorktreeSessionHolder {
20896 incarnation_id: shared.incarnation_id,
20897 instance_id: session.session.instance_id,
20898 generation: session.session.generation,
20899 });
20900 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20901 assert!(resolved.target.worktree_id.is_none());
20902
20903 let receipt = managed_spawn_receipt(
20904 &resolved,
20905 shared.incarnation_id,
20906 session.clone(),
20907 lease.snapshot(),
20908 None,
20909 None,
20910 None,
20911 );
20912 assert!(resolved.target.worktree_id.is_none());
20913 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
20914 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
20915 assert_eq!(receipt.spawn.session, session);
20916 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
20917 let encoded = serde_json::to_vec(&receipt).unwrap();
20918 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
20919 assert_eq!(decoded, receipt);
20920 }
20921
20922 #[tokio::test]
20923 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
20924 let root = temporary_workspace_root("managed-v2-revision");
20925 let allocation = root.join("allocation");
20926 std::fs::create_dir_all(&allocation).unwrap();
20927 let shared = managed_v2_test_shared(
20928 &allocation,
20929 Some(root.join("node-state.json")),
20930 "v1",
20931 );
20932 let mut request = managed_v2_request("managed-v2-revision");
20933 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
20934
20935 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
20936
20937 assert_eq!(
20938 failure.code,
20939 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
20940 );
20941 assert!(shared
20942 .managed_worktrees
20943 .lock()
20944 .unwrap_or_else(|poisoned| poisoned.into_inner())
20945 .records()
20946 .is_empty());
20947 assert!(shared
20948 .managed_spawn_replays
20949 .lock()
20950 .unwrap_or_else(|poisoned| poisoned.into_inner())
20951 .is_empty());
20952 std::fs::remove_dir_all(root).unwrap();
20953 }
20954
20955 #[test]
20956 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
20957 let root = temporary_workspace_root("managed-v2-concurrent");
20958 let allocation = root.join("allocation");
20959 std::fs::create_dir_all(&allocation).unwrap();
20960 let shared = Arc::new(managed_v2_test_shared(
20961 &allocation,
20962 Some(root.join("node-state.json")),
20963 "v1",
20964 ));
20965 let request = managed_v2_request("managed-v2-concurrent");
20966 let barrier = Arc::new(std::sync::Barrier::new(3));
20967 let mut joins = Vec::new();
20968 for _ in 0..2 {
20969 let shared = Arc::clone(&shared);
20970 let request = request.clone();
20971 let barrier = Arc::clone(&barrier);
20972 joins.push(std::thread::spawn(move || {
20973 let digest = managed_spawn_request_digest_v2(&request).unwrap();
20974 barrier.wait();
20975 shared.acquire_durable_managed_spawn_v2(&request, digest)
20976 }));
20977 }
20978 barrier.wait();
20979 let results = joins
20980 .into_iter()
20981 .map(|join| join.join().unwrap())
20982 .collect::<Vec<_>>();
20983 assert_eq!(
20984 results
20985 .iter()
20986 .filter(|result| {
20987 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
20988 })
20989 .count(),
20990 1,
20991 );
20992 assert_eq!(
20993 results
20994 .iter()
20995 .filter(|result| {
20996 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
20997 })
20998 .count(),
20999 1,
21000 );
21001 assert_eq!(
21002 shared
21003 .managed_spawn_replays
21004 .lock()
21005 .unwrap_or_else(|poisoned| poisoned.into_inner())
21006 .len(),
21007 1,
21008 );
21009 assert!(shared
21010 .managed_worktrees
21011 .lock()
21012 .unwrap_or_else(|poisoned| poisoned.into_inner())
21013 .records()
21014 .is_empty());
21015 drop(shared);
21016 std::fs::remove_dir_all(root).unwrap();
21017 }
21018
21019 #[tokio::test]
21020 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
21021 let root = temporary_workspace_root("managed-v2-linked-pending");
21022 let allocation = root.join("allocation");
21023 std::fs::create_dir_all(&allocation).unwrap();
21024 let state_path = root.join("node-state.json");
21025 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21026 let lease = install_managed_lease(&shared);
21027 let request = managed_v2_request("managed-v2-linked-pending");
21028 assert!(matches!(
21029 shared
21030 .acquire_durable_managed_spawn_v2(
21031 &request,
21032 managed_spawn_request_digest_v2(&request).unwrap(),
21033 )
21034 .unwrap(),
21035 DurableManagedSpawnReplayDecision::Reserved,
21036 ));
21037 {
21038 let _transaction = shared
21039 .state_transaction
21040 .lock()
21041 .unwrap_or_else(|poisoned| poisoned.into_inner());
21042 shared
21043 .link_durable_managed_spawn_v2_lease_locked(
21044 &request.spawn_spec.idempotency_key,
21045 &lease.lease_id,
21046 )
21047 .unwrap();
21048 shared.persist_state_locked().unwrap();
21049 }
21050 drop(shared);
21051
21052 let mut loaded = session_registry::load(
21053 Some(&state_path),
21054 &NodeId::new("node-terminal-test").unwrap(),
21055 )
21056 .unwrap();
21057 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21058 *reopened
21059 .managed_worktrees
21060 .lock()
21061 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21062 ManagedWorktreeRegistry::from_records(
21063 std::mem::take(&mut loaded.managed_worktrees),
21064 std::mem::take(&mut loaded.managed_worktree_tombstones),
21065 )
21066 .unwrap();
21067 *reopened
21068 .managed_spawn_replays
21069 .lock()
21070 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21071 .managed_spawn_replays
21072 .into_iter()
21073 .map(|record| (record.idempotency_key.clone(), record))
21074 .collect();
21075 let lease_count = reopened
21076 .managed_worktrees
21077 .lock()
21078 .unwrap_or_else(|poisoned| poisoned.into_inner())
21079 .records()
21080 .len();
21081
21082 let failure = reopened
21083 .spawn_managed_worktree_v2(request)
21084 .await
21085 .unwrap_err();
21086
21087 assert_eq!(
21088 failure.code,
21089 NodeFailureCode::ManagedWorktreeRecoveryRequired,
21090 );
21091 assert_eq!(
21092 reopened
21093 .managed_worktrees
21094 .lock()
21095 .unwrap_or_else(|poisoned| poisoned.into_inner())
21096 .records()
21097 .len(),
21098 lease_count,
21099 );
21100 std::fs::remove_dir_all(root).unwrap();
21101 }
21102
21103 #[tokio::test]
21104 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
21105 let root = temporary_workspace_root("managed-v2-reopen");
21106 let allocation = root.join("allocation");
21107 std::fs::create_dir_all(&allocation).unwrap();
21108 let state_path = root.join("node-state.json");
21109 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21110 let lease = install_managed_lease(&shared);
21111 let request = managed_v2_request("managed-v2-reopen");
21112 let session = SessionAddress {
21113 workspace_id: lease.workspace_id.clone(),
21114 session: SessionKey {
21115 instance_id: AgentInstanceId(91),
21116 generation: SessionGeneration(1),
21117 },
21118 };
21119 let snapshot = {
21120 let mut registry = shared
21121 .managed_worktrees
21122 .lock()
21123 .unwrap_or_else(|poisoned| poisoned.into_inner());
21124 let current = registry.get_mut(&lease.lease_id).unwrap();
21125 current.state = ManagedWorktreeLeaseState::InUse;
21126 current.session_holders.push(ManagedWorktreeSessionHolder {
21127 incarnation_id: shared.incarnation_id,
21128 instance_id: session.session.instance_id,
21129 generation: session.session.generation,
21130 });
21131 current.snapshot()
21132 };
21133 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
21134 let receipt = managed_spawn_receipt(
21135 &resolved,
21136 shared.incarnation_id,
21137 session,
21138 snapshot,
21139 None,
21140 None,
21141 None,
21142 );
21143 assert!(matches!(
21144 shared
21145 .acquire_durable_managed_spawn_v2(
21146 &request,
21147 managed_spawn_request_digest_v2(&request).unwrap(),
21148 )
21149 .unwrap(),
21150 DurableManagedSpawnReplayDecision::Reserved,
21151 ));
21152 {
21153 let _transaction = shared
21154 .state_transaction
21155 .lock()
21156 .unwrap_or_else(|poisoned| poisoned.into_inner());
21157 shared
21158 .link_durable_managed_spawn_v2_lease_locked(
21159 &request.spawn_spec.idempotency_key,
21160 &lease.lease_id,
21161 )
21162 .unwrap();
21163 shared.persist_state_locked().unwrap();
21164 }
21165 shared
21166 .commit_durable_managed_spawn_v2(&request, receipt.clone())
21167 .unwrap();
21168 let legacy_same_key = ManagedWorktreeSpawnRequest {
21169 spawn_spec: request.spawn_spec.clone(),
21170 worktree_profile_id: request.worktree_profile_id.clone(),
21171 };
21172 shared.remember_managed_spawn_attempt(
21173 Some(&request.spawn_spec.idempotency_key),
21174 legacy_same_key.clone(),
21175 Ok(receipt.clone()),
21176 Instant::now(),
21177 );
21178 assert!(shared
21179 .replay_managed_spawn(&legacy_same_key, Instant::now())
21180 .unwrap()
21181 .is_none());
21182 drop(shared);
21183
21184 let mut loaded = session_registry::load(
21185 Some(&state_path),
21186 &NodeId::new("node-terminal-test").unwrap(),
21187 )
21188 .unwrap();
21189 assert_eq!(loaded.managed_spawn_replays.len(), 1);
21190 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21191 *reopened
21192 .managed_worktrees
21193 .lock()
21194 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21195 ManagedWorktreeRegistry::from_records(
21196 std::mem::take(&mut loaded.managed_worktrees),
21197 std::mem::take(&mut loaded.managed_worktree_tombstones),
21198 )
21199 .unwrap();
21200 *reopened
21201 .managed_spawn_replays
21202 .lock()
21203 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21204 .managed_spawn_replays
21205 .into_iter()
21206 .map(|record| (record.idempotency_key.clone(), record))
21207 .collect();
21208 let lease_count_before = reopened
21209 .managed_worktrees
21210 .lock()
21211 .unwrap_or_else(|poisoned| poisoned.into_inner())
21212 .records()
21213 .len();
21214
21215 assert_eq!(
21216 reopened
21217 .spawn_managed_worktree_v2(request.clone())
21218 .await
21219 .unwrap(),
21220 receipt,
21221 );
21222 assert_eq!(
21223 reopened
21224 .managed_worktrees
21225 .lock()
21226 .unwrap_or_else(|poisoned| poisoned.into_inner())
21227 .records()
21228 .len(),
21229 lease_count_before,
21230 );
21231 let mut changed = request;
21232 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
21233 let failure = reopened
21234 .spawn_managed_worktree_v2(changed)
21235 .await
21236 .unwrap_err();
21237 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
21238 assert_eq!(
21239 reopened
21240 .managed_worktrees
21241 .lock()
21242 .unwrap_or_else(|poisoned| poisoned.into_inner())
21243 .records()
21244 .len(),
21245 lease_count_before,
21246 );
21247 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
21248 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
21249 let failure = reopened
21250 .spawn_managed_worktree_v2(new_key)
21251 .await
21252 .unwrap_err();
21253 assert_eq!(
21254 failure.code,
21255 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21256 );
21257 assert_eq!(
21258 reopened
21259 .managed_worktrees
21260 .lock()
21261 .unwrap_or_else(|poisoned| poisoned.into_inner())
21262 .records()
21263 .len(),
21264 lease_count_before,
21265 );
21266 assert_eq!(
21267 reopened
21268 .managed_spawn_replays
21269 .lock()
21270 .unwrap_or_else(|poisoned| poisoned.into_inner())
21271 .len(),
21272 1,
21273 );
21274 std::fs::remove_dir_all(root).unwrap();
21275 }
21276
21277 #[test]
21278 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
21279 let shared = terminal_test_shared();
21280 let request = ManagedWorktreeSpawnRequest {
21281 spawn_spec: spawn_spec_fixture(),
21282 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21283 };
21284 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
21285 shared.remember_managed_spawn(
21286 request.clone(),
21287 Err(expected.clone()),
21288 Instant::now(),
21289 );
21290
21291 assert_eq!(
21292 shared
21293 .replay_managed_spawn(&request, Instant::now())
21294 .unwrap()
21295 .unwrap(),
21296 Err(expected),
21297 );
21298 assert!(shared
21299 .managed_spawn_replays
21300 .lock()
21301 .unwrap_or_else(|poisoned| poisoned.into_inner())
21302 .is_empty());
21303 }
21304
21305 #[tokio::test]
21306 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
21307 let shared = terminal_test_shared();
21308 let lease = install_managed_lease(&shared);
21309 let raw = shared.spawn_session(
21310 lease.workspace_id.clone(),
21311 agent("claude"),
21312 SessionMode::Pty,
21313 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
21314 None,
21315 ).await.unwrap_err();
21316 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21317 let mut spec = spawn_spec_fixture();
21318 spec.target.workspace_id = lease.workspace_id.clone();
21319 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
21320 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21321 let mut selected = spawn_spec_fixture();
21322 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
21323 selected.target.worktree_id = Some(lease.workspace_id);
21324 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
21325 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21326 assert!(shared.handle.snapshot().sessions.is_empty());
21327 }
21328
21329 #[test]
21330 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
21331 let shared = terminal_test_shared();
21332 let lease = install_managed_lease(&shared);
21333 let snapshot = snapshot_for_wire(
21334 &shared, true, true, true, false, true, true, false, false, true,
21335 );
21336 assert!(snapshot.managed_worktrees.is_empty());
21337 assert!(snapshot.workspaces.iter().all(|workspace| {
21338 workspace.managed_worktree_profiles.is_none()
21339 }));
21340 let mut reply = ResponseEnvelope {
21341 request_id: 1,
21342 result: Ok(NodeResponse::Resync {
21343 event_sequence: 1,
21344 oldest_available_sequence: 1,
21345 snapshot: shared.snapshot(),
21346 events: vec![NodeEventEnvelope {
21347 sequence: 1,
21348 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
21349 }],
21350 }),
21351 };
21352 project_response_without_managed_worktrees(&mut reply);
21353 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
21354 panic!("projection changed the response kind");
21355 };
21356 assert!(snapshot.managed_worktrees.is_empty());
21357 assert!(events.is_empty());
21358 let mut git = git_snapshot_for_parser();
21359 git.branch = Some(lease.branch.clone());
21360 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
21361 lease_id: lease.lease_id.clone(),
21362 source_workspace_id: lease.source_workspace_id.clone(),
21363 branch: lease.branch.clone(),
21364 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
21365 active_session_count: 1,
21366 managed_record_count: 0,
21367 });
21368 let mut inspection_reply = ResponseEnvelope {
21369 request_id: 2,
21370 result: Ok(NodeResponse::WorkspaceInspected {
21371 inspection: WorkspaceInspection {
21372 workspace_id: lease.workspace_id.clone(),
21373 entries: Vec::new(),
21374 tree_truncated: false,
21375 git,
21376 truncation: None,
21377 },
21378 }),
21379 };
21380 project_response_without_managed_worktrees(&mut inspection_reply);
21381 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
21382 panic!("projection changed the inspection response kind");
21383 };
21384 assert!(inspection.git.managed_worktree.is_none());
21385 let failure = managed_git_worktree_failure(GitWorktreeError {
21386 kind: GitWorktreeErrorKind::Failed,
21387 message: r"git failed at C:\private\managed-target".to_owned(),
21388 });
21389 assert!(!failure.message.contains("private"));
21390 assert!(!failure.message.contains("managed-target"));
21391 }
21392
21393 #[tokio::test]
21394 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
21395 let root = temporary_workspace_root("managed-restart");
21396 let source = root.join("source");
21397 let allocation = root.join("allocation");
21398 std::fs::create_dir_all(&source).unwrap();
21399 std::fs::create_dir_all(&allocation).unwrap();
21400 run_test_git(&source, &["init"]);
21401 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21402 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21403 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21404 run_test_git(&source, &["add", "seed.txt"]);
21405 run_test_git(&source, &["commit", "-m", "seed"]);
21406 let profile = ManagedWorktreeProfile::new(
21407 WorktreeProfileId::new("default").unwrap(),
21408 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21409 &allocation,
21410 "gate4agent",
21411 "HEAD",
21412 ManagedWorktreeRetention::Retain,
21413 ).unwrap();
21414 let workspace = WorkspaceConfig::new(
21415 WorkspaceId::new("primary").unwrap(),
21416 &source,
21417 ).unwrap()
21418 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21419 .with_managed_worktree_profile(profile.clone()).unwrap();
21420 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
21421 let source_text = workspace.canonical_root().to_owned();
21422 let target_text = target.to_string_lossy().into_owned();
21423 let created = create_git_worktree(
21424 &source_text,
21425 &target_text,
21426 "gate4agent/mw-restart",
21427 Some("HEAD"),
21428 ).await.unwrap();
21429 let lease = ManagedWorktreeLeaseRecord {
21430 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
21431 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21432 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
21433 profile_id: profile.profile_id().clone(),
21434 profile_revision: profile.revision().clone(),
21435 target_root: created.path.clone(),
21436 branch: "gate4agent/mw-restart".to_owned(),
21437 base_commit: created.head.clone(),
21438 expected_head: None,
21439 retention: ManagedWorktreeRetention::Retain,
21440 state: ManagedWorktreeLeaseState::Allocating,
21441 session_holders: Vec::new(),
21442 record_holders: Vec::new(),
21443 cleanup_failure: None,
21444 created_at_unix_ms: 1,
21445 updated_at_unix_ms: 1,
21446 };
21447 let catalog = active_registry().unwrap();
21448 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21449 let shared = NodeShared::new_with_incarnation(
21450 handle,
21451 "fixture-token".to_owned(),
21452 NodeId::new("node-managed-restart").unwrap(),
21453 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
21454 vec![workspace],
21455 vec![agent("claude")],
21456 ProviderRuntimeStatuses::default(),
21457 None,
21458 Vec::new(),
21459 Vec::new(),
21460 SpawnProfileRegistry::default(),
21461 None,
21462 None,
21463 None,
21464 None,
21465 Vec::new(),
21466 vec![lease.clone()],
21467 Vec::new(),
21468 Vec::new(),
21469 None,
21470 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
21471 );
21472 shared.reconcile_managed_worktrees().await;
21473 let retained = shared.managed_worktrees.lock()
21474 .unwrap_or_else(|poisoned| poisoned.into_inner())
21475 .get(&lease.lease_id).unwrap().clone();
21476 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
21477 assert!(shared.workspaces.read()
21478 .unwrap_or_else(|poisoned| poisoned.into_inner())
21479 .contains_key(&lease.workspace_id));
21480 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
21481 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
21482 assert!(!Path::new(&created.path).exists());
21483 std::fs::remove_dir_all(root).unwrap();
21484 }
21485
21486 fn run_test_git(root: &Path, arguments: &[&str]) {
21487 let output = std::process::Command::new("git")
21488 .arg("-C").arg(root).args(arguments).output().unwrap();
21489 assert!(
21490 output.status.success(),
21491 "git {:?} failed: {}",
21492 arguments,
21493 String::from_utf8_lossy(&output.stderr),
21494 );
21495 }
21496
21497 #[tokio::test]
21498 async fn spawn_spec_validation_fails_before_session_mutation() {
21499 let shared = terminal_test_shared();
21500 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21501 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
21502 }));
21503 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21504 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
21505 }));
21506 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21507 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
21508 }));
21509
21510 let mut target_mismatch = spawn_spec_fixture();
21511 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
21512 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
21513 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
21514
21515 let mut unknown_profile = spawn_spec_fixture();
21516 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
21517 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
21518 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
21519 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
21520
21521 let mut revision_mismatch = spawn_spec_fixture();
21522 revision_mismatch.idempotency_key =
21523 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
21524 revision_mismatch.expected_profile_revision =
21525 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
21526 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
21527 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
21528
21529 let mut unsupported_materializer = spawn_spec_fixture();
21530 unsupported_materializer.idempotency_key =
21531 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
21532 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
21533 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
21534 };
21535 let failure = shared
21536 .spawn_from_spec(unsupported_materializer)
21537 .await
21538 .unwrap_err();
21539 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
21540
21541 let failure = shared
21542 .spawn_session_with_deadline(
21543 WorkspaceId::new("primary").unwrap(),
21544 agent("claude"),
21545 SessionMode::Pty,
21546 gate4agent_types::TerminalSize {
21547 rows: 24,
21548 columns: 80,
21549 },
21550 None,
21551 None,
21552 None,
21553 None,
21554 None,
21555 None,
21556 SpawnRecordPolicy::ProviderIdentityOnly,
21557 Some(Instant::now()),
21558 &[],
21559 None,
21560 ApprovalLevel::default(),
21561 None,
21562 None,
21563 )
21564 .await
21565 .unwrap_err();
21566 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
21567 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
21568 assert!(shared.handle.snapshot().sessions.is_empty());
21569 }
21570
21571 #[test]
21572 fn worktree_selection_requires_its_separate_negotiated_capability() {
21573 let mut spec = spawn_spec_fixture();
21574 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
21575 let request = NodeRequest::SpawnSpec { spec };
21576 let spawn_spec_capability =
21577 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
21578 assert!(request_uses_unnegotiated_capability(
21579 &request,
21580 &[spawn_spec_capability.clone()],
21581 ));
21582 let worktree_capability =
21583 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
21584 assert!(request_uses_unnegotiated_capability(
21585 &request,
21586 &[spawn_spec_capability.clone(), worktree_capability.clone()],
21587 ));
21588 let profile_revision_capability =
21589 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
21590 assert!(request_uses_unnegotiated_capability(
21591 &request,
21592 &[
21593 spawn_spec_capability.clone(),
21594 worktree_capability.clone(),
21595 profile_revision_capability.clone(),
21596 ],
21597 ));
21598 let bundle_materialization_capability =
21605 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
21606 assert!(request_uses_unnegotiated_capability(
21607 &request,
21608 &[
21609 spawn_spec_capability.clone(),
21610 worktree_capability.clone(),
21611 profile_revision_capability.clone(),
21612 bundle_materialization_capability.clone(),
21613 ],
21614 ));
21615 let history_context_pack_capability =
21616 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
21617 assert!(!request_uses_unnegotiated_capability(
21618 &request,
21619 &[
21620 spawn_spec_capability,
21621 worktree_capability,
21622 profile_revision_capability,
21623 bundle_materialization_capability,
21624 history_context_pack_capability,
21625 ],
21626 ));
21627 }
21628
21629 #[tokio::test]
21630 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
21631 let mut shared = terminal_test_shared();
21632 let spec = spawn_spec_fixture();
21633 let receipt = shared
21634 .resolve_spawn_spec(&spec)
21635 .unwrap()
21636 .receipt(shared.incarnation_id, terminal_address(1));
21637 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
21638
21639 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
21640 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
21641 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
21642
21643 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
21644
21645 let mut revision_conflicting = spec.clone();
21646 revision_conflicting.expected_profile_revision =
21647 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
21648 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
21649 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
21650
21651 let mut stale_cache_miss = spec.clone();
21652 stale_cache_miss.idempotency_key =
21653 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
21654 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
21655 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
21656
21657 let mut conflicting = spec;
21658 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
21659 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
21660 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
21661 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
21662 assert!(shared.handle.snapshot().sessions.is_empty());
21663 }
21664
21665 #[tokio::test]
21666 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
21667 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
21668 let mut spec = spawn_spec_fixture();
21669 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
21670 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
21671 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
21672
21673 let failure = shared
21674 .create_worktree(
21675 WorkspaceId::new("primary").unwrap(),
21676 WorkspaceId::new("new-worktree").unwrap(),
21677 "not-an-absolute-path".to_owned(),
21678 "invalid branch".to_owned(),
21679 None,
21680 )
21681 .await
21682 .unwrap_err();
21683 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
21684
21685 let failure = shared
21686 .remove_worktree(
21687 WorkspaceId::new("primary").unwrap(),
21688 "not-an-absolute-path".to_owned(),
21689 )
21690 .await
21691 .unwrap_err();
21692 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
21693 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
21694 assert!(shared.handle.snapshot().sessions.is_empty());
21695 }
21696
21697 #[tokio::test]
21698 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
21699 let root = temporary_workspace_root("manual-worktree-compensation");
21700 let source = root.join("source");
21701 let target = root.join("target");
21702 let invalid_state_parent = root.join("state-parent-is-a-file");
21703 let invalid_state_path = invalid_state_parent.join("state.json");
21704 std::fs::create_dir_all(&source).unwrap();
21705 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
21706 run_test_git(&source, &["init"]);
21707 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21708 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21709 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21710 run_test_git(&source, &["add", "seed.txt"]);
21711 run_test_git(&source, &["commit", "-m", "seed"]);
21712
21713 let catalog = active_registry().unwrap();
21714 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21715 let workspace = WorkspaceConfig::new(
21716 WorkspaceId::new("primary").unwrap(),
21717 &source,
21718 ).unwrap();
21719 let mut shared = NodeShared::new(
21720 handle,
21721 "fixture-token".to_owned(),
21722 NodeId::new("node-worktree-compensation").unwrap(),
21723 vec![workspace],
21724 vec![agent("claude")],
21725 );
21726 shared.state_path = Some(invalid_state_path);
21727
21728 let error = shared.create_worktree(
21729 WorkspaceId::new("primary").unwrap(),
21730 WorkspaceId::new("compensated").unwrap(),
21731 target.to_string_lossy().into_owned(),
21732 "gate4agent/compensated".to_owned(),
21733 Some("HEAD".to_owned()),
21734 ).await.unwrap_err();
21735
21736 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21737 assert!(!target.exists());
21738 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
21739 .iter()
21740 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
21741 std::fs::remove_dir_all(root).unwrap();
21742 }
21743
21744 #[test]
21745 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
21746 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
21747 NativeGitWorktreeSnapshot {
21748 path: path.to_owned(),
21749 head: "0123456789abcdef".to_owned(),
21750 branch: Some("refs/heads/test".to_owned()),
21751 is_bare,
21752 is_main,
21753 locked: false,
21754 lock_reason: None,
21755 prunable: false,
21756 prunable_reason: None,
21757 workspace_id: None,
21758 }
21759 }
21760
21761 let source = r"C:\repo";
21762 let selected = r"C:\repo-worktree";
21763 let listed = vec![
21764 worktree(source, true, false),
21765 worktree(selected, false, false),
21766 ];
21767 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
21768
21769 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
21770 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
21771
21772 let bare = vec![
21773 worktree(source, true, false),
21774 worktree(selected, false, true),
21775 ];
21776 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
21777 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
21778
21779 let foreign_failure =
21780 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
21781 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
21782 }
21783
21784 fn terminal_frame(sequence: u64) -> TerminalFrame {
21785 TerminalFrame {
21786 sequence,
21787 size: gate4agent_types::TerminalSize {
21788 rows: 24,
21789 columns: 80,
21790 },
21791 cursor_row: 2,
21792 cursor_column: 3,
21793 contents: format!("frame-{sequence}"),
21794 formatted: vec![1, 2, 3],
21795 scrollback_formatted: Vec::new(),
21796 alternate_screen: false,
21797 mouse_protocol_enabled: false,
21798 mouse_protocol_encoding: Default::default(),
21799 produced_at_unix_ms: 0,
21800 screen_state: gate4agent_types::PtyScreenState::default(),
21801 bracketed_paste: None,
21802 }
21803 }
21804
21805 #[test]
21806 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
21807 let shared = terminal_test_shared();
21808 let mut receiver = shared.terminal_event_tx.subscribe();
21809 let original = terminal_address(1);
21810
21811 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
21812 let first = receiver.try_recv().unwrap();
21813 assert_eq!(first.len(), 1);
21814 assert!(matches!(
21815 &first[0].event,
21816 NodeEvent::TerminalFrame { address, frame }
21817 if address == &original && frame.sequence == 7
21818 ));
21819
21820 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
21821 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
21822 assert!(matches!(
21823 receiver.try_recv(),
21824 Err(broadcast::error::TryRecvError::Empty)
21825 ));
21826
21827 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
21828 let advanced = receiver.try_recv().unwrap();
21829 assert_eq!(advanced[0].sequence, 2);
21830
21831 let resumed = terminal_address(2);
21832 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
21833 let reset = receiver.try_recv().unwrap();
21834 assert_eq!(reset[0].sequence, 3);
21835 assert!(matches!(
21836 &reset[0].event,
21837 NodeEvent::TerminalFrame { address, frame }
21838 if address == &resumed && frame.sequence == 1
21839 ));
21840 shared.clear_terminal_frame_watermark(&resumed);
21841 assert!(!shared
21842 .terminal_frame_watermarks
21843 .lock()
21844 .unwrap_or_else(|poisoned| poisoned.into_inner())
21845 .contains_key(&resumed.session.instance_id));
21846 }
21847
21848 #[test]
21849 fn terminal_frame_batches_replace_without_entering_node_event_history() {
21850 let shared = terminal_test_shared();
21851 let mut receiver = shared.terminal_event_tx.subscribe();
21852 let address = terminal_address(1);
21853 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
21854
21855 for sequence in 1..=count {
21856 shared.publish_terminal_frame_candidates(vec![(
21857 address.clone(),
21858 terminal_frame(sequence),
21859 )]);
21860 }
21861
21862 let history = shared
21863 .history
21864 .lock()
21865 .unwrap_or_else(|poisoned| poisoned.into_inner());
21866 assert!(history.events.is_empty());
21867 assert_eq!(history.last_sequence, count);
21868 assert_eq!(history.replay_floor_sequence, 1);
21869 drop(history);
21870 let NodeResponse::Resync {
21871 event_sequence,
21872 oldest_available_sequence,
21873 events,
21874 ..
21875 } = shared.resync(0) else {
21876 unreachable!("resync helper returned another response")
21877 };
21878 assert_eq!(event_sequence, count);
21879 assert_eq!(oldest_available_sequence, 1);
21880 assert!(events.is_empty());
21881 assert!(matches!(
21882 resync_required_event(&shared).event,
21883 NodeEvent::ResyncRequired {
21884 oldest_available_sequence: 1,
21885 }
21886 ));
21887 assert!(matches!(
21888 receiver.try_recv(),
21889 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
21890 ));
21891 let latest = receiver.try_recv().unwrap();
21892 assert_eq!(latest.len(), 1);
21893 assert_eq!(latest[0].sequence, count);
21894 }
21895
21896 #[test]
21897 fn real_durable_eviction_advances_authoritative_replay_floor() {
21898 let shared = terminal_test_shared();
21899 for _ in 0..=NODE_EVENT_HISTORY_MAX {
21900 shared.publish(NodeEvent::ControllerChanged { controller: None });
21901 }
21902
21903 let NodeResponse::Resync {
21904 event_sequence,
21905 oldest_available_sequence,
21906 events,
21907 ..
21908 } = shared.resync(0) else {
21909 unreachable!("resync helper returned another response")
21910 };
21911 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
21912 assert_eq!(oldest_available_sequence, 2);
21913 assert_eq!(events.first().map(|event| event.sequence), Some(2));
21914 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
21915 assert!(matches!(
21916 resync_required_event(&shared).event,
21917 NodeEvent::ResyncRequired {
21918 oldest_available_sequence: 2,
21919 }
21920 ));
21921 }
21922
21923 #[test]
21924 fn legacy_terminal_frame_connection_has_no_live_subscription() {
21925 let shared = terminal_test_shared();
21926 let without_capability = terminal_event_subscription(&shared, false);
21927 let with_capability = terminal_event_subscription(&shared, true);
21928 assert!(without_capability.is_none());
21929 assert!(with_capability.is_some());
21930 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21931 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
21932 }));
21933 }
21934
21935 #[test]
21936 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
21937 let mut pending = Vec::new();
21938 let mut event_sequence = 0_u64;
21939 for frame_sequence in 1..=4 {
21940 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
21941 event_sequence += 1;
21942 let mut address = terminal_address(1);
21943 address.session.instance_id = AgentInstanceId(instance);
21944 queue_connection_event(
21945 &mut pending,
21946 NodeEventEnvelope {
21947 sequence: event_sequence,
21948 event: NodeEvent::TerminalFrame {
21949 address,
21950 frame: terminal_frame(frame_sequence),
21951 },
21952 },
21953 0,
21954 );
21955 }
21956 }
21957 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
21958 pending.sort_unstable_by_key(|event| event.sequence);
21959
21960 let mut uninterrupted = pending.clone();
21961 let first_burst = connection_event_burst_len(uninterrupted.len());
21962 uninterrupted.drain(..first_burst);
21963 let second_burst = connection_event_burst_len(uninterrupted.len());
21964 uninterrupted.drain(..second_burst);
21965 assert!(uninterrupted.is_empty());
21966
21967 let (frame_tx, mut frame_rx) =
21968 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
21969 frame_tx
21970 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
21971 request_id: 77,
21972 request: NodeRequest::Snapshot,
21973 })))
21974 .unwrap();
21975 let burst_len = connection_event_burst_len(pending.len());
21976 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
21977 pending.drain(..burst_len);
21978 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
21979 assert!(matches!(
21980 frame_rx.try_recv(),
21981 Ok(Ok(ClientFrame::Request(RequestEnvelope {
21982 request_id: 77,
21983 request: NodeRequest::Snapshot,
21984 })))
21985 ));
21986 }
21987
21988 #[test]
21989 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
21990 let mut pending = Vec::new();
21991 for sequence in [7_u64, 8] {
21992 let mut address = terminal_address(1);
21993 address.session.instance_id = AgentInstanceId(sequence);
21994 queue_connection_event(
21995 &mut pending,
21996 NodeEventEnvelope {
21997 sequence,
21998 event: NodeEvent::TerminalFrame {
21999 address,
22000 frame: terminal_frame(sequence),
22001 },
22002 },
22003 0,
22004 );
22005 }
22006 let mut discard_through = 0;
22007 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
22008 assert!(pending.is_empty());
22009
22010 let replayed = NodeEventEnvelope {
22011 sequence: 8,
22012 event: NodeEvent::TerminalFrame {
22013 address: terminal_address(1),
22014 frame: terminal_frame(8),
22015 },
22016 };
22017 let advanced = NodeEventEnvelope {
22018 sequence: 9,
22019 event: NodeEvent::TerminalFrame {
22020 address: terminal_address(2),
22021 frame: terminal_frame(1),
22022 },
22023 };
22024 queue_connection_event_batch(
22025 &mut pending,
22026 &[replayed, advanced.clone()],
22027 discard_through,
22028 );
22029 assert_eq!(pending, vec![advanced]);
22030 }
22031
22032 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
22033 ManagedSessionRecord {
22034 record_id: SessionRecordId::new(record_id).unwrap(),
22035 display_name: format!("{provider} record"),
22036 provider: agent(provider),
22037 mode: SessionMode::Pty,
22038 state: ManagedSessionState::Unavailable,
22039 workspace_id: WorkspaceId::new("primary").unwrap(),
22040 canonical_root: opaque_windows_path(
22041 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22042 ),
22043 provider_session: None,
22044 active_session: None,
22045 environment_profile: None,
22046 bundle: None,
22047 context_id: None,
22048 context: None,
22049 exported_context: None,
22050 task_binding: None,
22051 created_at_unix_ms: 1,
22052 updated_at_unix_ms: 1,
22053 last_error: None,
22054 }
22055 }
22056
22057 fn retention_test_record(
22058 workspace_id: &str,
22059 record_id: &str,
22060 state: ManagedSessionState,
22061 updated_at_unix_ms: u64,
22062 ) -> ManagedSessionRecord {
22063 ManagedSessionRecord {
22064 record_id: SessionRecordId::new(record_id).unwrap(),
22065 display_name: format!("{record_id} display"),
22066 provider: agent("claude"),
22067 mode: SessionMode::Pty,
22068 state,
22069 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
22070 canonical_root: opaque_windows_path(
22071 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22072 ),
22073 provider_session: None,
22074 active_session: None,
22075 environment_profile: None,
22076 bundle: None,
22077 context_id: None,
22078 context: None,
22079 exported_context: None,
22080 task_binding: None,
22081 created_at_unix_ms: updated_at_unix_ms,
22082 updated_at_unix_ms,
22083 last_error: None,
22084 }
22085 }
22086
22087 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
22095 vec![
22096 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
22097 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
22098 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
22099 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
22100 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
22101 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
22102 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
22103 retention_test_record(
22104 "workspace-a",
22105 "decoy-pending",
22106 ManagedSessionState::IdentityPending,
22107 now - 500_000,
22108 ),
22109 retention_test_record(
22110 "workspace-a",
22111 "decoy-dormant",
22112 ManagedSessionState::Dormant,
22113 now - 500_000,
22114 ),
22115 ]
22116 }
22117
22118 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
22119 let root = temporary_workspace_root("session-record-retention");
22120 std::fs::create_dir_all(&root).unwrap();
22121 let catalog = active_registry().unwrap();
22122 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22123 let workspace_a =
22124 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
22125 let workspace_b =
22126 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
22127 let mut shared = NodeShared::new(
22128 handle,
22129 "fixture-token".to_owned(),
22130 NodeId::new("node-retention-test").unwrap(),
22131 vec![workspace_a, workspace_b],
22132 vec![agent("claude")],
22133 );
22134 shared.state_path = Some(root.join("node-state-v10.json"));
22135 let now = unix_time_ms();
22136 for record in retention_fixture_records(now) {
22137 shared.insert_record(record).unwrap();
22138 }
22139 shared
22140 .persist_state_locked()
22141 .expect("fixture-only, empty-history persist against a fresh temp state path");
22142 (shared, runtime, root, now)
22143 }
22144
22145 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22146 shared
22147 .history
22148 .lock()
22149 .unwrap_or_else(|poisoned| poisoned.into_inner())
22150 .events
22151 .iter()
22152 .filter_map(|envelope| match &envelope.event {
22153 NodeEvent::SessionRecordRemoved { record_id } => {
22154 Some(record_id.as_str().to_owned())
22155 }
22156 _ => None,
22157 })
22158 .collect()
22159 }
22160
22161 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22162 shared
22163 .session_records
22164 .lock()
22165 .unwrap_or_else(|poisoned| poisoned.into_inner())
22166 .records
22167 .keys()
22168 .map(|record_id| record_id.as_str().to_owned())
22169 .collect()
22170 }
22171
22172 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
22173 values.iter().map(|value| (*value).to_owned()).collect()
22174 }
22175
22176 #[tokio::test]
22177 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
22178 let (mut shared, runtime, root, _now) = retention_fixture_shared();
22179 shared.session_record_retention = SessionRecordRetentionConfig {
22180 age_ms: 0,
22181 keep_per_workspace: 1,
22182 };
22183 shared.retire_unavailable_session_records().await;
22184
22185 assert_eq!(
22186 retention_remaining_record_ids(&shared),
22187 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22188 );
22189 assert_eq!(
22190 retention_removed_record_ids(&shared),
22191 owned(&["a1", "a2", "b1", "b2"]),
22192 );
22193 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
22194
22195 drop(shared);
22196 drop(runtime);
22197 std::fs::remove_dir_all(root).unwrap();
22198 }
22199
22200 #[tokio::test]
22206 async fn session_record_retention_both_set_retires_only_the_intersection() {
22207 let (mut shared, runtime, root, _now) = retention_fixture_shared();
22208 shared.session_record_retention = SessionRecordRetentionConfig {
22209 age_ms: 250_000,
22210 keep_per_workspace: 1,
22211 };
22212 shared.retire_unavailable_session_records().await;
22213
22214 assert_eq!(
22215 retention_remaining_record_ids(&shared),
22216 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22217 );
22218 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
22219 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
22220
22221 drop(shared);
22222 drop(runtime);
22223 std::fs::remove_dir_all(root).unwrap();
22224 }
22225
22226 #[tokio::test]
22227 async fn session_record_retention_both_disabled_is_a_no_op() {
22228 let (mut shared, runtime, root, _now) = retention_fixture_shared();
22229 shared.session_record_retention = SessionRecordRetentionConfig::default();
22230 shared.retire_unavailable_session_records().await;
22231
22232 assert_eq!(
22233 retention_remaining_record_ids(&shared),
22234 owned(&[
22235 "a1", "a2", "a3", "b1", "b2", "b3",
22236 "decoy-live", "decoy-pending", "decoy-dormant",
22237 ]),
22238 );
22239 assert!(retention_removed_record_ids(&shared).is_empty());
22240 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
22241
22242 drop(shared);
22243 drop(runtime);
22244 std::fs::remove_dir_all(root).unwrap();
22245 }
22246
22247 #[test]
22248 fn session_record_context_export_selects_one_exact_history_identity() {
22249 let candidates = vec![
22250 HistoryCandidateSummary {
22251 id: "candidate-other".to_owned(),
22252 session_id_hint: "provider-other".to_owned(),
22253 modified_at_unix_ms: Some(1),
22254 },
22255 HistoryCandidateSummary {
22256 id: "candidate-exact".to_owned(),
22257 session_id_hint: "provider-exact".to_owned(),
22258 modified_at_unix_ms: Some(2),
22259 },
22260 ];
22261 assert_eq!(
22262 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
22263 "candidate-exact",
22264 );
22265 }
22266
22267 #[test]
22268 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
22269 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
22270 id: id.to_owned(),
22271 session_id_hint: hint.to_owned(),
22272 modified_at_unix_ms: None,
22273 };
22274 let missing = select_history_candidate(
22275 &[candidate("candidate-other", "provider-other")],
22276 Some("provider-exact"),
22277 )
22278 .unwrap_err();
22279 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
22280 let ambiguous = select_history_candidate(
22281 &[
22282 candidate("candidate-a", "provider-exact"),
22283 candidate("candidate-b", "provider-exact"),
22284 ],
22285 Some("provider-exact"),
22286 )
22287 .unwrap_err();
22288 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
22289 }
22290
22291 #[test]
22299 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
22300 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
22301 id: id.to_owned(),
22302 session_id_hint: hint.to_owned(),
22303 modified_at_unix_ms: None,
22304 };
22305 assert_eq!(
22306 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
22307 .unwrap(),
22308 "candidate-sole",
22309 );
22310 let missing = select_history_candidate(&[], None).unwrap_err();
22311 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
22312 let ambiguous = select_history_candidate(
22313 &[
22314 candidate("candidate-a", "sidecar-session-a"),
22315 candidate("candidate-b", "sidecar-session-b"),
22316 ],
22317 None,
22318 )
22319 .unwrap_err();
22320 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
22321 }
22322
22323 #[test]
22324 fn session_record_context_export_revalidation_rejects_record_change() {
22325 let session = terminal_address(1);
22326 let identity = ProviderSessionIdentity {
22327 key: ProviderSessionKey::SessionId,
22328 id: "provider-exact".to_owned(),
22329 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
22330 };
22331 let mut expected = record("claude", "record-context-export");
22332 expected.state = ManagedSessionState::Live;
22333 expected.provider_session = Some(identity.clone());
22334 expected.active_session = Some(session.clone());
22335 let binding = SessionBinding {
22336 workspace_id: session.workspace_id.clone(),
22337 generation: session.session.generation,
22338 runtime_policy: ProviderRuntimePolicy::raw_pty(),
22339 pending_resume: None,
22340 record_id: Some(expected.record_id.clone()),
22341 managed_worktree_lease_id: None,
22342 environment_profile: None,
22343 bundle: None,
22344 context: None,
22345 materialization_id: None,
22346 };
22347 let current_root = windows_path_text(&expected.canonical_root).to_owned();
22348 assert!(session_record_context_export_binding_is_exact(
22349 &expected,
22350 &expected,
22351 Some(&identity),
22352 &session,
22353 &binding,
22354 &expected.provider,
22355 ¤t_root,
22356 false,
22357 ));
22358 let mut changed = expected.clone();
22359 changed.updated_at_unix_ms += 1;
22360 assert!(!session_record_context_export_binding_is_exact(
22361 &expected,
22362 &changed,
22363 Some(&identity),
22364 &session,
22365 &binding,
22366 &expected.provider,
22367 ¤t_root,
22368 false,
22369 ));
22370 assert!(!session_record_context_export_binding_is_exact(
22375 &expected,
22376 &changed,
22377 Some(&identity),
22378 &session,
22379 &binding,
22380 &expected.provider,
22381 ¤t_root,
22382 true,
22383 ));
22384 let mut detached = expected.clone();
22385 detached.state = ManagedSessionState::Dormant;
22386 detached.active_session = None;
22387 detached.updated_at_unix_ms += 1;
22388 assert!(!session_record_context_export_binding_is_exact(
22389 &expected,
22390 &detached,
22391 Some(&identity),
22392 &session,
22393 &binding,
22394 &expected.provider,
22395 ¤t_root,
22396 false,
22397 ));
22398 assert!(session_record_context_export_binding_is_exact(
22399 &expected,
22400 &detached,
22401 Some(&identity),
22402 &session,
22403 &binding,
22404 &expected.provider,
22405 ¤t_root,
22406 true,
22407 ));
22408 }
22409
22410 #[test]
22417 fn session_record_context_export_binding_is_exact_without_identity() {
22418 let session = terminal_address(1);
22419 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
22420 expected.state = ManagedSessionState::Live;
22421 expected.active_session = Some(session.clone());
22422 let binding = SessionBinding {
22423 workspace_id: session.workspace_id.clone(),
22424 generation: session.session.generation,
22425 runtime_policy: ProviderRuntimePolicy::raw_pty(),
22426 pending_resume: None,
22427 record_id: Some(expected.record_id.clone()),
22428 managed_worktree_lease_id: None,
22429 environment_profile: None,
22430 bundle: None,
22431 context: None,
22432 materialization_id: None,
22433 };
22434 let current_root = windows_path_text(&expected.canonical_root).to_owned();
22435 assert!(session_record_context_export_binding_is_exact(
22436 &expected,
22437 &expected,
22438 None,
22439 &session,
22440 &binding,
22441 &expected.provider,
22442 ¤t_root,
22443 false,
22444 ));
22445 let mut drifted = expected.clone();
22449 drifted.provider_session = Some(ProviderSessionIdentity {
22450 key: ProviderSessionKey::SessionId,
22451 id: "unexpected-identity".to_owned(),
22452 transcript_path: None,
22453 });
22454 assert!(!session_record_context_export_binding_is_exact(
22455 &expected,
22456 &drifted,
22457 None,
22458 &session,
22459 &binding,
22460 &expected.provider,
22461 ¤t_root,
22462 false,
22463 ));
22464 }
22465
22466 #[test]
22467 fn session_record_context_export_preflight_requires_live_usable_source() {
22468 let mut source = record("claude", "record-context-export-preflight");
22469 source.state = ManagedSessionState::Live;
22470 assert!(session_record_context_export_source_is_usable(
22471 &source,
22472 &SessionStatus::Running,
22473 false,
22474 ));
22475 assert!(session_record_context_export_source_is_usable(
22476 &source,
22477 &SessionStatus::Exited { exit_code: Some(0) },
22478 false,
22479 ));
22480
22481 source.state = ManagedSessionState::Dormant;
22482 assert!(!session_record_context_export_source_is_usable(
22483 &source,
22484 &SessionStatus::Running,
22485 false,
22486 ));
22487 assert!(session_record_context_export_source_is_usable(
22491 &source,
22492 &SessionStatus::Exited { exit_code: Some(0) },
22493 true,
22494 ));
22495 assert!(!session_record_context_export_source_is_usable(
22496 &source,
22497 &SessionStatus::Exited { exit_code: Some(0) },
22498 false,
22499 ));
22500 source.state = ManagedSessionState::Live;
22501 assert!(!session_record_context_export_source_is_usable(
22502 &source,
22503 &SessionStatus::Starting,
22504 false,
22505 ));
22506 }
22507
22508 #[test]
22509 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
22510 let catalog = active_registry().unwrap();
22511 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22512 let workspace = WorkspaceConfig::new(
22513 WorkspaceId::new("primary").unwrap(),
22514 std::env::current_dir().unwrap(),
22515 )
22516 .unwrap();
22517 let shared = NodeShared::new(
22518 handle,
22519 "fixture-token".to_owned(),
22520 NodeId::new("node-context-export-conflict").unwrap(),
22521 vec![workspace],
22522 vec![agent("claude")],
22523 );
22524 let history = HistorySessionRecord {
22525 session_id: "provider-exact".to_owned(),
22526 title: None,
22527 cwd: None,
22528 model: None,
22529 message_count: 1,
22530 completed_turn_count: None,
22531 total_tokens: 0,
22532 messages: vec![gate4agent_types::HistoryMessageRecord {
22533 role: gate4agent_types::HistoryMessageRole::User,
22534 text: "bounded context".to_owned(),
22535 }],
22536 };
22537 let pack = NodeContextPack::export(
22538 ContextPackLineageReceipt {
22539 source_node_id: shared.node_id.clone(),
22540 source_session: terminal_address(1),
22541 source_provider: agent("claude"),
22542 },
22543 &history,
22544 )
22545 .unwrap();
22546 let context_id = pack.receipt().id.clone();
22547 let session = terminal_address(1);
22548 let identity = ProviderSessionIdentity {
22549 key: ProviderSessionKey::SessionId,
22550 id: history.session_id.clone(),
22551 transcript_path: None,
22552 };
22553 let mut expected = record("claude", "record-context-export-final-conflict");
22554 expected.state = ManagedSessionState::Live;
22555 expected.provider_session = Some(identity.clone());
22556 expected.active_session = Some(session.clone());
22557 let mut changed = expected.clone();
22558 changed.updated_at_unix_ms += 1;
22559 shared.insert_record(changed).unwrap();
22560
22561 let error = shared
22562 .commit_context_pack_for_session_record(
22563 &expected,
22564 Some(&identity),
22565 &session,
22566 "candidate-exact",
22567 &history,
22568 pack,
22569 false,
22570 )
22571 .unwrap_err();
22572
22573 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
22574 assert!(shared
22575 .context_catalog
22576 .read()
22577 .unwrap_or_else(|poisoned| poisoned.into_inner())
22578 .get(&context_id)
22579 .is_none());
22580 }
22581
22582 #[tokio::test]
22583 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
22584 let root = temporary_workspace_root("session-task-binding");
22585 std::fs::create_dir_all(&root).unwrap();
22586 let state_path = root.join("node-state.json");
22587 let catalog = active_registry().unwrap();
22588 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22589 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
22590 let node_id = NodeId::new("node-1").unwrap();
22591 let mut shared = NodeShared::new(
22592 handle,
22593 "fixture-token".to_owned(),
22594 node_id.clone(),
22595 vec![workspace],
22596 vec![agent("claude")],
22597 );
22598 shared.state_path = Some(state_path.clone());
22599 let record = record("claude", "sr-task");
22600 let record_id = record.record_id.clone();
22601 shared.insert_record(record).unwrap();
22602 shared.persist_state().unwrap();
22603 shared
22604 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
22605 .unwrap();
22606
22607 let minted = process_request_inner(
22608 &shared,
22609 77,
22610 ClientRole::Operator,
22611 NodeRequest::SetSessionTask {
22612 record_id: record_id.clone(),
22613 expected_revision: 0,
22614 target: SessionTaskTargetV1::New,
22615 },
22616 )
22617 .await
22618 .unwrap();
22619 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
22620 panic!("session task mutation returned another response");
22621 };
22622 let minted_binding = minted.task_binding.clone().unwrap();
22623 let minted_task_id = minted_binding.task_id.clone().unwrap();
22624 assert_eq!(minted_binding.revision, 1);
22625 assert!(minted_task_id.as_str().starts_with("task-"));
22626 assert_eq!(minted_task_id.as_str().len(), 29);
22627
22628 let event_count = shared.current_sequence();
22629 let repeated = process_request_inner(
22630 &shared,
22631 77,
22632 ClientRole::Operator,
22633 NodeRequest::SetSessionTask {
22634 record_id: record_id.clone(),
22635 expected_revision: 1,
22636 target: SessionTaskTargetV1::Existing {
22637 task_id: minted_task_id.clone(),
22638 },
22639 },
22640 )
22641 .await
22642 .unwrap();
22643 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
22644 assert_eq!(shared.current_sequence(), event_count);
22645
22646 let stale = process_request_inner(
22647 &shared,
22648 77,
22649 ClientRole::Operator,
22650 NodeRequest::SetSessionTask {
22651 record_id: record_id.clone(),
22652 expected_revision: 0,
22653 target: SessionTaskTargetV1::Existing {
22654 task_id: minted_task_id,
22655 },
22656 },
22657 )
22658 .await
22659 .unwrap_err();
22660 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
22661
22662 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
22663 let assigned = process_request_inner(
22664 &shared,
22665 77,
22666 ClientRole::Operator,
22667 NodeRequest::SetSessionTask {
22668 record_id: record_id.clone(),
22669 expected_revision: 1,
22670 target: SessionTaskTargetV1::Existing {
22671 task_id: assigned_id.clone(),
22672 },
22673 },
22674 )
22675 .await
22676 .unwrap();
22677 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
22678 panic!("session task assignment returned another response");
22679 };
22680 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
22681 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
22682
22683 let cleared = process_request_inner(
22684 &shared,
22685 77,
22686 ClientRole::Operator,
22687 NodeRequest::SetSessionTask {
22688 record_id: record_id.clone(),
22689 expected_revision: 2,
22690 target: SessionTaskTargetV1::Clear,
22691 },
22692 )
22693 .await
22694 .unwrap();
22695 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
22696 panic!("session task clear returned another response");
22697 };
22698 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
22699 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
22700 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
22701 assert_eq!(
22702 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
22703 cleared.updated_at_unix_ms,
22704 );
22705
22706 let persisted = std::fs::read(&state_path).unwrap();
22707 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
22708 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
22709 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
22710 std::fs::remove_dir_all(root).unwrap();
22711 }
22712
22713 #[test]
22714 fn session_task_capability_strips_snapshots_events_and_replies() {
22715 let mut bound = record("claude", "sr-task-projection");
22716 bound.updated_at_unix_ms = 2;
22717 bound.task_binding = Some(SessionTaskBindingV1 {
22718 revision: 1,
22719 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
22720 changed_at_unix_ms: 2,
22721 });
22722 let request = NodeRequest::SetSessionTask {
22723 record_id: bound.record_id.clone(),
22724 expected_revision: 1,
22725 target: SessionTaskTargetV1::Clear,
22726 };
22727 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22728 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
22729 }));
22730 assert!(!request_uses_unnegotiated_capability(
22731 &request,
22732 &baseline_capabilities().unwrap(),
22733 ));
22734 assert!(request_uses_unnegotiated_capability(&request, &[]));
22735
22736 let mut snapshot = terminal_test_shared().snapshot();
22737 snapshot.session_records.push(bound.clone());
22738 let mut reply = ResponseEnvelope {
22739 request_id: 1,
22740 result: Ok(NodeResponse::Resync {
22741 event_sequence: 1,
22742 oldest_available_sequence: 1,
22743 snapshot,
22744 events: vec![NodeEventEnvelope {
22745 sequence: 1,
22746 event: NodeEvent::SessionRecordUpserted {
22747 record: bound.clone(),
22748 },
22749 }],
22750 }),
22751 };
22752 project_response_without_session_task_binding(&mut reply);
22753 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22754 panic!("task projection changed the response kind");
22755 };
22756 assert!(snapshot.session_records[0].task_binding.is_none());
22757 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
22758 panic!("task projection changed the event kind");
22759 };
22760 assert!(record.task_binding.is_none());
22761
22762 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
22763 sequence: 2,
22764 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
22765 });
22766 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
22767 panic!("task event projection changed the event kind");
22768 };
22769 assert!(record.task_binding.is_none());
22770
22771 let mut direct = ResponseEnvelope {
22772 request_id: 2,
22773 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
22774 };
22775 project_response_without_session_task_binding(&mut direct);
22776 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
22777 panic!("task reply projection changed the response kind");
22778 };
22779 assert!(record.task_binding.is_none());
22780 }
22781
22782 #[test]
22783 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
22784 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
22785 let workspace = WorkspaceConfig::new(
22786 WorkspaceId::new("primary").unwrap(),
22787 std::env::current_dir().unwrap(),
22788 )
22789 .unwrap();
22790 let config = NodeServerConfig::new(
22791 DEFAULT_NODE_ENDPOINT,
22792 "fixture-token",
22793 NodeId::new("node-1").unwrap(),
22794 [workspace],
22795 )
22796 .unwrap();
22797 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
22798 }
22799
22800 #[cfg(all(feature = "fixture", windows))]
22801 #[test]
22802 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
22803 let root = temporary_workspace_root("provider-bundle-argv-hold");
22804 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
22805 std::fs::create_dir_all(&root).unwrap();
22806 std::fs::create_dir_all(&outside).unwrap();
22807 let proof_path = root.join("provider.proof");
22808 let release_signal = root.join("release.signal");
22809 let config = NodeServerConfig::new(
22810 format!(
22811 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
22812 std::process::id(),
22813 ),
22814 "fixture-token",
22815 NodeId::new("provider-bundle-hold-node").unwrap(),
22816 [WorkspaceConfig::new(
22817 WorkspaceId::new("primary").unwrap(),
22818 std::env::current_dir().unwrap(),
22819 )
22820 .unwrap()],
22821 )
22822 .unwrap()
22823 .with_spawn_profiles(SpawnProfileRegistry::new([
22824 crate::protocol::SpawnProfileDefaults {
22825 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
22826 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
22827 .unwrap(),
22828 provider: agent("kimi"),
22829 mode: SessionMode::Pty,
22830 terminal_size: gate4agent_types::TerminalSize {
22831 rows: 24,
22832 columns: 80,
22833 },
22834 prompt: None,
22835 bundle_id: None,
22836 context_id: None,
22837 environment_profile_id: None,
22838 },
22839 ])
22840 .unwrap());
22841 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
22842 config,
22843 agent("kimi"),
22844 proof_path.clone(),
22845 release_signal.clone(),
22846 )
22847 .unwrap();
22848 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
22849
22850 let spec = NodeServer::provider_bundle_argv_fixture_spec(
22851 agent("kimi"),
22852 proof_path.clone(),
22853 Some(release_signal.clone()),
22854 )
22855 .unwrap();
22856 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
22857 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
22858 let release_wait = wrapper
22859 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
22860 .unwrap();
22861 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
22862 assert!(proof_write < release_wait);
22863 assert!(release_wait < fixture_ready);
22864 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
22865 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
22866 assert_eq!(
22867 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
22868 &[
22869 proof_path.to_string_lossy().into_owned(),
22870 release_signal.to_string_lossy().into_owned(),
22871 ],
22872 );
22873
22874 let error = NodeServer::provider_bundle_argv_fixture_spec(
22875 agent("kimi"),
22876 proof_path,
22877 Some(outside.join("release.signal")),
22878 )
22879 .unwrap_err();
22880 assert!(matches!(
22881 error,
22882 NodeServerError::Registry(message)
22883 if message == "provider bundle argv release path must share the proof parent"
22884 ));
22885 drop(server);
22886 std::fs::remove_dir_all(root).unwrap();
22887 std::fs::remove_dir_all(outside).unwrap();
22888 }
22889
22890 #[cfg(feature = "fixture")]
22891 #[test]
22892 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
22893 let root = temporary_workspace_root("context-fixture-registry");
22894 std::fs::create_dir_all(&root).unwrap();
22895 let proof_path = root.join("context.proof");
22896 let config = NodeServerConfig::new(
22897 format!(
22898 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
22899 std::process::id(),
22900 ),
22901 "fixture-token",
22902 NodeId::new("context-fixture-node").unwrap(),
22903 [WorkspaceConfig::new(
22904 WorkspaceId::new("primary").unwrap(),
22905 std::env::current_dir().unwrap(),
22906 )
22907 .unwrap()],
22908 )
22909 .unwrap();
22910 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
22911 let mut enabled = server
22912 .shared
22913 .enabled_providers
22914 .iter()
22915 .map(AgentId::as_str)
22916 .collect::<Vec<_>>();
22917 enabled.sort_unstable();
22918 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
22919 for provider in &server.shared.enabled_providers {
22920 assert!(server
22921 .shared
22922 .provider_adapter_contracts
22923 .iter()
22924 .any(|contract| {
22925 &contract.provider == provider
22926 && contract.family == AdapterFamily::History
22927 }));
22928 }
22929 drop(server);
22930 std::fs::remove_dir_all(root).unwrap();
22931 }
22932
22933 #[cfg(feature = "fixture")]
22934 #[test]
22935 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
22936 let root = temporary_workspace_root("monitoring-context-fixture");
22937 std::fs::create_dir_all(&root).unwrap();
22938 let proof_path = root.join("context.proof");
22939 let standard = NodeServer::context_pack_fixture_catalog(
22940 proof_path.clone(),
22941 None,
22942 false,
22943 ).unwrap();
22944 let monitoring = NodeServer::context_pack_fixture_catalog(
22945 proof_path.clone(),
22946 None,
22947 true,
22948 ).unwrap();
22949 let claude = AgentId::new("claude").unwrap();
22950 let standard_claude = standard.get(&claude).unwrap();
22951 let monitoring_claude = monitoring.get(&claude).unwrap();
22952 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
22956 assert_eq!(
22957 monitoring_claude.capabilities.adapters.history,
22958 standard_claude.capabilities.adapters.history,
22959 );
22960 assert_ne!(monitoring_claude.launch, standard_claude.launch);
22961 let codex = AgentId::new("codex").unwrap();
22962 let standard_codex = standard.get(&codex).unwrap();
22963 let monitoring_codex = monitoring.get(&codex).unwrap();
22964 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
22965 assert_eq!(
22966 monitoring_codex.capabilities.adapters.history,
22967 standard_codex.capabilities.adapters.history,
22968 );
22969 assert_ne!(monitoring_codex.launch, standard_codex.launch);
22970 let codex_validation = monitoring_codex.launch.fixed_args.iter()
22971 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
22972 .unwrap();
22973 for required in [
22974 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
22975 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
22976 "@('role', 'text')",
22977 "g4a-private-provider-session-canary",
22978 "private-provider-login-identity",
22979 "$rawProof",
22980 "$env:GATE4AGENT_HOOK_URL",
22981 "g4a-private-provider-session-canary",
22982 ] {
22983 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
22984 }
22985 for provider in ["grok", "kimi"] {
22986 let provider = AgentId::new(provider).unwrap();
22987 assert_eq!(monitoring.get(&provider), standard.get(&provider));
22988 }
22989
22990 let config = NodeServerConfig::new(
22991 format!(
22992 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
22993 std::process::id(),
22994 ),
22995 "fixture-token",
22996 NodeId::new("monitoring-context-fixture-node").unwrap(),
22997 [WorkspaceConfig::new(
22998 WorkspaceId::new("primary").unwrap(),
22999 std::env::current_dir().unwrap(),
23000 )
23001 .unwrap()],
23002 )
23003 .unwrap();
23004 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
23005 assert!(server.shared.fixture_semantic_hook_policy);
23006 drop(server);
23007 std::fs::remove_dir_all(root).unwrap();
23008 }
23009
23010 #[cfg(feature = "fixture")]
23011 #[test]
23012 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
23013 let root = temporary_workspace_root("kimi-context-only-fixture");
23014 std::fs::create_dir_all(&root).unwrap();
23015 let proof_path = root.join("context.proof");
23016 let standard = NodeServer::context_pack_fixture_catalog(
23017 proof_path.clone(),
23018 None,
23019 false,
23020 ).unwrap();
23021 let kimi = AgentId::new("kimi").unwrap();
23022 let context_only = NodeServer::context_pack_fixture_catalog(
23023 proof_path,
23024 Some(&kimi),
23025 false,
23026 )
23027 .unwrap();
23028
23029 let codex = AgentId::new("codex").unwrap();
23030 assert_eq!(
23031 standard.get(&codex).unwrap().launch.fixed_args,
23032 context_only.get(&codex).unwrap().launch.fixed_args,
23033 );
23034 let script = context_only
23035 .get(&kimi)
23036 .unwrap()
23037 .launch
23038 .fixed_args
23039 .iter()
23040 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
23041 .expect("Kimi context-only validation script is missing");
23042 for required in [
23043 "$bundleArgs.Count -ne 0",
23044 "GATE4AGENT_CONTEXT_ROOT",
23045 "context-pack.json",
23046 "$entries.Count -ne 1",
23047 "g4a-context-pack-v1",
23048 "$document.PSObject.Properties['cwd']",
23049 "'claude', 'codex', 'grok', 'kimi'",
23050 "$hasUser",
23051 "$hasAssistant",
23052 "SHA256",
23053 "context-only",
23054 ] {
23055 assert!(script.contains(required), "missing validation: {required}");
23056 }
23057 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
23058 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
23059 }
23060 assert!(!standard
23061 .get(&kimi)
23062 .unwrap()
23063 .launch
23064 .fixed_args
23065 .iter()
23066 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
23067
23068 std::fs::remove_dir_all(root).unwrap();
23069 }
23070
23071 #[cfg(all(feature = "fixture", windows))]
23072 #[tokio::test]
23073 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
23074 let root = temporary_workspace_root("kimi-context-only-spawn");
23075 let workspace_root = root.join("workspace");
23076 std::fs::create_dir_all(&workspace_root).unwrap();
23077 let materialization_root = root.join("private-materializations");
23078 let proof_path = root.join("context.proof");
23079 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
23080 let workspace_id = WorkspaceId::new("primary").unwrap();
23081 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
23082 let endpoint = format!(
23083 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
23084 std::process::id(),
23085 unix_time_ms(),
23086 );
23087 let spawn_profiles = SpawnProfileRegistry::new([
23088 crate::protocol::SpawnProfileDefaults {
23089 profile_id: profile_id.clone(),
23090 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
23091 provider: agent("kimi"),
23092 mode: SessionMode::Pty,
23093 terminal_size: gate4agent_types::TerminalSize {
23094 rows: 30,
23095 columns: 120,
23096 },
23097 prompt: None,
23098 bundle_id: None,
23099 context_id: None,
23100 environment_profile_id: None,
23101 },
23102 ])
23103 .unwrap();
23104 let mut config = NodeServerConfig::new(
23105 &endpoint,
23106 "fixture-token",
23107 node_id.clone(),
23108 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
23109 )
23110 .unwrap()
23111 .with_spawn_profiles(spawn_profiles)
23112 .with_session_environment_materialization(
23113 materialization_root,
23114 Arc::new(FixtureSecretResolver {
23115 deny: Arc::new(AtomicBool::new(false)),
23116 }),
23117 )
23118 .unwrap();
23119 config.fixture_raw_pty_runtime = true;
23120 let server = NodeServer::new_context_only_proof_fixture(
23121 config,
23122 agent("kimi"),
23123 proof_path.clone(),
23124 )
23125 .unwrap();
23126 let shared = Arc::clone(&server.shared);
23127 let shutdown = server.shutdown_handle();
23128
23129 let history = HistorySessionRecord {
23130 session_id: "context-only-source-history".to_owned(),
23131 title: None,
23132 cwd: None,
23133 model: None,
23134 message_count: 2,
23135 completed_turn_count: None,
23136 total_tokens: 0,
23137 messages: vec![
23138 gate4agent_types::HistoryMessageRecord {
23139 role: gate4agent_types::HistoryMessageRole::User,
23140 text: "continue the bounded parent run through native C2".to_owned(),
23141 },
23142 gate4agent_types::HistoryMessageRecord {
23143 role: gate4agent_types::HistoryMessageRole::Assistant,
23144 text: "the exact parent context is ready for the child run".to_owned(),
23145 },
23146 ],
23147 };
23148 let pack = NodeContextPack::export(
23149 ContextPackLineageReceipt {
23150 source_node_id: node_id.clone(),
23151 source_session: SessionAddress {
23152 workspace_id: workspace_id.clone(),
23153 session: SessionKey {
23154 instance_id: AgentInstanceId(71),
23155 generation: SessionGeneration(1),
23156 },
23157 },
23158 source_provider: agent("codex"),
23159 },
23160 &history,
23161 )
23162 .unwrap();
23163 let expected_context_hash = digest(&SHA256, pack.bytes())
23164 .as_ref()
23165 .iter()
23166 .map(|byte| format!("{byte:02x}"))
23167 .collect::<String>();
23168 let context = shared
23169 .context_catalog
23170 .write()
23171 .unwrap_or_else(|poisoned| poisoned.into_inner())
23172 .insert(pack)
23173 .unwrap();
23174 let spec = SpawnSpec {
23175 target: crate::protocol::SpawnTarget {
23176 node_id,
23177 workspace_id: workspace_id.clone(),
23178 worktree_id: None,
23179 },
23180 profile_id,
23181 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
23182 "target-kimi-r1",
23183 ).unwrap(),
23184 overrides: crate::protocol::SpawnOverrides {
23185 context_id: crate::protocol::SpawnOverride::Set {
23186 value: context.id.clone(),
23187 },
23188 ..crate::protocol::SpawnOverrides::default()
23189 },
23190 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
23191 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
23192 required_capabilities: SpawnRequiredCapabilities::default(),
23193 };
23194 let resolved = match shared.resolve_spawn_spec(&spec) {
23195 Ok(resolved) => resolved,
23196 Err(error) => panic!(
23197 "Kimi context-only resolve rejected: exact_code={:?} message={}",
23198 error.code,
23199 error.message,
23200 ),
23201 };
23202 let resolved_context = match shared.resolve_context(&resolved) {
23203 Ok(Some(resolved_context)) => resolved_context,
23204 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
23205 Err(error) => panic!(
23206 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
23207 error.code,
23208 error.message,
23209 ),
23210 };
23211 assert_eq!(resolved_context, context);
23212 let runtime_policy = match shared
23213 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
23214 .await
23215 {
23216 Ok(runtime_policy) => runtime_policy,
23217 Err(error) => panic!(
23218 "Kimi RawPty admission rejected: exact_code={:?} message={}",
23219 error.code,
23220 error.message,
23221 ),
23222 };
23223 assert!(runtime_policy.raw_pty_lifecycle);
23224
23225 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
23226 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
23227 agent("kimi"),
23228 TransportKind::Pty,
23229 vec![EnvMutation {
23230 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
23231 value: Some(OsString::from("must-remain-rejected")),
23232 }],
23233 )
23234 .unwrap();
23235 assert_eq!(
23236 launch_control
23237 .install_native_launch_environment_overlay(
23238 AgentInstanceId(72),
23239 generic_overlay,
23240 )
23241 .unwrap_err(),
23242 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
23243 );
23244
23245 let diagnostic_address = SessionAddress {
23246 workspace_id: workspace_id.clone(),
23247 session: SessionKey {
23248 instance_id: AgentInstanceId(73),
23249 generation: SessionGeneration(1),
23250 },
23251 };
23252 let (diagnostic_overlay, diagnostic_materialization) = match shared
23253 .prepare_session_materialization(
23254 &diagnostic_address,
23255 &agent("kimi"),
23256 SessionMode::Pty,
23257 None,
23258 None,
23259 Some(&resolved_context),
23260 None,
23261 )
23262 {
23263 Ok(Some(prepared)) => prepared,
23264 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
23265 Err(error) => panic!(
23266 "Kimi context-only materialization rejected: exact_code={:?} message={}",
23267 error.code,
23268 error.message,
23269 ),
23270 };
23271 let diagnostic_overlay = diagnostic_overlay
23272 .expect("Kimi context-only materialization omitted its environment overlay");
23273 assert!(matches!(
23274 &diagnostic_overlay,
23275 PreparedNativeLaunchOverlay::Environment(_),
23276 ));
23277 match shared.install_prepared_launch_overlay(
23278 diagnostic_address.session.instance_id,
23279 diagnostic_overlay,
23280 ) {
23281 Ok(None) => {}
23282 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
23283 Err(error) => panic!(
23284 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
23285 error.code,
23286 error.message,
23287 ),
23288 }
23289 assert!(launch_control.clear_native_instance_launch_overlay(
23290 diagnostic_address.session.instance_id,
23291 ));
23292 drop(diagnostic_materialization);
23293 assert!(shared
23294 .materializations
23295 .lock()
23296 .unwrap_or_else(|poisoned| poisoned.into_inner())
23297 .is_empty());
23298
23299 let node_task = tokio::spawn(server.run());
23300 let ready_client = timeout(Duration::from_secs(5), async {
23301 loop {
23302 match gate4agent_node_wire::LocalNodeClient::connect(
23303 &endpoint,
23304 &shared.node_id,
23305 ClientRole::Observer,
23306 "fixture-token",
23307 )
23308 .await
23309 {
23310 Ok(client) => break client,
23311 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
23312 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
23313 }
23314 }
23315 })
23316 .await
23317 .expect("Kimi context-only Node did not become ready");
23318 assert_eq!(
23319 ready_client
23320 .hello()
23321 .snapshot
23322 .provider_runtime_statuses
23323 .iter()
23324 .find(|status| status.provider().as_str() == "kimi")
23325 .expect("Kimi runtime status is missing")
23326 .mode(),
23327 crate::protocol::ProviderRuntimeMode::RawPassthrough,
23328 );
23329 drop(ready_client);
23330
23331 let receipt = match shared.spawn_from_spec(spec).await {
23332 Ok(receipt) => receipt,
23333 Err(error) => panic!(
23334 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
23335 error.code,
23336 error.message,
23337 ),
23338 };
23339 assert_eq!(receipt.provider, agent("kimi"));
23340 assert_eq!(receipt.context.as_ref(), Some(&context));
23341 assert!(receipt.context_binding_is_valid());
23342 timeout(Duration::from_secs(5), async {
23343 while !proof_path.is_file() {
23344 sleep(Duration::from_millis(10)).await;
23345 }
23346 })
23347 .await
23348 .expect("Kimi context-only fixture did not write its proof");
23349
23350 let proof = std::fs::read_to_string(&proof_path).unwrap();
23351 let lines = proof.lines().collect::<Vec<_>>();
23352 assert_eq!(lines.len(), 7);
23353 assert_eq!(lines[0], "context-only");
23354 assert!(Path::new(lines[1]).is_absolute());
23355 assert_ne!(
23356 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
23357 std::fs::canonicalize(&workspace_root).unwrap(),
23358 );
23359 assert_eq!(
23360 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
23361 std::fs::canonicalize(&workspace_root).unwrap(),
23362 );
23363 assert_eq!(lines[3], expected_context_hash);
23364 assert_eq!(lines[4], "g4a-context-pack-v1");
23365 assert_eq!(lines[5], "codex");
23366 assert_eq!(lines[6], "2");
23367 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
23368 session.instance_id == receipt.session.session.instance_id
23369 && session.agent_id.as_str() == "kimi"
23370 }));
23371
23372 shutdown.request_shutdown().await.unwrap();
23373 timeout(Duration::from_secs(5), node_task)
23374 .await
23375 .unwrap()
23376 .unwrap()
23377 .unwrap();
23378 drop(shutdown);
23379 drop(shared);
23380 std::fs::remove_dir_all(root).unwrap();
23381 }
23382
23383 #[cfg(feature = "fixture")]
23384 #[test]
23385 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
23386 let root = temporary_workspace_root("clean-exit-constructor");
23387 std::fs::create_dir_all(&root).unwrap();
23388 let config = construction_test_config("clean-exit-constructor")
23389 .with_state_path(root.join("node-state.json"))
23390 .unwrap();
23391 let server = NodeServer::new_clean_exit_fixture(
23392 config,
23393 root.clone(),
23394 root.join("started.marker"),
23395 root.join("release.signal"),
23396 )
23397 .unwrap();
23398 assert_eq!(
23399 server
23400 .shared
23401 .enabled_providers
23402 .iter()
23403 .map(AgentId::as_str)
23404 .collect::<Vec<_>>(),
23405 ["claude"],
23406 );
23407 drop(server);
23408 std::fs::remove_dir_all(root).unwrap();
23409 }
23410
23411 #[test]
23412 fn production_registry_provider_contract_manifest_is_exact() {
23413 let registry = active_registry().unwrap();
23414 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
23415 assert_eq!(
23416 providers
23417 .iter()
23418 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
23419 .collect::<Vec<_>>(),
23420 vec![
23421 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23422 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23423 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23424 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23425 ]
23426 );
23427 assert_eq!(
23428 adapters
23429 .iter()
23430 .map(|contract| (
23431 contract.provider.as_str(),
23432 contract.family,
23433 contract.adapter_id.as_str(),
23434 contract.revision.as_str(),
23435 ))
23436 .collect::<Vec<_>>(),
23437 vec![
23438 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
23439 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
23440 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
23441 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
23442 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
23443 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
23444 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
23445 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
23446 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
23447 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
23448 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
23449 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
23450 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
23451 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
23452 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
23453 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
23454 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
23455 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
23456 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
23457 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
23458 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
23459 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
23460 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
23461 ]
23462 );
23463 }
23464
23465 fn construction_test_config(name: &str) -> NodeServerConfig {
23466 let workspace = WorkspaceConfig::new(
23467 WorkspaceId::new("primary").unwrap(),
23468 std::env::current_dir().unwrap(),
23469 )
23470 .unwrap();
23471 NodeServerConfig::new(
23472 format!(r"\\.\pipe\gate4agent-{name}"),
23473 "fixture-token",
23474 NodeId::new(name).unwrap(),
23475 [workspace],
23476 )
23477 .unwrap()
23478 }
23479
23480 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
23481 let adapter = AdapterBinding::new(
23482 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
23483 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
23484 gate4agent_types::AdapterVerification::SyntheticFixture,
23485 )
23486 .unwrap();
23487 let agent_ids = ["claude", "codex", "kimi"]
23488 .into_iter()
23489 .map(|agent_id| AgentId::new(agent_id).unwrap())
23490 .collect::<Vec<_>>();
23491 let adapters = gate4agent_catalog::AdapterRegistry::new(
23492 [
23493 AdapterFamily::PtySemantic,
23494 AdapterFamily::Pipe,
23495 AdapterFamily::OneShot,
23496 AdapterFamily::Acp,
23497 AdapterFamily::Hook,
23498 AdapterFamily::ManagedHook,
23499 AdapterFamily::History,
23500 AdapterFamily::Resume,
23501 AdapterFamily::SessionOptions,
23502 AdapterFamily::CapabilityProbe,
23503 ]
23504 .into_iter()
23505 .map(|family| gate4agent_catalog::AdapterDescriptor {
23506 family,
23507 binding: adapter.clone(),
23508 agents: agent_ids.clone(),
23509 }),
23510 )
23511 .unwrap();
23512 let specs = agent_ids
23513 .iter()
23514 .map(|agent_id| {
23515 let agent_id = agent_id.as_str();
23516 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
23517 spec.revision =
23518 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
23519 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
23520 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
23521 adapter: adapter.clone(),
23522 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
23523 launch_override: None,
23524 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
23525 });
23526 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
23527 adapter: adapter.clone(),
23528 launch_override: None,
23529 });
23530 spec.capabilities.adapters.hook = Some(adapter.clone());
23531 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
23532 spec.capabilities.adapters.one_shot = Some(adapter.clone());
23533 spec.capabilities.adapters.history = Some(adapter.clone());
23534 spec.capabilities.adapters.resume = Some(adapter.clone());
23535 spec.capabilities.adapters.session_options = Some(adapter.clone());
23536 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
23537 spec
23538 })
23539 .collect::<Vec<_>>();
23540 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
23541 }
23542
23543 #[test]
23551 fn node_server_construction_accepts_the_production_manifest() {
23552 let registry = active_registry().unwrap();
23553 let (providers, _) = provider_contract_manifest(®istry).unwrap();
23554 assert_eq!(providers.len(), registry.iter().count());
23555 assert!(!providers.is_empty());
23556 let server = NodeServer::new_with_registry(
23557 construction_test_config("manifest-production-bound"),
23558 registry,
23559 )
23560 .unwrap();
23561 drop(server);
23562 }
23563
23564 #[test]
23565 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
23566 let registry = maximum_size_provider_manifest_registry();
23567 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
23568 assert_eq!(providers.len(), 3);
23569 assert_eq!(adapters.len(), 30);
23570 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
23571 let error = match NodeServer::new_with_registry(
23572 construction_test_config("manifest-handshake-overflow"),
23573 registry,
23574 ) {
23575 Ok(_) => panic!("oversized negotiated manifest was accepted"),
23576 Err(error) => error,
23577 };
23578 assert!(matches!(
23579 error,
23580 NodeServerError::ProviderContractManifest(message)
23581 if message.contains("authentication binding length")
23582 && message.contains("8192-byte limit")
23583 ));
23584 }
23585
23586 #[test]
23587 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
23588 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
23589 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
23590 let registry = AgentRegistry::new([spec]).unwrap();
23591 assert!(matches!(
23592 provider_contract_manifest(®istry),
23593 Err(NodeServerError::ProviderContractManifest(message))
23594 if message.contains("exceeds the 128-byte limit")
23595 ));
23596 }
23597
23598 #[test]
23599 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
23600 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
23601 spec.revision = "invalid revision".to_owned();
23602 let registry = AgentRegistry::new([spec]).unwrap();
23603 assert!(matches!(
23604 provider_contract_manifest(®istry),
23605 Err(NodeServerError::ProviderContractManifest(message))
23606 if message.contains("bounded lowercase ASCII")
23607 ));
23608 }
23609
23610 #[test]
23611 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
23612 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
23613 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
23614 let conflicting_binding = builtin_registry()
23615 .get_by_id("codex")
23616 .unwrap()
23617 .capabilities
23618 .adapters
23619 .one_shot
23620 .clone()
23621 .unwrap();
23622 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
23623 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
23624 pipe.adapter = transport_binding;
23625 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
23626 let registry = AgentRegistry::new([spec]).unwrap();
23627 assert!(matches!(
23628 provider_contract_manifest(®istry),
23629 Err(NodeServerError::ProviderContractManifest(message))
23630 if message.contains("conflicting OneShot adapter bindings")
23631 ));
23632 }
23633
23634 #[test]
23635 fn public_node_failures_expose_only_fixed_categories() {
23636 let provider = failure(
23637 NodeFailureCode::BackendOperationFailed,
23638 r"provider bearer-secret failed at C:\private\session.jsonl",
23639 );
23640 assert_eq!(provider.message, "backend-operation-failed");
23641 assert!(!provider.message.contains("bearer-secret"));
23642 assert!(!provider.message.contains(r"C:\private"));
23643 let persistence = persistence_failure(io::Error::new(
23644 io::ErrorKind::Other,
23645 r"state write failed at C:\private\state-v1.json",
23646 ));
23647 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
23648 assert!(!persistence.message.contains(r"C:\private"));
23649
23650 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
23651 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
23652 assert_eq!(
23653 generic.to_string(),
23654 "node durable state failed: durable-state-load-failed",
23655 );
23656 }
23657 }
23658
23659 #[test]
23660 fn terminal_byte_sequences_are_bounded_before_dispatch() {
23661 let sequence = b"\x1b[1;5D".to_vec();
23662 assert_eq!(
23663 terminal_bytes_action(sequence.clone()).unwrap(),
23664 InputAction::TerminalBytes(sequence),
23665 );
23666 assert_eq!(
23667 terminal_bytes_action(Vec::new()).unwrap_err().code,
23668 NodeFailureCode::InvalidRequest,
23669 );
23670 assert_eq!(
23671 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
23672 .unwrap_err()
23673 .code,
23674 NodeFailureCode::InvalidRequest,
23675 );
23676 }
23677
23678 #[test]
23679 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
23680 let root = temporary_workspace_root("exclusive-state-owner");
23681 std::fs::create_dir_all(&root).unwrap();
23682 let workspace = WorkspaceConfig::new(
23683 WorkspaceId::new("primary").unwrap(),
23684 &root,
23685 )
23686 .unwrap();
23687 let state_path = root.join("state-v1.json");
23688 let endpoint = format!(
23689 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
23690 std::process::id(),
23691 std::time::SystemTime::now()
23692 .duration_since(std::time::UNIX_EPOCH)
23693 .unwrap()
23694 .as_nanos(),
23695 );
23696 let config = NodeServerConfig::new(
23697 endpoint,
23698 "fixture-token",
23699 NodeId::new("node-1").unwrap(),
23700 [workspace],
23701 )
23702 .unwrap()
23703 .with_state_path(&state_path)
23704 .unwrap();
23705
23706 let first = NodeServer::new(config.clone()).unwrap();
23707 let error = match NodeServer::new(config.clone()) {
23708 Ok(_) => panic!("a second node server acquired the same durable state path"),
23709 Err(error) => error,
23710 };
23711 assert!(matches!(
23712 &error,
23713 NodeServerError::DurableState(error)
23714 if error.kind() == io::ErrorKind::WouldBlock
23715 ));
23716 assert_eq!(
23717 error.to_string(),
23718 "node durable state failed: durable-state-lock-failed",
23719 );
23720 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
23721 drop(first);
23722 let second = NodeServer::new(config).unwrap();
23723 drop(second);
23724 std::fs::remove_dir_all(root).unwrap();
23725 }
23726
23727 #[test]
23728 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
23729 let catalog = active_registry().unwrap();
23730 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23731 let workspace_id = WorkspaceId::new("primary").unwrap();
23732 let workspace = WorkspaceConfig::new(
23733 workspace_id.clone(),
23734 std::env::current_dir().unwrap(),
23735 )
23736 .unwrap();
23737 let shared = NodeShared::new(
23738 handle,
23739 "fixture-token".to_owned(),
23740 NodeId::new("node-1").unwrap(),
23741 vec![workspace.clone()],
23742 vec![agent("claude"), agent("codex"), agent("kimi")],
23743 );
23744 let instance_id = AgentInstanceId(17);
23745 let generation = SessionGeneration(3);
23746 assert!(shared.address_for(instance_id, generation).is_none());
23747 shared.bind_session(instance_id, workspace_id.clone(), generation);
23748 let address = SessionAddress {
23749 workspace_id,
23750 session: SessionKey { instance_id, generation },
23751 };
23752 assert_eq!(
23753 shared.workspace_root(&address.workspace_id).unwrap(),
23754 workspace.canonical_root,
23755 );
23756 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
23757 shared.remove_binding(&address);
23758 assert!(shared.address_for(instance_id, generation).is_none());
23759 }
23760
23761 #[test]
23762 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
23763 let address = SessionAddress {
23764 workspace_id: WorkspaceId::new("primary").unwrap(),
23765 session: SessionKey {
23766 instance_id: AgentInstanceId(7),
23767 generation: SessionGeneration(2),
23768 },
23769 };
23770 let record_id = SessionRecordId::new("record-a").unwrap();
23771 let size = gate4agent_types::TerminalSize {
23772 rows: 24,
23773 columns: 80,
23774 };
23775 let address_requests = [
23776 NodeRequest::Resume {
23777 session: address.clone(),
23778 terminal_size: size,
23779 initial_prompt: None,
23780 },
23781 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
23782 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
23783 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
23784 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
23785 NodeRequest::TerminalControl {
23786 session: address.clone(),
23787 control: TerminalControl::Enter,
23788 },
23789 NodeRequest::Resize { session: address.clone(), size },
23790 NodeRequest::Interrupt { session: address.clone() },
23791 NodeRequest::Stop { session: address.clone(), force: false },
23792 NodeRequest::Remove { session: address },
23793 ];
23794 for request in &address_requests {
23795 assert!(request_requires_open_provider_ids_with(
23796 request,
23797 |_| Some(agent("third-party-agent")),
23798 |_| Some(agent("claude")),
23799 ));
23800 assert!(!request_requires_open_provider_ids_with(
23801 request,
23802 |_| Some(agent("claude")),
23803 |_| Some(agent("claude")),
23804 ));
23805 assert!(request_requires_open_provider_ids_with(
23806 request,
23807 |_| None,
23808 |_| Some(agent("claude")),
23809 ));
23810 }
23811
23812 let record_requests = [
23813 NodeRequest::RenameSessionRecord {
23814 record_id: record_id.clone(),
23815 display_name: "renamed".to_owned(),
23816 },
23817 NodeRequest::ResumeSessionRecord {
23818 record_id: record_id.clone(),
23819 terminal_size: size,
23820 initial_prompt: None,
23821 },
23822 NodeRequest::ForgetSessionRecord { record_id },
23823 ];
23824 for request in &record_requests {
23825 assert!(request_requires_open_provider_ids_with(
23826 request,
23827 |_| Some(agent("claude")),
23828 |_| Some(agent("grok")),
23829 ));
23830 assert!(!request_requires_open_provider_ids_with(
23831 request,
23832 |_| Some(agent("claude")),
23833 |_| Some(agent("kimi")),
23834 ));
23835 assert!(request_requires_open_provider_ids_with(
23836 request,
23837 |_| Some(agent("claude")),
23838 |_| None,
23839 ));
23840 }
23841
23842 assert!(request_requires_open_provider_ids_with(
23843 &NodeRequest::Spawn {
23844 workspace_id: WorkspaceId::new("primary").unwrap(),
23845 provider: agent("third-party-agent"),
23846 mode: SessionMode::Pty,
23847 terminal_size: size,
23848 initial_prompt: None,
23849 },
23850 |_| None,
23851 |_| None,
23852 ));
23853 assert!(!request_requires_open_provider_ids_with(
23854 &NodeRequest::Snapshot,
23855 |_| None,
23856 |_| None,
23857 ));
23858 assert!(request_requires_open_provider_ids_with(
23859 &NodeRequest::ForgetContextPack {
23860 context_id: SpawnContextId::new("ctx-forget").unwrap(),
23861 },
23862 |_| None,
23863 |_| None,
23864 ));
23865 }
23866
23867 #[test]
23868 fn legacy_projection_filters_open_record_replies_and_event_history() {
23869 let catalog = active_registry().unwrap();
23870 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23871 let workspace = WorkspaceConfig::new(
23872 WorkspaceId::new("primary").unwrap(),
23873 std::env::current_dir().unwrap(),
23874 )
23875 .unwrap();
23876 let shared = NodeShared::new(
23877 handle,
23878 "fixture-token".to_owned(),
23879 NodeId::new("node-1").unwrap(),
23880 vec![workspace],
23881 vec![agent("claude"), agent("grok")],
23882 );
23883 let legacy = record("claude", "legacy-record");
23884 let open = record("grok", "open-record");
23885 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
23886 record: legacy.clone(),
23887 });
23888 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
23889 record: open.clone(),
23890 });
23891 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
23892 record_id: legacy.record_id.clone(),
23893 });
23894 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
23895 record_id: open.record_id.clone(),
23896 });
23897 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
23898 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
23899 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
23900 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
23901
23902 let mut record_reply = ResponseEnvelope {
23903 request_id: 1,
23904 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
23905 };
23906 project_response_legacy_provider_ids(&shared, &mut record_reply);
23907 assert!(matches!(
23908 record_reply.result,
23909 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
23910 ));
23911
23912 let mut resync = ResponseEnvelope {
23913 request_id: 2,
23914 result: Ok(NodeResponse::Resync {
23915 event_sequence: open_removed.sequence,
23916 oldest_available_sequence: 1,
23917 snapshot: shared.snapshot(),
23918 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
23919 }),
23920 };
23921 project_response_legacy_provider_ids(&shared, &mut resync);
23922 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
23923 panic!("legacy projection changed the resync response kind");
23924 };
23925 assert!(snapshot.session_records.iter().all(|item| {
23926 provider_id_is_legacy(&item.provider)
23927 }));
23928 assert_eq!(events.len(), 2);
23929 assert!(events.iter().all(|event| matches!(
23930 &event.event,
23931 NodeEvent::SessionRecordUpserted { record }
23932 if provider_id_is_legacy(&record.provider)
23933 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
23934 }
23935
23936 #[test]
23937 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
23938 let catalog = active_registry().unwrap();
23939 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23940 let workspace_id = WorkspaceId::new("primary").unwrap();
23941 let workspace = WorkspaceConfig::new(
23942 workspace_id.clone(),
23943 std::env::current_dir().unwrap(),
23944 )
23945 .unwrap();
23946 let shared = NodeShared::new(
23947 handle,
23948 "fixture-token".to_owned(),
23949 NodeId::new("node-1").unwrap(),
23950 vec![workspace],
23951 vec![agent("claude")],
23952 );
23953 for index in 0..CONTROL_SESSIONS_MAX {
23954 shared.bind_session(
23955 AgentInstanceId((index + 1) as u64),
23956 workspace_id.clone(),
23957 SessionGeneration(1),
23958 );
23959 }
23960 let error = shared.ensure_binding_capacity().unwrap_err();
23961 assert_eq!(error.code, NodeFailureCode::BackendBusy);
23962 }
23963
23964 #[tokio::test]
23965 async fn provider_spawn_admission_rejects_before_session_mutation() {
23966 let catalog = active_registry().unwrap();
23967 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23968 let workspace = WorkspaceConfig::new(
23969 WorkspaceId::new("primary").unwrap(),
23970 std::env::current_dir().unwrap(),
23971 )
23972 .unwrap();
23973 let mut shared = NodeShared::new(
23974 handle,
23975 "fixture-token".to_owned(),
23976 NodeId::new("node-provider-admission").unwrap(),
23977 vec![workspace],
23978 vec![agent("claude")],
23979 );
23980 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
23981 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
23982 ])
23983 .unwrap();
23984 let terminal_size = gate4agent_types::TerminalSize {
23985 rows: 24,
23986 columns: 80,
23987 };
23988
23989 let unavailable = shared
23990 .spawn_session(
23991 WorkspaceId::new("primary").unwrap(),
23992 agent("claude"),
23993 SessionMode::Pty,
23994 terminal_size,
23995 None,
23996 )
23997 .await
23998 .unwrap_err();
23999 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
24000 assert_eq!(unavailable.message, "backend-operation-failed");
24001
24002 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24003 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
24004 agent("claude"),
24005 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
24006 ),
24007 ])
24008 .unwrap();
24009 let semantic = shared
24010 .spawn_session(
24011 WorkspaceId::new("primary").unwrap(),
24012 agent("claude"),
24013 SessionMode::Inline,
24014 terminal_size,
24015 None,
24016 )
24017 .await
24018 .unwrap_err();
24019 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
24020 assert_eq!(semantic.message, "unsupported-capability");
24021
24022 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
24023 assert!(shared
24024 .session_bindings
24025 .lock()
24026 .unwrap_or_else(|poisoned| poisoned.into_inner())
24027 .is_empty());
24028 assert!(shared
24029 .session_records
24030 .lock()
24031 .unwrap_or_else(|poisoned| poisoned.into_inner())
24032 .records
24033 .is_empty());
24034 assert!(shared.handle.snapshot().sessions.is_empty());
24035 }
24036
24037 #[tokio::test]
24038 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
24039 let catalog = active_registry().unwrap();
24040 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24041 let workspace_id = WorkspaceId::new("primary").unwrap();
24042 let workspace = WorkspaceConfig::new(
24043 workspace_id.clone(),
24044 std::env::current_dir().unwrap(),
24045 )
24046 .unwrap();
24047 let shared = NodeShared::new(
24048 handle,
24049 "fixture-token".to_owned(),
24050 NodeId::new("node-raw-spawn-record").unwrap(),
24051 vec![workspace],
24052 vec![agent("claude")],
24053 );
24054 let address = SessionAddress {
24055 workspace_id,
24056 session: SessionKey {
24057 instance_id: AgentInstanceId(71),
24058 generation: SessionGeneration::default(),
24059 },
24060 };
24061
24062 let record_id = shared
24063 .bind_spawn_session(
24064 &address,
24065 agent("claude"),
24066 SessionMode::Pty,
24067 ProviderRuntimePolicy::raw_pty(),
24068 SpawnRecordPolicy::Always,
24069 None,
24070 )
24071 .unwrap()
24072 .unwrap();
24073 shared
24074 .handle
24075 .dispatch(shared.prepare_command(ControlCommand::Register {
24076 instance_id: address.session.instance_id,
24077 agent_id: agent("claude"),
24078 transport: TransportKind::Pty,
24079 }))
24080 .unwrap();
24081 runtime.tick().await;
24082
24083 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24084 session.instance_id == address.session.instance_id
24085 && session.generation == address.session.generation
24086 }));
24087 let record = shared.record(&record_id).unwrap();
24088 assert_eq!(record.display_name, "claude #1");
24089 assert_eq!(record.state, ManagedSessionState::Live);
24090 assert_eq!(record.active_session.as_ref(), Some(&address));
24091 assert!(record.provider_session.is_none());
24092 let binding_record_id = shared
24093 .session_bindings
24094 .lock()
24095 .unwrap_or_else(|poisoned| poisoned.into_inner())
24096 .get(&address.session.instance_id)
24097 .and_then(|binding| binding.record_id.clone());
24098 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
24099
24100 let renamed = shared
24101 .rename_session_record(&record_id, "raw review".to_owned())
24102 .unwrap();
24103 assert_eq!(renamed.record_id, record_id);
24104 assert_eq!(renamed.display_name, "raw review");
24105 shared.reconcile_managed_record(
24106 &record_id,
24107 &address,
24108 &ControlEvent {
24109 sequence: 1,
24110 command_id: None,
24111 instance_id: address.session.instance_id,
24112 generation: address.session.generation,
24113 event: ControlEventKind::Running { process_id: None },
24114 },
24115 false,
24116 false,
24117 );
24118 let running = shared.record(&record_id).unwrap();
24119 assert_eq!(running.state, ManagedSessionState::Live);
24120 assert_eq!(running.active_session.as_ref(), Some(&address));
24121 assert_eq!(running.display_name, "raw review");
24122
24123 shared.reconcile_managed_record(
24124 &record_id,
24125 &address,
24126 &ControlEvent {
24127 sequence: 2,
24128 command_id: None,
24129 instance_id: address.session.instance_id,
24130 generation: address.session.generation,
24131 event: ControlEventKind::Exited {
24132 exit_code: Some(0),
24133 forced: false,
24134 },
24135 },
24136 false,
24137 false,
24138 );
24139 let detached = shared.record(&record_id).unwrap();
24140 assert_eq!(detached.state, ManagedSessionState::Unavailable);
24141 assert!(detached.active_session.is_none());
24142 assert_eq!(detached.display_name, "raw review");
24143 let renamed_detached = shared
24144 .rename_session_record(&record_id, "raw review detached".to_owned())
24145 .unwrap();
24146 assert_eq!(renamed_detached.record_id, record_id);
24147 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
24148 assert!(renamed_detached.active_session.is_none());
24149 assert_eq!(renamed_detached.display_name, "raw review detached");
24150 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
24151 }
24152
24153 #[test]
24154 fn legacy_raw_spawn_binding_remains_untracked() {
24155 let catalog = active_registry().unwrap();
24156 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24157 let workspace_id = WorkspaceId::new("primary").unwrap();
24158 let workspace = WorkspaceConfig::new(
24159 workspace_id.clone(),
24160 std::env::current_dir().unwrap(),
24161 )
24162 .unwrap();
24163 let shared = NodeShared::new(
24164 handle,
24165 "fixture-token".to_owned(),
24166 NodeId::new("node-legacy-raw-spawn").unwrap(),
24167 vec![workspace],
24168 vec![agent("claude")],
24169 );
24170 let address = SessionAddress {
24171 workspace_id,
24172 session: SessionKey {
24173 instance_id: AgentInstanceId(72),
24174 generation: SessionGeneration::default(),
24175 },
24176 };
24177
24178 let record_id = shared
24179 .bind_spawn_session(
24180 &address,
24181 agent("claude"),
24182 SessionMode::Pty,
24183 ProviderRuntimePolicy::raw_pty(),
24184 SpawnRecordPolicy::ProviderIdentityOnly,
24185 None,
24186 )
24187 .unwrap();
24188
24189 assert!(record_id.is_none());
24190 let binding_record_id = shared
24191 .session_bindings
24192 .lock()
24193 .unwrap_or_else(|poisoned| poisoned.into_inner())
24194 .get(&address.session.instance_id)
24195 .and_then(|binding| binding.record_id.clone());
24196 assert!(binding_record_id.is_none());
24197 assert!(shared.snapshot().session_records.is_empty());
24198 }
24199
24200 #[tokio::test]
24201 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
24202 let catalog = active_registry().unwrap();
24203 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24204 let workspace_id = WorkspaceId::new("primary").unwrap();
24205 let workspace = WorkspaceConfig::new(
24206 workspace_id.clone(),
24207 std::env::current_dir().unwrap(),
24208 )
24209 .unwrap();
24210 let mut shared = NodeShared::new(
24211 handle,
24212 "fixture-token".to_owned(),
24213 NodeId::new("node-raw-semantic-gate").unwrap(),
24214 vec![workspace],
24215 vec![agent("claude")],
24216 );
24217 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24218 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
24219 ])
24220 .unwrap();
24221 shared
24222 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24223 .unwrap();
24224 let address = SessionAddress {
24225 workspace_id,
24226 session: SessionKey {
24227 instance_id: AgentInstanceId(72),
24228 generation: SessionGeneration::default(),
24229 },
24230 };
24231 shared.bind_session_with_policy(
24232 &address,
24233 ProviderRuntimePolicy::raw_pty(),
24234 None,
24235 );
24236 shared
24237 .handle
24238 .dispatch(shared.prepare_command(ControlCommand::Register {
24239 instance_id: address.session.instance_id,
24240 agent_id: agent("claude"),
24241 transport: TransportKind::Pty,
24242 }))
24243 .unwrap();
24244 runtime.tick().await;
24245 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
24246 let revision_before = shared.handle.snapshot().revision;
24247 let history_sequence_before = shared.current_sequence();
24248 let terminal_size = gate4agent_types::TerminalSize {
24249 rows: 24,
24250 columns: 80,
24251 };
24252
24253 for request in [
24254 NodeRequest::Prompt {
24255 session: address.clone(),
24256 text: "prompt".to_owned(),
24257 },
24258 NodeRequest::Paste {
24259 session: address.clone(),
24260 text: "paste".to_owned(),
24261 },
24262 NodeRequest::Resume {
24268 session: address.clone(),
24269 terminal_size,
24270 initial_prompt: Some("resume".to_owned()),
24271 },
24272 ] {
24273 let error = process_request_inner(
24274 &shared,
24275 77,
24276 ClientRole::Operator,
24277 request,
24278 )
24279 .await
24280 .unwrap_err();
24281 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
24282 }
24283
24284 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
24285 assert_eq!(shared.handle.snapshot().revision, revision_before);
24286 assert_eq!(shared.current_sequence(), history_sequence_before);
24287 let bindings = shared
24288 .session_bindings
24289 .lock()
24290 .unwrap_or_else(|poisoned| poisoned.into_inner());
24291 let binding = bindings.get(&address.session.instance_id).unwrap();
24292 assert_eq!(binding.generation, address.session.generation);
24293 assert!(binding.pending_resume.is_none());
24294 assert!(binding.record_id.is_none());
24295 }
24296
24297 #[tokio::test]
24298 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
24299 let catalog = active_registry().unwrap();
24300 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24301 let primary = WorkspaceConfig::new(
24302 WorkspaceId::new("primary").unwrap(),
24303 std::env::current_dir().unwrap(),
24304 )
24305 .unwrap();
24306 let shared = NodeShared::new(
24307 handle,
24308 "fixture-token".to_owned(),
24309 NodeId::new("node-1").unwrap(),
24310 vec![primary],
24311 vec![agent("claude")],
24312 );
24313 shared
24314 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24315 .unwrap();
24316 let before = shared.snapshot();
24317 let error = process_request_inner(
24318 &shared,
24319 77,
24320 ClientRole::Operator,
24321 NodeRequest::RegisterWorkspace {
24322 workspace_id: WorkspaceId::new("foreign").unwrap(),
24323 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
24324 },
24325 )
24326 .await
24327 .unwrap_err();
24328 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
24329 assert_eq!(error.message, "invalid-request");
24330 assert_eq!(shared.snapshot().workspaces, before.workspaces);
24331 }
24332
24333 #[tokio::test]
24334 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
24335 let catalog = active_registry().unwrap();
24336 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24337 let primary_id = WorkspaceId::new("primary").unwrap();
24338 let primary = WorkspaceConfig::new(
24339 primary_id.clone(),
24340 std::env::current_dir().unwrap(),
24341 )
24342 .unwrap();
24343 let shared = NodeShared::new(
24344 handle,
24345 "fixture-token".to_owned(),
24346 NodeId::new("node-1").unwrap(),
24347 vec![primary.clone()],
24348 vec![agent("claude")],
24349 );
24350 let secondary_id = WorkspaceId::new("secondary").unwrap();
24351 let secondary = shared
24352 .register_workspace(
24353 secondary_id.clone(),
24354 std::env::temp_dir().to_string_lossy().into_owned(),
24355 )
24356 .await
24357 .unwrap();
24358 assert_eq!(secondary.workspace_id, secondary_id);
24359 assert!(secondary.sessions.is_empty());
24360 assert_eq!(shared.snapshot().workspaces.len(), 2);
24361
24362 let duplicate_id = shared
24363 .register_workspace(
24364 secondary_id.clone(),
24365 primary.canonical_root().to_owned(),
24366 )
24367 .await
24368 .unwrap_err();
24369 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
24370 let duplicate_root = shared
24371 .register_workspace(
24372 WorkspaceId::new("duplicate-root").unwrap(),
24373 windows_path_text(&secondary.canonical_root).to_owned(),
24374 )
24375 .await
24376 .unwrap_err();
24377 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
24378
24379 let address = SessionAddress {
24380 workspace_id: secondary_id.clone(),
24381 session: SessionKey {
24382 instance_id: AgentInstanceId(41),
24383 generation: SessionGeneration(1),
24384 },
24385 };
24386 shared.bind_session(
24387 address.session.instance_id,
24388 secondary_id.clone(),
24389 address.session.generation,
24390 );
24391 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
24392 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
24393 shared.remove_binding(&address);
24394 shared.unregister_workspace(&secondary_id).unwrap();
24395 assert_eq!(shared.snapshot().workspaces.len(), 1);
24396
24397 let last = shared.unregister_workspace(&primary_id).unwrap_err();
24398 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
24399 let history = shared
24400 .history
24401 .lock()
24402 .unwrap_or_else(|poisoned| poisoned.into_inner());
24403 assert!(matches!(
24404 &history.events[0],
24405 NodeEventEnvelope {
24406 sequence: 1,
24407 event: NodeEvent::WorkspaceAdded { workspace },
24408 } if workspace.workspace_id == secondary_id
24409 ));
24410 assert!(matches!(
24411 &history.events[1],
24412 NodeEventEnvelope {
24413 sequence: 2,
24414 event: NodeEvent::WorkspaceRemoved { workspace_id },
24415 } if workspace_id == &secondary_id
24416 ));
24417 }
24418
24419 #[tokio::test]
24420 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
24421 let secondary_root = temporary_workspace_root("provider-session-reference-index");
24422 std::fs::create_dir_all(&secondary_root).unwrap();
24423 let catalog = active_registry().unwrap();
24424 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24425 let primary_id = WorkspaceId::new("primary").unwrap();
24426 let secondary_id = WorkspaceId::new("secondary").unwrap();
24427 let shared = NodeShared::new(
24428 handle,
24429 "fixture-token".to_owned(),
24430 NodeId::new("node-session-reference-index").unwrap(),
24431 vec![
24432 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
24433 .unwrap(),
24434 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
24435 ],
24436 vec![agent("claude"), agent("codex")],
24437 );
24438 let identity = ProviderSessionIdentity {
24439 key: gate4agent_types::ProviderSessionKey::SessionId,
24440 id: "provider-session-42".to_owned(),
24441 transcript_path: None,
24442 };
24443 let index_request = |workspace_id: WorkspaceId,
24444 provider: AgentId,
24445 identity: ProviderSessionIdentity,
24446 display_name: &str| {
24447 NodeRequest::IndexProviderSession {
24448 workspace_id,
24449 provider,
24450 identity,
24451 display_name: display_name.to_owned(),
24452 }
24453 };
24454 let request = index_request(
24455 primary_id.clone(),
24456 agent("claude"),
24457 identity.clone(),
24458 "release shepherd",
24459 );
24460 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24461 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
24462 }));
24463 let observer = process_request_inner(
24464 &shared,
24465 77,
24466 ClientRole::Observer,
24467 request.clone(),
24468 )
24469 .await
24470 .unwrap_err();
24471 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
24472 let uncontrolled = process_request_inner(
24473 &shared,
24474 77,
24475 ClientRole::Operator,
24476 request.clone(),
24477 )
24478 .await
24479 .unwrap_err();
24480 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
24481 shared
24482 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24483 .unwrap();
24484
24485 let indexed = process_request_inner(
24486 &shared,
24487 77,
24488 ClientRole::Operator,
24489 request.clone(),
24490 )
24491 .await
24492 .unwrap();
24493 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
24494 panic!("provider session reference index returned another response");
24495 };
24496 assert_eq!(record.state, ManagedSessionState::Dormant);
24497 assert_eq!(record.mode, SessionMode::Pty);
24498 assert_eq!(record.workspace_id, primary_id);
24499 assert_eq!(record.provider_session.as_ref(), Some(&identity));
24500 assert!(record.active_session.is_none());
24501 assert!(record.environment_profile.is_none());
24502 assert!(record.bundle.is_none());
24503 assert!(record.context.is_none());
24504
24505 let repeated = process_request_inner(
24506 &shared,
24507 77,
24508 ClientRole::Operator,
24509 index_request(
24510 primary_id.clone(),
24511 agent("claude"),
24512 identity.clone(),
24513 "ignored replacement",
24514 ),
24515 )
24516 .await
24517 .unwrap();
24518 assert!(matches!(
24519 repeated,
24520 NodeResponse::ProviderSessionIndexed { record: ref existing }
24521 if existing.record_id == record.record_id
24522 && existing.display_name == "release shepherd"
24523 ));
24524 let conflicting_workspace = process_request_inner(
24525 &shared,
24526 77,
24527 ClientRole::Operator,
24528 index_request(
24529 secondary_id,
24530 agent("claude"),
24531 identity.clone(),
24532 "release shepherd",
24533 ),
24534 )
24535 .await
24536 .unwrap_err();
24537 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
24538 let conflicting_provider = process_request_inner(
24539 &shared,
24540 77,
24541 ClientRole::Operator,
24542 index_request(
24543 primary_id.clone(),
24544 agent("codex"),
24545 identity.clone(),
24546 "release shepherd",
24547 ),
24548 )
24549 .await
24550 .unwrap_err();
24551 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
24552 let transcript_rejected = process_request_inner(
24553 &shared,
24554 77,
24555 ClientRole::Operator,
24556 index_request(
24557 primary_id,
24558 agent("claude"),
24559 ProviderSessionIdentity {
24560 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
24561 ..identity
24562 },
24563 "release shepherd",
24564 ),
24565 )
24566 .await
24567 .unwrap_err();
24568 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
24569 let upserts = shared
24570 .history
24571 .lock()
24572 .unwrap_or_else(|poisoned| poisoned.into_inner())
24573 .events
24574 .iter()
24575 .filter(|event| matches!(
24576 &event.event,
24577 NodeEvent::SessionRecordUpserted { record: published }
24578 if published.record_id == record.record_id
24579 ))
24580 .count();
24581 assert_eq!(upserts, 1);
24582 std::fs::remove_dir_all(secondary_root).unwrap();
24583 }
24584
24585 #[tokio::test]
24586 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
24587 let fixture_root = temporary_workspace_root("standalone-independent");
24588 let target = fixture_root.join("independent");
24589 std::fs::create_dir_all(&fixture_root).unwrap();
24590 let catalog = active_registry().unwrap();
24591 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24592 let selected = WorkspaceConfig::new(
24593 WorkspaceId::new("primary").unwrap(),
24594 std::env::current_dir().unwrap(),
24595 )
24596 .unwrap();
24597 let selected_root = selected.canonical_root().to_owned();
24598 let shared = NodeShared::new(
24599 handle,
24600 "fixture-token".to_owned(),
24601 NodeId::new("node-standalone").unwrap(),
24602 vec![selected],
24603 vec![agent("claude")],
24604 );
24605 let workspace_id = WorkspaceId::new("independent").unwrap();
24606 let request = NodeRequest::CreateStandaloneWorkspace {
24607 workspace_id: workspace_id.clone(),
24608 root: opaque_windows_path(target.to_string_lossy().into_owned()),
24609 initial_branch: Some("main".to_owned()),
24610 };
24611 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24612 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
24613 }));
24614 assert!(!request_uses_unnegotiated_capability(
24615 &request,
24616 &baseline_capabilities().unwrap(),
24617 ));
24618 assert!(request_uses_unnegotiated_capability(&request, &[]));
24619
24620 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
24621 .await
24622 .unwrap_err();
24623 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
24624 assert!(!target.exists());
24625 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
24626 .await
24627 .unwrap_err();
24628 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
24629 assert!(!target.exists());
24630 shared
24631 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24632 .unwrap();
24633
24634 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
24635 .await
24636 .unwrap();
24637 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
24638 panic!("standalone workspace creation returned another response");
24639 };
24640 assert_eq!(workspace.workspace_id, workspace_id);
24641 assert!(target.join(".git").is_dir());
24642 let branch = run_git_bounded(
24643 target.to_str().unwrap(),
24644 &["branch", "--show-current"],
24645 GIT_OUTPUT_MAX_BYTES,
24646 )
24647 .await
24648 .unwrap();
24649 assert!(branch.success);
24650 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
24651 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
24652 assert_eq!(worktrees.len(), 1);
24653 let listed_root = WorkspaceConfig::new(
24654 WorkspaceId::new("listed-independent").unwrap(),
24655 &worktrees[0].path,
24656 )
24657 .unwrap();
24658 assert!(platform::roots_equal(
24659 listed_root.canonical_root(),
24660 windows_path_text(&workspace.canonical_root),
24661 ));
24662 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
24663 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
24664 snapshot.workspace_id == workspace_id
24665 && snapshot.canonical_root == workspace.canonical_root
24666 }));
24667 assert!(shared
24668 .history
24669 .lock()
24670 .unwrap_or_else(|poisoned| poisoned.into_inner())
24671 .events
24672 .iter()
24673 .any(|event| matches!(
24674 &event.event,
24675 NodeEvent::WorkspaceAdded { workspace }
24676 if workspace.workspace_id == workspace_id
24677 )));
24678 std::fs::remove_dir_all(fixture_root).unwrap();
24679 }
24680
24681 #[tokio::test]
24682 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
24683 let fixture_root = temporary_workspace_root("standalone-compensation");
24684 let created_target = fixture_root.join("created-target");
24685 let existing_target = fixture_root.join("existing-target");
24686 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
24687 let invalid_state_path = invalid_state_parent.join("state.json");
24688 std::fs::create_dir_all(&existing_target).unwrap();
24689 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
24690 let catalog = active_registry().unwrap();
24691 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24692 let primary = WorkspaceConfig::new(
24693 WorkspaceId::new("primary").unwrap(),
24694 std::env::current_dir().unwrap(),
24695 )
24696 .unwrap();
24697 let mut shared = NodeShared::new(
24698 handle,
24699 "fixture-token".to_owned(),
24700 NodeId::new("node-standalone-compensation").unwrap(),
24701 vec![primary],
24702 vec![agent("claude")],
24703 );
24704 shared.state_path = Some(invalid_state_path);
24705
24706 let created_recovery = shared
24707 .create_standalone_workspace(
24708 WorkspaceId::new("compensated").unwrap(),
24709 created_target.to_string_lossy().into_owned(),
24710 Some("main".to_owned()),
24711 )
24712 .await
24713 .unwrap_err();
24714 assert_eq!(
24715 created_recovery.code,
24716 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
24717 );
24718 assert!(created_target.join(".git").is_dir());
24719 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
24720 workspace.workspace_id.as_str() != "compensated"
24721 }));
24722
24723 let existing_recovery = shared
24724 .create_standalone_workspace(
24725 WorkspaceId::new("restored").unwrap(),
24726 existing_target.to_string_lossy().into_owned(),
24727 None,
24728 )
24729 .await
24730 .unwrap_err();
24731 assert_eq!(
24732 existing_recovery.code,
24733 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
24734 );
24735 assert!(existing_target.is_dir());
24736 assert!(existing_target.join(".git").is_dir());
24737 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
24738 workspace.workspace_id.as_str() != "restored"
24739 }));
24740
24741 let registered_empty = fixture_root.join("registered-empty");
24742 std::fs::create_dir(®istered_empty).unwrap();
24743 let catalog = active_registry().unwrap();
24744 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24745 let registered = WorkspaceConfig::new(
24746 WorkspaceId::new("registered-empty").unwrap(),
24747 ®istered_empty,
24748 )
24749 .unwrap();
24750 let duplicate_shared = NodeShared::new(
24751 handle,
24752 "fixture-token".to_owned(),
24753 NodeId::new("node-standalone-duplicate-root").unwrap(),
24754 vec![registered],
24755 vec![agent("claude")],
24756 );
24757 let duplicate = duplicate_shared
24758 .create_standalone_workspace(
24759 WorkspaceId::new("duplicate-root").unwrap(),
24760 registered_empty.to_string_lossy().into_owned(),
24761 None,
24762 )
24763 .await
24764 .unwrap_err();
24765 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
24766 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
24767 std::fs::remove_dir_all(fixture_root).unwrap();
24768 }
24769
24770 #[test]
24771 fn managed_record_id_collision_preserves_the_existing_record() {
24772 let catalog = active_registry().unwrap();
24773 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24774 let workspace = WorkspaceConfig::new(
24775 WorkspaceId::new("primary").unwrap(),
24776 std::env::current_dir().unwrap(),
24777 )
24778 .unwrap();
24779 let shared = NodeShared::new(
24780 handle,
24781 "fixture-token".to_owned(),
24782 NodeId::new("node-1").unwrap(),
24783 vec![workspace.clone()],
24784 vec![agent("claude")],
24785 );
24786 let record_id = SessionRecordId::new("sr-collision").unwrap();
24787 let original = ManagedSessionRecord {
24788 record_id: record_id.clone(),
24789 display_name: "original".to_owned(),
24790 provider: agent("claude"),
24791 mode: SessionMode::Pty,
24792 state: ManagedSessionState::Dormant,
24793 workspace_id: workspace.workspace_id,
24794 canonical_root: opaque_windows_path(workspace.canonical_root),
24795 provider_session: None,
24796 active_session: None,
24797 environment_profile: None,
24798 bundle: None,
24799 context_id: None,
24800 context: None,
24801 exported_context: None,
24802 task_binding: None,
24803 created_at_unix_ms: 1,
24804 updated_at_unix_ms: 1,
24805 last_error: None,
24806 };
24807 shared.insert_record(original.clone()).unwrap();
24808 let mut collision = original.clone();
24809 collision.display_name = "replacement".to_owned();
24810 let error = shared.insert_record(collision).unwrap_err();
24811 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24812 assert_eq!(shared.record(&record_id).unwrap(), original);
24813 }
24814
24815 #[test]
24816 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
24817 let catalog = active_registry().unwrap();
24818 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24819 let workspace = WorkspaceConfig::new(
24820 WorkspaceId::new("primary").unwrap(),
24821 std::env::current_dir().unwrap(),
24822 )
24823 .unwrap();
24824 let shared = NodeShared::new(
24825 handle,
24826 "fixture-token".to_owned(),
24827 NodeId::new("node-1").unwrap(),
24828 vec![workspace.clone()],
24829 vec![agent("claude")],
24830 );
24831 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
24832 shared
24833 .insert_record(ManagedSessionRecord {
24834 record_id: record_id.clone(),
24835 display_name: "runtime error".to_owned(),
24836 provider: agent("claude"),
24837 mode: SessionMode::Pty,
24838 state: ManagedSessionState::IdentityPending,
24839 workspace_id: workspace.workspace_id,
24840 canonical_root: opaque_windows_path(workspace.canonical_root),
24841 provider_session: None,
24842 active_session: None,
24843 environment_profile: None,
24844 bundle: None,
24845 context_id: None,
24846 context: None,
24847 exported_context: None,
24848 task_binding: None,
24849 created_at_unix_ms: 1,
24850 updated_at_unix_ms: 1,
24851 last_error: None,
24852 })
24853 .unwrap();
24854
24855 shared
24856 .mark_record_error(
24857 &record_id,
24858 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
24859 )
24860 .unwrap();
24861 let record = shared.record(&record_id).unwrap();
24862 assert_eq!(record.state, ManagedSessionState::Unavailable);
24863 assert_eq!(
24864 record.last_error.as_deref(),
24865 Some("provider-runtime-failed"),
24866 );
24867 let snapshot = shared.snapshot();
24868 assert_eq!(snapshot.session_records, vec![record]);
24869 }
24870
24871 #[test]
24880 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
24881 let catalog = active_registry().unwrap();
24882 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24883 let workspace = WorkspaceConfig::new(
24884 WorkspaceId::new("primary").unwrap(),
24885 std::env::current_dir().unwrap(),
24886 )
24887 .unwrap();
24888 let shared = NodeShared::new(
24889 handle,
24890 "fixture-token".to_owned(),
24891 NodeId::new("node-1").unwrap(),
24892 vec![workspace],
24893 vec![agent("claude")],
24894 );
24895
24896 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
24897
24898 shared.record_rejected_command(
24899 CommandId(1),
24900 "agent 'claude' does not support transport Acp".to_owned(),
24901 true,
24902 );
24903 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
24905
24906 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
24907 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
24908 assert!(reason.unsupported_transport);
24909
24910 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
24912
24913 shared.record_rejected_command(
24918 CommandId(3),
24919 "backend busy".to_owned(),
24920 false,
24921 );
24922 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
24923 assert!(!unclassified.unsupported_transport);
24924 }
24925
24926 #[test]
24927 fn provider_identity_update_is_ignored_without_exact_session_policy() {
24928 let catalog = active_registry().unwrap();
24929 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24930 let workspace = WorkspaceConfig::new(
24931 WorkspaceId::new("primary").unwrap(),
24932 std::env::current_dir().unwrap(),
24933 )
24934 .unwrap();
24935 let shared = NodeShared::new(
24936 handle,
24937 "fixture-token".to_owned(),
24938 NodeId::new("node-identity-policy").unwrap(),
24939 vec![workspace.clone()],
24940 vec![agent("claude")],
24941 );
24942 let address = SessionAddress {
24943 workspace_id: workspace.workspace_id.clone(),
24944 session: SessionKey {
24945 instance_id: AgentInstanceId(90),
24946 generation: SessionGeneration(1),
24947 },
24948 };
24949 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
24950 shared
24951 .insert_record(ManagedSessionRecord {
24952 record_id: record_id.clone(),
24953 display_name: "policy denied".to_owned(),
24954 provider: agent("claude"),
24955 mode: SessionMode::Pty,
24956 state: ManagedSessionState::IdentityPending,
24957 workspace_id: workspace.workspace_id,
24958 canonical_root: opaque_windows_path(workspace.canonical_root),
24959 provider_session: None,
24960 active_session: Some(address.clone()),
24961 environment_profile: None,
24962 bundle: None,
24963 context_id: None,
24964 context: None,
24965 exported_context: None,
24966 task_binding: None,
24967 created_at_unix_ms: 1,
24968 updated_at_unix_ms: 1,
24969 last_error: None,
24970 })
24971 .unwrap();
24972 let event = ControlEvent {
24973 sequence: 1,
24974 command_id: None,
24975 instance_id: address.session.instance_id,
24976 generation: address.session.generation,
24977 event: ControlEventKind::ProviderEvent {
24978 sequence: 1,
24979 source: gate4agent_types::ProviderSource {
24980 family: AdapterFamily::PtySemantic,
24981 binding: AdapterBinding::new(
24982 gate4agent_types::AdapterId::new("claude").unwrap(),
24983 "fixture/v1",
24984 gate4agent_types::AdapterVerification::SyntheticFixture,
24985 )
24986 .unwrap(),
24987 },
24988 source_sequence: 1,
24989 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
24990 identity: gate4agent_types::ProviderSessionIdentity {
24991 key: gate4agent_types::ProviderSessionKey::SessionId,
24992 id: "provider-session".to_owned(),
24993 transcript_path: None,
24994 },
24995 },
24996 },
24997 };
24998
24999 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
25000
25001 let record = shared.record(&record_id).unwrap();
25002 assert!(record.provider_session.is_none());
25003 assert_eq!(record.state, ManagedSessionState::IdentityPending);
25004 assert_eq!(record.active_session.as_ref(), Some(&address));
25005 }
25006
25007 #[test]
25008 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
25009 let secondary_root = temporary_workspace_root("identity-scope");
25010 std::fs::create_dir_all(&secondary_root).unwrap();
25011 let catalog = active_registry().unwrap();
25012 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25013 let primary = WorkspaceConfig::new(
25014 WorkspaceId::new("primary").unwrap(),
25015 std::env::current_dir().unwrap(),
25016 )
25017 .unwrap();
25018 let secondary = WorkspaceConfig::new(
25019 WorkspaceId::new("secondary").unwrap(),
25020 &secondary_root,
25021 )
25022 .unwrap();
25023 let shared = NodeShared::new(
25024 handle,
25025 "fixture-token".to_owned(),
25026 NodeId::new("node-1").unwrap(),
25027 vec![primary.clone(), secondary.clone()],
25028 vec![agent("claude")],
25029 );
25030 let identity = gate4agent_types::ProviderSessionIdentity {
25031 key: gate4agent_types::ProviderSessionKey::SessionId,
25032 id: "provider-session".to_owned(),
25033 transcript_path: None,
25034 };
25035 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
25036 shared
25037 .insert_record(ManagedSessionRecord {
25038 record_id: dormant_id.clone(),
25039 display_name: "dormant".to_owned(),
25040 provider: agent("claude"),
25041 mode: SessionMode::Pty,
25042 state: ManagedSessionState::Dormant,
25043 workspace_id: primary.workspace_id,
25044 canonical_root: opaque_windows_path(primary.canonical_root),
25045 provider_session: Some(identity.clone()),
25046 active_session: None,
25047 environment_profile: None,
25048 bundle: None,
25049 context_id: None,
25050 context: None,
25051 exported_context: None,
25052 task_binding: None,
25053 created_at_unix_ms: 1,
25054 updated_at_unix_ms: 1,
25055 last_error: None,
25056 })
25057 .unwrap();
25058 let address = SessionAddress {
25059 workspace_id: secondary.workspace_id.clone(),
25060 session: SessionKey {
25061 instance_id: AgentInstanceId(91),
25062 generation: SessionGeneration(1),
25063 },
25064 };
25065 let current_id = SessionRecordId::new("sr-current").unwrap();
25066 shared
25067 .insert_record(ManagedSessionRecord {
25068 record_id: current_id.clone(),
25069 display_name: "current".to_owned(),
25070 provider: agent("claude"),
25071 mode: SessionMode::Pty,
25072 state: ManagedSessionState::IdentityPending,
25073 workspace_id: secondary.workspace_id,
25074 canonical_root: opaque_windows_path(secondary.canonical_root),
25075 provider_session: None,
25076 active_session: Some(address.clone()),
25077 environment_profile: None,
25078 bundle: None,
25079 context_id: None,
25080 context: None,
25081 exported_context: None,
25082 task_binding: None,
25083 created_at_unix_ms: 2,
25084 updated_at_unix_ms: 2,
25085 last_error: None,
25086 })
25087 .unwrap();
25088 shared.bind_managed_session(
25089 &address,
25090 current_id.clone(),
25091 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25092 None,
25093 );
25094 let event = ControlEvent {
25095 sequence: 1,
25096 command_id: None,
25097 instance_id: address.session.instance_id,
25098 generation: address.session.generation,
25099 event: ControlEventKind::ProviderEvent {
25100 sequence: 1,
25101 source: gate4agent_types::ProviderSource {
25102 family: gate4agent_types::AdapterFamily::PtySemantic,
25103 binding: gate4agent_types::AdapterBinding::new(
25104 gate4agent_types::AdapterId::new("claude").unwrap(),
25105 "fixture/v1",
25106 gate4agent_types::AdapterVerification::SyntheticFixture,
25107 )
25108 .unwrap(),
25109 },
25110 source_sequence: 1,
25111 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25112 identity,
25113 },
25114 },
25115 };
25116 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
25117
25118 let binding = shared
25119 .session_bindings
25120 .lock()
25121 .unwrap_or_else(|poisoned| poisoned.into_inner())
25122 .get(&address.session.instance_id)
25123 .cloned()
25124 .unwrap();
25125 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
25126 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
25127 let current = shared.record(¤t_id).unwrap();
25128 assert_eq!(current.state, ManagedSessionState::Unavailable);
25129 assert_eq!(current.active_session.as_ref(), Some(&address));
25130 assert_eq!(
25131 current.last_error.as_deref(),
25132 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
25133 );
25134 std::fs::remove_dir_all(secondary_root).unwrap();
25135 }
25136
25137 #[tokio::test]
25138 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
25139 let root = temporary_workspace_root("semantic-provider-session");
25140 std::fs::create_dir_all(&root).unwrap();
25141 let state_path = root.join("state-v1.json");
25142 let catalog = active_registry().unwrap();
25143 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25144 let workspace = WorkspaceConfig::new(
25145 WorkspaceId::new("primary").unwrap(),
25146 &root,
25147 )
25148 .unwrap();
25149 let node_id = NodeId::new("node-semantic-identity").unwrap();
25150 let shared = NodeShared::new_with_incarnation(
25151 handle,
25152 "fixture-token".to_owned(),
25153 node_id.clone(),
25154 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
25155 vec![workspace.clone()],
25156 vec![agent("claude")],
25157 ProviderRuntimeStatuses::default(),
25158 None,
25159 Vec::new(),
25160 Vec::new(),
25161 SpawnProfileRegistry::default(),
25162 None,
25163 None,
25164 None,
25165 Some(state_path.clone()),
25166 Vec::new(),
25167 Vec::new(),
25168 Vec::new(),
25169 Vec::new(),
25170 None,
25171 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
25172 );
25173 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
25174 key: gate4agent_types::ProviderSessionKey::SessionId,
25175 id: "provider-session".to_owned(),
25176 transcript_path: None,
25177 };
25178 let durable_id = SessionRecordId::new("sr-durable").unwrap();
25179 shared
25180 .insert_record(ManagedSessionRecord {
25181 record_id: durable_id.clone(),
25182 display_name: "durable".to_owned(),
25183 provider: agent("claude"),
25184 mode: SessionMode::Pty,
25185 state: ManagedSessionState::Dormant,
25186 workspace_id: workspace.workspace_id.clone(),
25187 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25188 provider_session: Some(persisted_identity.clone()),
25189 active_session: None,
25190 environment_profile: None,
25191 bundle: None,
25192 context_id: None,
25193 context: None,
25194 exported_context: None,
25195 task_binding: None,
25196 created_at_unix_ms: 1,
25197 updated_at_unix_ms: 1,
25198 last_error: None,
25199 })
25200 .unwrap();
25201 let address = SessionAddress {
25202 workspace_id: workspace.workspace_id.clone(),
25203 session: SessionKey {
25204 instance_id: AgentInstanceId(92),
25205 generation: SessionGeneration(2),
25206 },
25207 };
25208 let pending_id = SessionRecordId::new("sr-pending").unwrap();
25209 shared
25210 .insert_record(ManagedSessionRecord {
25211 record_id: pending_id.clone(),
25212 display_name: "pending".to_owned(),
25213 provider: agent("claude"),
25214 mode: SessionMode::Pty,
25215 state: ManagedSessionState::IdentityPending,
25216 workspace_id: workspace.workspace_id.clone(),
25217 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25218 provider_session: None,
25219 active_session: Some(address.clone()),
25220 environment_profile: None,
25221 bundle: None,
25222 context_id: None,
25223 context: None,
25224 exported_context: None,
25225 task_binding: None,
25226 created_at_unix_ms: 2,
25227 updated_at_unix_ms: 2,
25228 last_error: None,
25229 })
25230 .unwrap();
25231 shared.bind_managed_session(
25232 &address,
25233 pending_id.clone(),
25234 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25235 None,
25236 );
25237 let observed_identity = gate4agent_types::ProviderSessionIdentity {
25238 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
25239 ..persisted_identity
25240 };
25241 let event = ControlEvent {
25242 sequence: 1,
25243 command_id: None,
25244 instance_id: address.session.instance_id,
25245 generation: address.session.generation,
25246 event: ControlEventKind::ProviderEvent {
25247 sequence: 1,
25248 source: gate4agent_types::ProviderSource {
25249 family: gate4agent_types::AdapterFamily::PtySemantic,
25250 binding: gate4agent_types::AdapterBinding::new(
25251 gate4agent_types::AdapterId::new("claude").unwrap(),
25252 "fixture/v1",
25253 gate4agent_types::AdapterVerification::SyntheticFixture,
25254 )
25255 .unwrap(),
25256 },
25257 source_sequence: 1,
25258 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25259 identity: observed_identity.clone(),
25260 },
25261 },
25262 };
25263 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
25264
25265 let snapshot = shared.snapshot();
25266 assert_eq!(snapshot.session_records.len(), 1);
25267 let live = &snapshot.session_records[0];
25268 assert_eq!(live.record_id, durable_id);
25269 assert_eq!(live.state, ManagedSessionState::Live);
25270 assert_eq!(live.active_session.as_ref(), Some(&address));
25271 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
25272 let binding = shared
25273 .session_bindings
25274 .lock()
25275 .unwrap_or_else(|poisoned| poisoned.into_inner())
25276 .get(&address.session.instance_id)
25277 .cloned()
25278 .unwrap();
25279 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
25280
25281 let mut duplicate = live.clone();
25282 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
25283 duplicate.display_name = "duplicate".to_owned();
25284 duplicate.state = ManagedSessionState::Dormant;
25285 duplicate.active_session = None;
25286 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
25287 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
25288 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
25289 assert_eq!(shared.snapshot().session_records.len(), 1);
25290
25291 let terminal_size = gate4agent_types::TerminalSize {
25292 rows: 24,
25293 columns: 80,
25294 };
25295 let (first_busy, second_busy) = tokio::join!(
25296 shared.resume_session_record(&durable_id, terminal_size, None),
25297 shared.resume_session_record(&durable_id, terminal_size, None),
25298 );
25299 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
25300 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
25301 assert_eq!(busy.message, "session-record-busy");
25302 }
25303
25304 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
25305 assert_eq!(loaded.records.len(), 1);
25306 assert_eq!(loaded.records[0].record_id, durable_id);
25307 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
25308 assert_eq!(
25309 loaded.records[0]
25310 .provider_session
25311 .as_ref()
25312 .unwrap()
25313 .transcript_path,
25314 None,
25315 );
25316 let catalog = active_registry().unwrap();
25317 let (reloaded_handle, _reloaded_runtime) =
25318 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25319 let reloaded = NodeShared::new_with_incarnation(
25320 reloaded_handle,
25321 "fixture-token".to_owned(),
25322 node_id,
25323 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
25324 vec![workspace],
25325 vec![agent("claude")],
25326 ProviderRuntimeStatuses::default(),
25327 None,
25328 Vec::new(),
25329 Vec::new(),
25330 SpawnProfileRegistry::default(),
25331 None,
25332 None,
25333 None,
25334 None,
25335 loaded.records,
25336 loaded.managed_worktrees,
25337 loaded.managed_worktree_tombstones,
25338 loaded.materializations,
25339 loaded.warning,
25340 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
25341 );
25342 assert_eq!(reloaded.snapshot().session_records.len(), 1);
25343 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
25344 std::fs::remove_dir_all(root).unwrap();
25345 }
25346
25347 #[tokio::test]
25348 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
25349 let secondary_root = temporary_workspace_root("workspace-restore");
25350 let other_root = temporary_workspace_root("workspace-conflict");
25351 std::fs::create_dir_all(&secondary_root).unwrap();
25352 std::fs::create_dir_all(&other_root).unwrap();
25353 let catalog = active_registry().unwrap();
25354 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25355 let primary = WorkspaceConfig::new(
25356 WorkspaceId::new("primary").unwrap(),
25357 std::env::current_dir().unwrap(),
25358 )
25359 .unwrap();
25360 let secondary = WorkspaceConfig::new(
25361 WorkspaceId::new("secondary").unwrap(),
25362 &secondary_root,
25363 )
25364 .unwrap();
25365 let shared = NodeShared::new(
25366 handle,
25367 "fixture-token".to_owned(),
25368 NodeId::new("node-1").unwrap(),
25369 vec![primary, secondary.clone()],
25370 vec![agent("claude")],
25371 );
25372 let record_id = SessionRecordId::new("sr-workspace").unwrap();
25373 shared
25374 .insert_record(ManagedSessionRecord {
25375 record_id: record_id.clone(),
25376 display_name: "workspace record".to_owned(),
25377 provider: agent("claude"),
25378 mode: SessionMode::Pty,
25379 state: ManagedSessionState::Dormant,
25380 workspace_id: secondary.workspace_id.clone(),
25381 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
25382 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
25383 key: gate4agent_types::ProviderSessionKey::SessionId,
25384 id: "provider-session".to_owned(),
25385 transcript_path: None,
25386 }),
25387 active_session: None,
25388 environment_profile: None,
25389 bundle: None,
25390 context_id: None,
25391 context: None,
25392 exported_context: None,
25393 task_binding: None,
25394 created_at_unix_ms: 1,
25395 updated_at_unix_ms: 1,
25396 last_error: None,
25397 })
25398 .unwrap();
25399 shared.unregister_workspace(&secondary.workspace_id).unwrap();
25400 let unavailable = shared.record(&record_id).unwrap();
25401 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
25402 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
25403
25404 let conflict = shared
25405 .register_workspace(
25406 secondary.workspace_id.clone(),
25407 other_root.to_string_lossy().into_owned(),
25408 )
25409 .await
25410 .unwrap_err();
25411 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
25412 shared
25413 .register_workspace(
25414 secondary.workspace_id,
25415 secondary_root.to_string_lossy().into_owned(),
25416 )
25417 .await
25418 .unwrap();
25419 let restored = shared.record(&record_id).unwrap();
25420 assert_eq!(restored.state, ManagedSessionState::Dormant);
25421 assert!(restored.last_error.is_none());
25422 std::fs::remove_dir_all(secondary_root).unwrap();
25423 std::fs::remove_dir_all(other_root).unwrap();
25424 }
25425
25426 #[test]
25427 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
25428 let error = validate_workspace_request_root(
25429 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
25430 )
25431 .unwrap_err();
25432 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
25433 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
25434 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
25435 }
25436
25437 #[test]
25438 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
25439 let catalog = active_registry().unwrap();
25440 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25441 let workspace = WorkspaceConfig::new(
25442 WorkspaceId::new("primary").unwrap(),
25443 std::env::current_dir().unwrap(),
25444 )
25445 .unwrap();
25446 let shared = NodeShared::new(
25447 handle,
25448 "fixture-token".to_owned(),
25449 NodeId::new("node-1").unwrap(),
25450 vec![workspace],
25451 vec![agent("claude")],
25452 );
25453 let publishers = 4_u64;
25454 let events_per_publisher = 256_u64;
25455 let start = std::sync::Barrier::new(publishers as usize + 1);
25456 let finished = AtomicU64::new(0);
25457
25458 std::thread::scope(|scope| {
25459 for _ in 0..publishers {
25460 scope.spawn(|| {
25461 start.wait();
25462 for _ in 0..events_per_publisher {
25463 shared.publish(NodeEvent::ControllerChanged { controller: None });
25464 }
25465 finished.fetch_add(1, Ordering::Release);
25466 });
25467 }
25468 start.wait();
25469 while finished.load(Ordering::Acquire) != publishers {
25470 assert_resync_history_is_linearized(shared.resync(0));
25471 std::thread::yield_now();
25472 }
25473 });
25474 let response = shared.resync(0);
25475 assert_resync_history_is_linearized(response.clone());
25476 let NodeResponse::Resync { event_sequence, events, .. } = response else {
25477 unreachable!("resync helper returned another response");
25478 };
25479 assert_eq!(event_sequence, publishers * events_per_publisher);
25480 assert_eq!(events.len() as u64, event_sequence);
25481 }
25482
25483 fn assert_resync_history_is_linearized(response: NodeResponse) {
25484 let NodeResponse::Resync { event_sequence, events, .. } = response else {
25485 unreachable!("resync helper returned another response");
25486 };
25487 assert!(events.windows(2).all(|pair| {
25488 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
25489 }));
25490 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
25491 }
25492
25493 #[test]
25494 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
25495 assert_eq!(
25496 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
25497 r"C:\repo\worktree",
25498 );
25499 assert_eq!(
25500 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
25501 r"\\server\share\repo",
25502 );
25503 assert!(validate_workspace_root(
25504 &WorkspaceId::new("network").unwrap(),
25505 r"\\server\share\repo",
25506 )
25507 .is_err());
25508 let workspace = WorkspaceConfig::new(
25509 WorkspaceId::new("primary").unwrap(),
25510 std::env::current_dir().unwrap(),
25511 )
25512 .unwrap();
25513 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
25514 }
25515
25516 #[test]
25517 fn resume_rebinds_only_on_the_expected_authorized_generation() {
25518 let catalog = active_registry().unwrap();
25519 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25520 let workspace_id = WorkspaceId::new("primary").unwrap();
25521 let workspace = WorkspaceConfig::new(
25522 workspace_id.clone(),
25523 std::env::current_dir().unwrap(),
25524 )
25525 .unwrap();
25526 let shared = NodeShared::new(
25527 handle,
25528 "fixture-token".to_owned(),
25529 NodeId::new("node-1").unwrap(),
25530 vec![workspace],
25531 vec![agent("claude")],
25532 );
25533 let instance_id = AgentInstanceId(17);
25534 let original = SessionAddress {
25535 workspace_id,
25536 session: SessionKey {
25537 instance_id,
25538 generation: SessionGeneration(3),
25539 },
25540 };
25541 shared.bind_session(
25542 instance_id,
25543 original.workspace_id.clone(),
25544 original.session.generation,
25545 );
25546 shared
25547 .arm_resume(
25548 &original,
25549 CommandId(41),
25550 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25551 )
25552 .unwrap();
25553
25554 shared.publish_control(ControlEvent {
25555 sequence: 1,
25556 command_id: None,
25557 instance_id,
25558 generation: SessionGeneration(4),
25559 event: ControlEventKind::Running { process_id: None },
25560 });
25561 assert_eq!(
25562 shared.address_for(instance_id, SessionGeneration(3)),
25563 Some(original.clone()),
25564 );
25565 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
25566
25567 shared.publish_control(ControlEvent {
25568 sequence: 2,
25569 command_id: Some(CommandId(42)),
25570 instance_id,
25571 generation: SessionGeneration(3),
25572 event: ControlEventKind::CommandRejected {
25573 message: "unrelated command".to_owned(),
25574 },
25575 });
25576
25577 shared.publish_control(ControlEvent {
25578 sequence: 3,
25579 command_id: None,
25580 instance_id,
25581 generation: SessionGeneration(4),
25582 event: ControlEventKind::ResumeAuthorized {
25583 session: gate4agent_types::ResumeSessionSummary {
25584 key: gate4agent_types::ProviderSessionKey::SessionId,
25585 id: "fixture-session".to_owned(),
25586 },
25587 },
25588 });
25589 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
25590 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
25591 assert_eq!(rebound.workspace_id, original.workspace_id);
25592 let history = shared
25593 .history
25594 .lock()
25595 .unwrap_or_else(|poisoned| poisoned.into_inner());
25596 assert!(history.events.iter().any(|envelope| matches!(
25597 &envelope.event,
25598 NodeEvent::Control { address, event }
25599 if address == &rebound
25600 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
25601 )));
25602 }
25603
25604 #[test]
25605 fn all_node_pty_prompts_use_bracketed_paste_framing() {
25606 for agent in ["claude", "codex", "kimi"] {
25607 assert_eq!(
25608 prompt_framing(&AgentId::new(agent).unwrap()),
25609 PromptFraming::BracketedPaste
25610 );
25611 }
25612 }
25613
25614 fn generous_walk_deadline() -> Instant {
25615 Instant::now() + Duration::from_secs(30)
25616 }
25617
25618 #[test]
25619 fn workspace_tree_is_relative_and_skips_heavy_directories() {
25620 let root = temporary_workspace_root("tree");
25621 std::fs::create_dir_all(root.join("src")).unwrap();
25622 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
25623 std::fs::create_dir_all(root.join("target/debug")).unwrap();
25624 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
25625 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
25626 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
25627 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
25633 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
25634 std::fs::create_dir_all(root.join("target-notes")).unwrap();
25638 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
25639
25640 let (entries, truncated, _budget) =
25641 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
25642 assert!(!truncated);
25643 assert!(entries.iter().any(|entry| {
25644 entry.relative_path.as_utf8() == Some("src")
25645 && entry.kind == WorkspaceEntryKind::Directory
25646 }));
25647 assert!(entries.iter().any(|entry| {
25648 entry.relative_path.as_utf8() == Some("src/lib.rs")
25649 && entry.kind == WorkspaceEntryKind::File
25650 }));
25651 assert!(entries.iter().any(|entry| {
25652 entry.relative_path.as_utf8() == Some("target-notes")
25653 && entry.kind == WorkspaceEntryKind::Directory
25654 }));
25655 assert!(entries.iter().any(|entry| {
25656 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
25657 && entry.kind == WorkspaceEntryKind::File
25658 }));
25659 assert!(entries.iter().all(|entry| {
25660 let path = entry.relative_path.as_utf8().unwrap();
25661 !path.starts_with(".git")
25662 && !(path == "target" || path.starts_with("target/"))
25663 && !(path == "target-a2" || path.starts_with("target-a2/"))
25664 && !path.starts_with("node_modules")
25665 }));
25666
25667 std::fs::remove_dir_all(&root).unwrap();
25668 }
25669
25670 #[test]
25671 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
25672 let root = temporary_workspace_root("tree-entry-cap");
25673 std::fs::create_dir_all(&root).unwrap();
25674 for index in 0..20 {
25675 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
25676 }
25677
25678 let (entries, truncated, budget) =
25679 collect_workspace_entries(&root, generous_walk_deadline(), 5);
25680 assert!(truncated);
25681 assert!(budget.entry_cap_exceeded);
25682 assert!(!budget.time_budget_exceeded);
25683 assert!(entries.len() <= 5);
25684
25685 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
25686 .expect("an entry-cap-truncated walk must report a truncation marker");
25687 assert!(inspection_truncation.walk_entry_cap_exceeded);
25688 assert!(!inspection_truncation.walk_time_budget_exceeded);
25689 assert!(!inspection_truncation.git_time_budget_exceeded);
25690 assert!(inspection_truncation.entries_visited >= 5);
25691
25692 std::fs::remove_dir_all(&root).unwrap();
25693 }
25694
25695 #[test]
25696 fn workspace_walk_within_budget_reports_no_truncation() {
25697 let root = temporary_workspace_root("tree-no-truncation");
25698 std::fs::create_dir_all(&root).unwrap();
25699 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
25700
25701 let (_entries, truncated, budget) =
25702 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
25703 assert!(!truncated);
25704 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
25705
25706 std::fs::remove_dir_all(&root).unwrap();
25707 }
25708
25709 #[test]
25710 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
25711 let root = temporary_workspace_root("tree-prefix-siblings");
25712 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
25713 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
25714 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
25715 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
25716
25717 let (entries, truncated, _budget) =
25718 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
25719 assert!(!truncated);
25720 let paths = entries
25721 .iter()
25722 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
25723 .collect::<Vec<_>>();
25724 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
25725 assert_eq!(
25726 paths,
25727 vec![
25728 "gate4agent-c2".to_owned(),
25729 "gate4agent-c2-client".to_owned(),
25730 "gate4agent-c2-client/lib.rs".to_owned(),
25731 "gate4agent-c2/src".to_owned(),
25732 "gate4agent-c2/src/lib.rs".to_owned(),
25733 ],
25734 );
25735
25736 std::fs::remove_dir_all(&root).unwrap();
25737 }
25738
25739 #[test]
25740 fn git_parsers_cap_status_and_commit_results() {
25741 let mut status_output = Vec::new();
25742 for index in 0..=GIT_STATUS_MAX_ENTRIES {
25743 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
25744 }
25745 let mut snapshot = git_snapshot_for_parser();
25746 parse_git_status(&status_output, &mut snapshot);
25747 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
25748 assert!(snapshot.truncated);
25749 assert_eq!(snapshot.status[0].index_status, " ");
25750 assert_eq!(snapshot.status[0].worktree_status, "M");
25751
25752 snapshot.truncated = false;
25753 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
25754 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
25755 .collect::<String>();
25756 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
25757 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
25758 assert!(snapshot.truncated);
25759 }
25760
25761 #[test]
25762 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
25763 let mut snapshot = git_snapshot_for_parser();
25764 parse_git_status(
25765 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
25766 &mut snapshot,
25767 );
25768
25769 assert!(!snapshot.truncated);
25770 assert_eq!(snapshot.status.len(), 3);
25771 assert_eq!(snapshot.status[0].index_status, "R");
25772 assert_eq!(snapshot.status[0].worktree_status, " ");
25773 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
25774 assert_eq!(
25775 snapshot.status[0]
25776 .previous_path
25777 .as_ref()
25778 .and_then(RepositoryPath::as_utf8),
25779 Some("src/previous name.rs"),
25780 );
25781 assert_eq!(snapshot.status[1].index_status, " ");
25782 assert_eq!(snapshot.status[1].worktree_status, "C");
25783 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
25784 assert_eq!(
25785 snapshot.status[1]
25786 .previous_path
25787 .as_ref()
25788 .and_then(RepositoryPath::as_utf8),
25789 Some("src/original.rs"),
25790 );
25791 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
25792 assert!(snapshot.status[2].previous_path.is_none());
25793 }
25794
25795 #[test]
25796 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
25797 let mut snapshot = git_snapshot_for_parser();
25798 parse_git_status(
25799 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
25800 &mut snapshot,
25801 );
25802
25803 assert!(snapshot.truncated);
25804 assert_eq!(snapshot.status.len(), 1);
25805 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
25806 assert!(snapshot.status[0].previous_path.is_none());
25807 }
25808
25809 #[tokio::test]
25810 async fn observer_can_browse_host_directories_without_controller() {
25811 let root = temporary_workspace_root("host-directory-observer");
25812 std::fs::create_dir_all(root.join("directory-b")).unwrap();
25813 std::fs::create_dir_all(root.join("directory-a")).unwrap();
25814 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
25815 let workspace_id = WorkspaceId::new("primary").unwrap();
25816 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
25817 let catalog = active_registry().unwrap();
25818 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25819 let shared = NodeShared::new(
25820 handle,
25821 "fixture-token".to_owned(),
25822 NodeId::new("node-1").unwrap(),
25823 vec![workspace],
25824 vec![agent("claude")],
25825 );
25826 let request = NodeRequest::BrowseHostDirectories {
25827 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
25828 after: None,
25829 };
25830 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25831 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
25832 }));
25833 assert!(!request_uses_unnegotiated_capability(
25834 &request,
25835 &baseline_capabilities().unwrap(),
25836 ));
25837 assert!(request_uses_unnegotiated_capability(&request, &[]));
25838
25839 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
25840 .await
25841 .unwrap();
25842 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
25843 panic!("host directory browse returned another response");
25844 };
25845 assert_eq!(
25846 listing
25847 .entries
25848 .iter()
25849 .map(|entry| entry.display_name.as_str())
25850 .collect::<Vec<_>>(),
25851 vec!["directory-a", "directory-b"],
25852 );
25853 assert!(listing.entries.iter().all(|entry| !entry.is_link));
25854 assert!(!listing.incomplete);
25855 std::fs::remove_dir_all(root).unwrap();
25856 }
25857
25858 #[tokio::test]
25859 async fn observer_can_inspect_only_a_registered_workspace() {
25860 let root = temporary_workspace_root("observer");
25861 std::fs::create_dir_all(root.join("src")).unwrap();
25862 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
25863 let workspace_id = WorkspaceId::new("primary").unwrap();
25864 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
25865 let catalog = active_registry().unwrap();
25866 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25867 let shared = NodeShared::new(
25868 handle,
25869 "fixture-token".to_owned(),
25870 NodeId::new("node-1").unwrap(),
25871 vec![workspace],
25872 vec![agent("claude")],
25873 );
25874
25875 let response = process_request_inner(
25876 &shared,
25877 77,
25878 ClientRole::Observer,
25879 NodeRequest::InspectWorkspace {
25880 workspace_id: workspace_id.clone(),
25881 },
25882 )
25883 .await
25884 .unwrap();
25885 let NodeResponse::WorkspaceInspected { inspection } = response else {
25886 panic!("workspace inspection returned another response");
25887 };
25888 assert_eq!(inspection.workspace_id, workspace_id);
25889 assert!(inspection.entries.iter().any(|entry| {
25890 entry.relative_path.as_utf8() == Some("src/main.rs")
25891 }));
25892
25893 let unknown = process_request_inner(
25894 &shared,
25895 77,
25896 ClientRole::Observer,
25897 NodeRequest::InspectWorkspace {
25898 workspace_id: WorkspaceId::new("unknown").unwrap(),
25899 },
25900 )
25901 .await
25902 .unwrap_err();
25903 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
25904 std::fs::remove_dir_all(&root).unwrap();
25905 }
25906
25907 #[test]
25908 fn context_pack_source_accepts_only_running_or_successful_exit() {
25909 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
25910 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
25911 exit_code: Some(0),
25912 }));
25913 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
25914 exit_code: Some(1),
25915 }));
25916 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
25917 exit_code: None,
25918 }));
25919 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
25920 message: "provider failed".to_owned(),
25921 }));
25922 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
25923 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
25924 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
25925 }
25926
25927 #[test]
25928 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
25929 let git = git_snapshot_for_parser();
25930 let first = ContextPackRepositoryObservation {
25931 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
25932 git: git.clone(),
25933 files: Vec::new(),
25934 };
25935 let second = ContextPackRepositoryObservation {
25936 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
25937 git,
25938 files: Vec::new(),
25939 };
25940
25941 let error = stable_context_pack_repository(first, second).unwrap_err();
25942
25943 assert_eq!(
25944 error.code,
25945 NodeFailureCode::ContextPackMaterializationFailed,
25946 );
25947 }
25948
25949 #[test]
25950 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
25951 let git = git_snapshot_for_parser();
25952 let first = ContextPackRepositoryObservation {
25953 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
25954 git: git.clone(),
25955 files: vec![ContextPackRepositoryFileSource::utf8(
25956 "README.md",
25957 "first\n".to_owned(),
25958 6,
25959 )],
25960 };
25961 let second = ContextPackRepositoryObservation {
25962 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
25963 git,
25964 files: vec![ContextPackRepositoryFileSource::utf8(
25965 "README.md",
25966 "other\n".to_owned(),
25967 6,
25968 )],
25969 };
25970
25971 let error = stable_context_pack_repository(first, second).unwrap_err();
25972
25973 assert_eq!(
25974 error.code,
25975 NodeFailureCode::ContextPackMaterializationFailed,
25976 );
25977 }
25978
25979 #[tokio::test]
25980 async fn native_session_catalog_zero_active_session_fixture() {
25981 let workspace_root = temporary_workspace_root("native-catalog-workspace");
25982 let history_root = temporary_workspace_root("native-catalog-history");
25983 let outside_root = temporary_workspace_root("native-catalog-outside");
25984 std::fs::create_dir_all(&workspace_root).unwrap();
25985 std::fs::create_dir_all(&history_root).unwrap();
25986 std::fs::create_dir_all(&outside_root).unwrap();
25987 let transcript = |session_id: &str, cwd: &Path, title: &str| {
25988 [
25989 serde_json::json!({
25990 "type": "user",
25991 "sessionId": session_id,
25992 "cwd": cwd.to_str().unwrap(),
25993 "message": { "content": "raw user text must not cross the catalog wire" }
25994 }),
25995 serde_json::json!({
25996 "type": "assistant",
25997 "sessionId": session_id,
25998 "cwd": cwd.to_str().unwrap(),
25999 "message": { "model": "claude-sonnet", "content": "raw answer" }
26000 }),
26001 serde_json::json!({
26002 "type": "ai-title",
26003 "sessionId": session_id,
26004 "aiTitle": title
26005 }),
26006 ]
26007 .into_iter()
26008 .map(|record| record.to_string())
26009 .collect::<Vec<_>>()
26010 .join("\n")
26011 };
26012 std::fs::write(
26013 history_root.join("stable-session.jsonl"),
26014 transcript("stable-session", &workspace_root, "Workspace review"),
26015 )
26016 .unwrap();
26017 std::fs::write(
26018 history_root.join("outside-session.jsonl"),
26019 transcript("outside-session", &outside_root, "Outside review"),
26020 )
26021 .unwrap();
26022 let history = NativeHistoryConfig::new(vec![
26023 NativeHistoryRoot::new(
26024 gate4agent_types::AdapterId::new("claude-code").unwrap(),
26025 HistorySourceLayout::SingleNdjson,
26026 history_root.clone(),
26027 )
26028 .unwrap(),
26029 ])
26030 .unwrap();
26031 let catalog = active_registry().unwrap();
26032 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26033 assert!(handle.snapshot().sessions.is_empty());
26034 let workspace_id = WorkspaceId::new("primary").unwrap();
26035 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
26036 let mut shared = NodeShared::new(
26037 handle,
26038 "fixture-token".to_owned(),
26039 NodeId::new("node-native-catalog").unwrap(),
26040 vec![workspace],
26041 vec![agent("claude")],
26042 );
26043 shared.native_session_catalog = Some(Arc::new(Mutex::new(
26044 NativeSessionCatalogAuthority::new(history),
26045 )));
26046 let route = NativeSessionCatalogRoute::workspace(
26047 workspace_id.clone(),
26048 agent("claude"),
26049 );
26050 let request = NodeRequest::CatalogNativeSessions {
26051 route: route.clone(),
26052 limit: 2,
26053 };
26054 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
26055 .await
26056 .unwrap_err();
26057 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26058 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
26059 .await
26060 .unwrap();
26061 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
26062 panic!("native catalog returned a different response");
26063 };
26064 let summary = summary.expect("node must emit native session catalog summary metadata");
26065 assert_eq!(entries.len(), 1);
26066 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
26067 assert_eq!(entries[0].external_group, None);
26068 assert_eq!(entries[0].record_id, None);
26069 assert_eq!(entries[0].title, None);
26070 assert_eq!(entries[0].model, None);
26071 assert_eq!(entries[0].message_count, 0);
26072 let encoded_entries = serde_json::to_string(&entries).unwrap();
26073 assert!(!encoded_entries.contains("stable-session"));
26074 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
26075 let page_request = NodeRequest::PageNativeSessions {
26076 route: route.clone(),
26077 window: crate::protocol::NativeSessionCatalogWindow::Recent,
26078 catalog_revision: summary.catalog_revision,
26079 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26080 after_selection_id: None,
26081 limit: 2,
26082 };
26083 let observer = process_request_inner(
26084 &shared,
26085 7,
26086 ClientRole::Observer,
26087 page_request.clone(),
26088 )
26089 .await
26090 .unwrap_err();
26091 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26092 let page_response = process_request_inner(
26093 &shared,
26094 7,
26095 ClientRole::Operator,
26096 page_request.clone(),
26097 )
26098 .await
26099 .unwrap();
26100 assert!(matches!(
26101 page_response,
26102 NodeResponse::NativeSessionsPaged { page, .. }
26103 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
26104 ));
26105 let stale = process_request_inner(
26106 &shared,
26107 7,
26108 ClientRole::Operator,
26109 NodeRequest::PageNativeSessions {
26110 route: route.clone(),
26111 window: crate::protocol::NativeSessionCatalogWindow::Recent,
26112 catalog_revision: summary.catalog_revision.wrapping_add(1),
26113 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26114 after_selection_id: None,
26115 limit: 2,
26116 },
26117 )
26118 .await
26119 .unwrap_err();
26120 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
26121 let selection = NativeSessionSelection {
26122 route: route.clone(),
26123 catalog_revision: summary.catalog_revision,
26124 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26125 selection_id: entries[0].selection_id.clone(),
26126 };
26127 let preview_response = process_request_inner(
26128 &shared,
26129 7,
26130 ClientRole::Operator,
26131 NodeRequest::PreviewNativeSession {
26132 selection: selection.clone(),
26133 message_limit: 1,
26134 },
26135 )
26136 .await
26137 .unwrap();
26138 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
26139 panic!("native preview returned a different response");
26140 };
26141 assert_eq!(preview.messages.len(), 1);
26142 assert!(preview.message_count_exact);
26143 assert_eq!(preview.messages[0].text, "raw answer");
26144 assert!(preview.truncated);
26145
26146 let index_request = NodeRequest::IndexNativeSession {
26147 selection: selection.clone(),
26148 display_name: "Saved Claude".to_owned(),
26149 };
26150 let observer = process_request_inner(
26151 &shared,
26152 7,
26153 ClientRole::Observer,
26154 index_request.clone(),
26155 )
26156 .await
26157 .unwrap_err();
26158 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26159 let without_controller = process_request_inner(
26160 &shared,
26161 7,
26162 ClientRole::Operator,
26163 index_request.clone(),
26164 )
26165 .await
26166 .unwrap_err();
26167 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
26168 let acquired = process_request_inner(
26169 &shared,
26170 7,
26171 ClientRole::Operator,
26172 NodeRequest::AcquireController { lease_ms: 5_000 },
26173 )
26174 .await
26175 .unwrap();
26176 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
26177 let indexed = process_request_inner(
26178 &shared,
26179 7,
26180 ClientRole::Operator,
26181 index_request,
26182 )
26183 .await
26184 .unwrap();
26185 assert!(indexed.native_session_index_contract_is_valid());
26186 let NodeResponse::NativeSessionIndexed {
26187 selection: echoed_selection,
26188 record,
26189 } = indexed else {
26190 panic!("native session index returned a different response");
26191 };
26192 assert_eq!(echoed_selection, selection);
26193 let recataloged = process_request_inner(
26194 &shared,
26195 7,
26196 ClientRole::Operator,
26197 NodeRequest::CatalogNativeSessions {
26198 route,
26199 limit: 2,
26200 },
26201 )
26202 .await
26203 .unwrap();
26204 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
26205 panic!("native catalog returned a different response after indexing");
26206 };
26207 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
26208
26209 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
26210 let external_catalog = process_request_inner(
26211 &shared,
26212 7,
26213 ClientRole::Operator,
26214 NodeRequest::CatalogNativeSessions {
26215 route: unregistered_route.clone(),
26216 limit: 2,
26217 },
26218 )
26219 .await
26220 .unwrap();
26221 let external_json = serde_json::to_string(&external_catalog).unwrap();
26222 assert!(!external_json.contains("outside-session"));
26223 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
26224 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
26225 assert!(!external_json.contains("\"session_id\""));
26226 assert!(!external_json.contains("\"cwd\""));
26227 assert!(!external_json.contains("\"path\""));
26228 let NodeResponse::NativeSessionsCataloged {
26229 route: echoed_route,
26230 entries: external_entries,
26231 summary: Some(external_summary),
26232 } = external_catalog
26233 else {
26234 panic!("unregistered native catalog returned a different response");
26235 };
26236 assert_eq!(echoed_route, unregistered_route);
26237 assert_eq!(external_entries.len(), 1);
26238 assert_eq!(external_entries[0].record_id, None);
26239 let external_group = external_entries[0]
26240 .external_group
26241 .as_ref()
26242 .expect("unregistered session must have an external group");
26243 assert_eq!(
26244 external_group.kind,
26245 gate4agent_types::NativeSessionExternalGroupKind::Project,
26246 );
26247 assert!(external_group.group_id.starts_with("external-"));
26248 assert_eq!(
26249 external_group.display_name,
26250 outside_root.file_name().unwrap().to_string_lossy(),
26251 );
26252 let external_selection = NativeSessionSelection {
26253 route: unregistered_route,
26254 catalog_revision: external_summary.catalog_revision,
26255 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
26256 selection_id: external_entries[0].selection_id.clone(),
26257 };
26258 let external_preview = process_request_inner(
26259 &shared,
26260 7,
26261 ClientRole::Operator,
26262 NodeRequest::PreviewNativeSession {
26263 selection: external_selection.clone(),
26264 message_limit: 1,
26265 },
26266 )
26267 .await
26268 .unwrap();
26269 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
26270 assert!(!external_preview_json.contains("outside-session"));
26271 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
26272 assert!(!external_preview_json.contains("\"session_id\""));
26273 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
26274 panic!("unregistered native preview returned a different response");
26275 };
26276 assert_eq!(preview.messages.len(), 1);
26277 let external_index = process_request_inner(
26278 &shared,
26279 7,
26280 ClientRole::Operator,
26281 NodeRequest::IndexNativeSession {
26282 selection: external_selection,
26283 display_name: "External Claude".to_owned(),
26284 },
26285 )
26286 .await
26287 .unwrap_err();
26288 assert_eq!(
26289 external_index.code,
26290 NodeFailureCode::WorkspaceRegistrationRequired,
26291 );
26292 let record_response = process_request_inner(
26293 &shared,
26294 7,
26295 ClientRole::Operator,
26296 NodeRequest::PreviewSessionRecord {
26297 record_id: record.record_id.clone(),
26298 message_limit: 2,
26299 },
26300 )
26301 .await
26302 .unwrap();
26303 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
26304 panic!("record preview returned a different response");
26305 };
26306 assert_eq!(record_id, record.record_id);
26307 assert_eq!(preview.messages.len(), 2);
26308 let encoded = serde_json::to_string(&preview).unwrap();
26309 assert!(!encoded.contains("stable-session"));
26310 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
26311 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
26312 panic!("record preview history summary returned a different response");
26313 };
26314 let summaries = events
26315 .iter()
26316 .filter_map(|envelope| match &envelope.event {
26317 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
26318 if record_id == &record.record_id =>
26319 {
26320 Some(*summary)
26321 }
26322 _ => None,
26323 })
26324 .collect::<Vec<_>>();
26325 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
26326 let summary_json = serde_json::to_string(&summaries).unwrap();
26327 for private in [
26328 "stable-session",
26329 "raw user text must not cross the catalog wire",
26330 "raw answer",
26331 "Workspace review",
26332 "claude-sonnet",
26333 ] {
26334 assert!(!summary_json.contains(private), "{private}");
26335 }
26336 let identity = record.provider_session.clone().unwrap();
26337 assert_eq!(
26338 shared.revalidate_session_record_preview(&record, &identity),
26339 Ok(())
26340 );
26341 shared.remove_record_memory(&record.record_id);
26342 assert_eq!(
26343 shared
26344 .revalidate_session_record_preview(&record, &identity)
26345 .unwrap_err()
26346 .code,
26347 NodeFailureCode::UnknownSessionRecord
26348 );
26349 let released = process_request_inner(
26350 &shared,
26351 7,
26352 ClientRole::Operator,
26353 NodeRequest::ReleaseController,
26354 )
26355 .await
26356 .unwrap();
26357 assert!(matches!(released, NodeResponse::Controller { controller: None }));
26358 assert!(shared.controller_state().is_none());
26359 std::fs::remove_dir_all(workspace_root).unwrap();
26360 std::fs::remove_dir_all(history_root).unwrap();
26361 std::fs::remove_dir_all(outside_root).unwrap();
26362 }
26363
26364 #[tokio::test]
26365 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
26366 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
26367 std::fs::create_dir_all(&history_root).unwrap();
26368 let history = NativeHistoryConfig::new(vec![
26369 NativeHistoryRoot::new(
26370 gate4agent_types::AdapterId::new("claude-code").unwrap(),
26371 HistorySourceLayout::SingleNdjson,
26372 history_root.clone(),
26373 )
26374 .unwrap(),
26375 ])
26376 .unwrap();
26377 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
26378 let held = catalog.lock().unwrap();
26379 let result = timeout(
26380 Duration::from_secs(1),
26381 run_native_session_catalog_operation(
26382 Arc::clone(&catalog),
26383 "native session catalog",
26384 |_| Ok::<(), std::convert::Infallible>(()),
26385 ),
26386 )
26387 .await
26388 .expect("concurrent native catalog request must not wait for the operation deadline")
26389 .unwrap_err();
26390 assert_eq!(result.code, NodeFailureCode::BackendBusy);
26391 drop(held);
26392 std::fs::remove_dir_all(history_root).unwrap();
26393 }
26394
26395 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
26396 let unique = std::time::SystemTime::now()
26397 .duration_since(std::time::UNIX_EPOCH)
26398 .unwrap()
26399 .as_nanos();
26400 std::env::temp_dir().join(format!(
26401 "gate4agent-node-{label}-{}-{unique}",
26402 std::process::id(),
26403 ))
26404 }
26405
26406 fn git_snapshot_for_parser() -> GitSnapshot {
26407 GitSnapshot {
26408 is_repository: true,
26409 branch: Some("main".to_owned()),
26410 status: Vec::new(),
26411 recent_commits: Vec::new(),
26412 worktrees: Vec::new(),
26413 managed_worktree: None,
26414 truncated: false,
26415 diagnostic: None,
26416 }
26417 }
26418
26419 #[tokio::test]
26420 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
26421 let root = temporary_workspace_root("managed-git-scope");
26422 std::fs::create_dir_all(&root).unwrap();
26423 run_test_git(&root, &["init"]);
26424 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
26425 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
26426 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
26427 run_test_git(&root, &["add", "seed.txt"]);
26428 run_test_git(&root, &["commit", "-m", "seed"]);
26429 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
26430
26431 let workspace_id = WorkspaceId::new("managed-a").unwrap();
26432 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26433 let catalog = active_registry().unwrap();
26434 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26435 let shared = NodeShared::new(
26436 handle,
26437 "fixture-token".to_owned(),
26438 NodeId::new("node-1").unwrap(),
26439 vec![workspace],
26440 vec![agent("claude")],
26441 );
26442 let mut lease = ManagedWorktreeLeaseRecord {
26443 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
26444 source_workspace_id: WorkspaceId::new("primary").unwrap(),
26445 workspace_id: workspace_id.clone(),
26446 profile_id: WorktreeProfileId::new("default").unwrap(),
26447 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
26448 target_root: root.to_string_lossy().into_owned(),
26449 branch: "gate4agent/a".to_owned(),
26450 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
26451 expected_head: None,
26452 retention: ManagedWorktreeRetention::Retain,
26453 state: ManagedWorktreeLeaseState::InUse,
26454 session_holders: vec![ManagedWorktreeSessionHolder {
26455 incarnation_id: shared.incarnation_id,
26456 instance_id: AgentInstanceId(7),
26457 generation: SessionGeneration(1),
26458 }],
26459 record_holders: Vec::new(),
26460 cleanup_failure: None,
26461 created_at_unix_ms: 1,
26462 updated_at_unix_ms: 1,
26463 };
26464 *shared
26465 .managed_worktrees
26466 .lock()
26467 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
26468 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
26469
26470 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
26471 let scope = inspection.git.managed_worktree.unwrap();
26472 assert_eq!(scope.lease_id, lease.lease_id);
26473 assert_eq!(scope.branch, "gate4agent/a");
26474 assert_eq!(scope.active_session_count, 1);
26475
26476 lease.branch = "gate4agent/b".to_owned();
26477 *shared
26478 .managed_worktrees
26479 .lock()
26480 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
26481 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
26482 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
26483 assert!(mismatch.git.managed_worktree.is_none());
26484 std::fs::remove_dir_all(root).unwrap();
26485 }
26486
26487 #[tokio::test]
26488 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
26489 let root = temporary_workspace_root("workspace-file-write-controller");
26490 std::fs::create_dir_all(root.join("src")).unwrap();
26491 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
26492 let workspace_id = WorkspaceId::new("primary").unwrap();
26493 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26494 let catalog = active_registry().unwrap();
26495 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26496 let shared = NodeShared::new(
26497 handle,
26498 "fixture-token".to_owned(),
26499 NodeId::new("node-1").unwrap(),
26500 vec![workspace],
26501 vec![agent("claude")],
26502 );
26503 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
26504 let expected_revision = workspace_file_revision(b"old\n");
26505 let request = NodeRequest::WriteWorkspaceFile {
26506 workspace_id: workspace_id.clone(),
26507 path: path.clone(),
26508 expected_revision: expected_revision.clone(),
26509 text: "new\n".to_owned(),
26510 };
26511 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26512 .await
26513 .unwrap_err();
26514 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26515 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26516 .await
26517 .unwrap_err();
26518 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26519 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
26520 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
26521 let NodeResponse::WorkspaceFileWritten { file } = response else {
26522 panic!("workspace file write returned another response");
26523 };
26524 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
26525 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
26526 let stale = process_request_inner(
26527 &shared,
26528 77,
26529 ClientRole::Operator,
26530 NodeRequest::WriteWorkspaceFile {
26531 workspace_id,
26532 path,
26533 expected_revision,
26534 text: "lost update\n".to_owned(),
26535 },
26536 )
26537 .await
26538 .unwrap_err();
26539 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
26540 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
26541 std::fs::remove_dir_all(root).unwrap();
26542 }
26543
26544 #[tokio::test]
26545 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
26546 let root = temporary_workspace_root("workspace-entry-create-controller");
26547 std::fs::create_dir_all(root.join("src")).unwrap();
26548 let workspace_id = WorkspaceId::new("primary").unwrap();
26549 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26550 let catalog = active_registry().unwrap();
26551 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26552 let shared = NodeShared::new(
26553 handle,
26554 "fixture-token".to_owned(),
26555 NodeId::new("node-1").unwrap(),
26556 vec![workspace],
26557 vec![agent("claude")],
26558 );
26559 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
26560 let request = NodeRequest::CreateWorkspaceFile {
26561 workspace_id: workspace_id.clone(),
26562 path: path.clone(),
26563 };
26564 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26565 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
26566 }));
26567 assert!(!request_uses_unnegotiated_capability(
26568 &request,
26569 &baseline_capabilities().unwrap(),
26570 ));
26571 assert!(request_uses_unnegotiated_capability(&request, &[]));
26572 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26573 .await
26574 .unwrap_err();
26575 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26576 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26577 .await
26578 .unwrap_err();
26579 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26580 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
26581
26582 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26583 .await
26584 .unwrap();
26585 let NodeResponse::WorkspaceFileCreated { file } = response else {
26586 panic!("workspace file create returned another response");
26587 };
26588 assert_eq!(file.workspace_id, workspace_id);
26589 assert_eq!(file.path, path);
26590 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
26591 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
26592
26593 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
26594 .await
26595 .unwrap_err();
26596 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
26597
26598 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
26599 let response = process_request_inner(
26600 &shared,
26601 77,
26602 ClientRole::Operator,
26603 NodeRequest::CreateWorkspaceDirectory {
26604 workspace_id: workspace_id.clone(),
26605 path: directory_path.clone(),
26606 },
26607 )
26608 .await
26609 .unwrap();
26610 assert_eq!(
26611 response,
26612 NodeResponse::WorkspaceDirectoryCreated {
26613 workspace_id: workspace_id.clone(),
26614 entry: WorkspaceEntry {
26615 relative_path: directory_path,
26616 kind: WorkspaceEntryKind::Directory,
26617 },
26618 },
26619 );
26620 assert!(root.join("src/nested").is_dir());
26621
26622 let missing_parent = process_request_inner(
26623 &shared,
26624 77,
26625 ClientRole::Operator,
26626 NodeRequest::CreateWorkspaceFile {
26627 workspace_id: workspace_id.clone(),
26628 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
26629 },
26630 )
26631 .await
26632 .unwrap_err();
26633 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
26634 let non_directory_parent = process_request_inner(
26635 &shared,
26636 77,
26637 ClientRole::Operator,
26638 NodeRequest::CreateWorkspaceFile {
26639 workspace_id,
26640 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
26641 },
26642 )
26643 .await
26644 .unwrap_err();
26645 assert_eq!(
26646 non_directory_parent.code,
26647 NodeFailureCode::RepositoryParentNotDirectory,
26648 );
26649 std::fs::remove_dir_all(root).unwrap();
26650 }
26651
26652 #[tokio::test]
26653 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
26654 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
26655 std::fs::create_dir_all(root.join("src")).unwrap();
26656 #[cfg(windows)]
26657 let root = std::fs::canonicalize(root).unwrap();
26658 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
26659 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
26660 let barrier = Arc::new(std::sync::Barrier::new(2));
26661 let done = Arc::new(AtomicBool::new(false));
26662 let task_commit_state = Arc::clone(&commit_state);
26663 let task_barrier = Arc::clone(&barrier);
26664 let task_done = Arc::clone(&done);
26665 let task_root = root.clone();
26666 let task = tokio::task::spawn_blocking(move || {
26667 task_barrier.wait();
26668 let result = create_workspace_file_on_disk(
26669 &task_root,
26670 &path,
26671 &task_commit_state,
26672 );
26673 task_done.store(true, Ordering::Release);
26674 result
26675 });
26676
26677 let error = settle_workspace_entry_create(
26678 task,
26679 Arc::clone(&commit_state),
26680 Duration::from_millis(10),
26681 "fixture deadline",
26682 "fixture task failed",
26683 )
26684 .await
26685 .unwrap_err();
26686 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
26687 assert_eq!(
26688 commit_state.load(Ordering::Acquire),
26689 WORKSPACE_ENTRY_CREATE_CANCELED,
26690 );
26691
26692 barrier.wait();
26693 timeout(Duration::from_secs(1), async {
26694 while !done.load(Ordering::Acquire) {
26695 tokio::task::yield_now().await;
26696 }
26697 })
26698 .await
26699 .expect("canceled blocking create did not settle");
26700 assert!(!root.join("src/late.rs").exists());
26701 std::fs::remove_dir_all(root).unwrap();
26702 }
26703
26704}